diff --git a/nixos/doc/manual/release-notes/rl-2411.section.md b/nixos/doc/manual/release-notes/rl-2411.section.md index 209655cc9925ac..248b173c3a046c 100644 --- a/nixos/doc/manual/release-notes/rl-2411.section.md +++ b/nixos/doc/manual/release-notes/rl-2411.section.md @@ -129,6 +129,8 @@ - [Dashy](https://dashy.to), an open source, highly customizable, easy to use, privacy-respecting dashboard app. Available as [services.dashy](options.html#opt-services.dashy). +- [Stash](https://github.com/stashapp/stash), An organizer for your adult videos/images, written in Go. Available as [services.stash](#opt-services.stash.enable). + - [QGroundControl], a ground station support and configuration manager for the PX4 and APM Flight Stacks. Available as [programs.qgroundcontrol](options.html#opt-programs.qgroundcontrol.enable). - [Eintopf](https://eintopf.info), a community event and calendar web application. Available as [services.eintopf](options.html#opt-services.eintopf.enable). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 92e8db5ee8e1c2..2840f53dad7cb8 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1556,6 +1556,7 @@ ./services/web-apps/snipe-it.nix ./services/web-apps/sogo.nix ./services/web-apps/stirling-pdf.nix + ./services/web-apps/stash.nix ./services/web-apps/trilium.nix ./services/web-apps/tt-rss.nix ./services/web-apps/vikunja.nix diff --git a/nixos/modules/services/web-apps/stash.nix b/nixos/modules/services/web-apps/stash.nix new file mode 100644 index 00000000000000..60c8ad8a8eb4a0 --- /dev/null +++ b/nixos/modules/services/web-apps/stash.nix @@ -0,0 +1,585 @@ +{ + config, + pkgs, + lib, + ... +}: +let + inherit (lib) + getExe + literalExpression + mkEnableOption + mkIf + mkOption + mkPackageOption + optionalString + toUpper + types + ; + + cfg = config.services.stash; + + stashType = types.submodule { + options = { + path = mkOption { + type = types.path; + description = "location of your media files"; + }; + excludevideo = mkOption { + type = types.bool; + default = false; + description = "Whether to exclude video files from being scanned into Stash"; + }; + excludeimage = mkOption { + type = types.bool; + default = false; + description = "Whether to exclude image files from being scanned into Stash"; + }; + }; + }; + stashBoxType = types.submodule { + options = { + name = mkOption { + type = types.str; + description = "The name of the Stash Box"; + }; + endpoint = mkOption { + type = types.str; + description = "URL to the Stash Box graphql api"; + }; + apikey = mkOption { + type = types.str; + description = "Stash Box API key"; + }; + }; + }; + + recentlyReleased = mode: { + __typename = "CustomFilter"; + message = { + id = "recently_released_objects"; + values.objects = mode; + }; + mode = toUpper mode; + sortBy = "date"; + direction = "DESC"; + }; + recentlyAdded = mode: { + __typename = "CustomFilter"; + message = { + id = "recently_added_objects"; + values.objects = mode; + }; + mode = toUpper mode; + sortBy = "created_at"; + direction = "DESC"; + }; + uiPresets = { + recentlyReleasedScenes = recentlyReleased "Scenes"; + recentlyAddedScenes = recentlyAdded "Scenes"; + recentlyReleasedGalleries = recentlyReleased "Galleries"; + recentlyAddedGalleries = recentlyAdded "Galleries"; + recentlyAddedImages = recentlyAdded "Images"; + recentlyReleasedMovies = recentlyReleased "Movies"; + recentlyAddedMovies = recentlyAdded "Movies"; + recentlyAddedStudios = recentlyAdded "Studios"; + recentlyAddedPerformers = recentlyAdded "Performers"; + }; + + settingsFormat = pkgs.formats.yaml { }; + settingsFile = settingsFormat.generate "config.yml" cfg.settings; + settingsType = types.submodule { + freeformType = settingsFormat.type; + + options = { + host = mkOption { + type = types.str; + default = "localhost"; + example = "::1"; + description = "The ip address that Stash should bind to."; + }; + + port = mkOption { + type = types.port; + default = 9999; + example = 1234; + description = "The port that Stash should listen on."; + }; + + stash = mkOption { + type = types.listOf stashType; + description = '' + Add directories containing your adult videos and images. + Stash will use these directories to find videos and/or images during scanning. + ''; + example = literalExpression '' + { + stash = [ + { + Path = "/media/drive/videos"; + ExcludeImage = true; + } + ]; + } + ''; + }; + stash_boxes = mkOption { + type = types.listOf stashBoxType; + default = [ ]; + description = ''Stash-box facilitates automated tagging of scenes and performers based on fingerprints and filenames''; + example = literalExpression '' + { + stash_boxes = [ + { + name = "StashDB"; + endpoint = "https://stashdb.org/graphql"; + apikey = "aaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbb.cccccccccccccc"; + } + ]; + } + ''; + }; + ui.frontPageContent = mkOption { + description = "Search filters to display on the front page."; + type = types.either (types.listOf types.attrs) (types.functionTo (types.listOf types.attrs)); + default = presets: [ + presets.recentlyReleasedScenes + presets.recentlyAddedStudios + presets.recentlyReleasedMovies + presets.recentlyAddedPerformers + presets.recentlyReleasedGalleries + ]; + example = literalExpression '' + presets: [ + # To get the savedFilterId, you can query `{ findSavedFilters(mode: ) { id name } }` on localhost:9999/graphql + { + __typename = "SavedFilter"; + savedFilterId = 1; + } + # basic custom filter + { + __typename = "CustomFilter"; + title = "Random Scenes"; + mode = "SCENES"; + sortBy = "random"; + direction = "DESC"; + } + presets.recentlyAddedImages + ] + ''; + apply = type: if builtins.isFunction type then (type uiPresets) else type; + }; + blobs_path = mkOption { + type = types.path; + default = "${cfg.dataDir}/blobs"; + description = "Path to blobs"; + }; + cache = mkOption { + type = types.path; + default = "${cfg.dataDir}/cache"; + description = "Path to cache"; + }; + database = mkOption { + type = types.path; + default = "${cfg.dataDir}/go.sqlite"; + description = "Path to the SQLite database"; + }; + generated = mkOption { + type = types.path; + default = "${cfg.dataDir}/generated"; + description = "Path to generated files"; + }; + plugins_path = mkOption { + type = types.path; + default = "${cfg.dataDir}/plugins"; + description = "Path to scrapers"; + }; + scrapers_path = mkOption { + type = types.path; + default = "${cfg.dataDir}/scrapers"; + description = "Path to scrapers"; + }; + + blobs_storage = mkOption { + type = types.enum [ + "FILESYSTEM" + "DATABASE" + ]; + default = "FILESYSTEM"; + description = "Where to store blobs"; + }; + calculate_md5 = mkOption { + type = types.bool; + default = false; + description = "Whether to calculate MD5 checksums for scene video files"; + }; + create_image_clip_from_videos = mkOption { + type = types.bool; + default = false; + description = "Create Image Clips from Video extensions when Videos are disabled in Library"; + }; + dangerous_allow_public_without_auth = mkOption { + type = types.bool; + default = false; + description = "Learn more at https://docs.stashapp.cc/networking/authentication-required-when-accessing-stash-from-the-internet/"; + }; + gallery_cover_regex = mkOption { + type = types.str; + default = "(poster|cover|folder|board)\.[^\.]+$"; + description = "Regex used to identify images as gallery covers"; + }; + no_proxy = mkOption { + type = types.str; + default = "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12"; + description = "A list of domains for which the proxy must not be used"; + }; + nobrowser = mkOption { + type = types.bool; + default = true; + description = "If we should not auto-open a browser window on startup"; + }; + notifications_enabled = mkOption { + type = types.bool; + default = true; + description = "If we should send notifications to the desktop"; + }; + parallel_tasks = mkOption { + type = types.int; + default = 1; + description = "Number of parallel tasks to start during scan/generate"; + }; + preview_audio = mkOption { + type = types.bool; + default = true; + description = "Include audio stream in previews"; + }; + preview_exclude_end = mkOption { + type = types.int; + default = 0; + description = "Duration of start of video to exclude when generating previews"; + }; + preview_exclude_start = mkOption { + type = types.int; + default = 0; + description = "Duration of end of video to exclude when generating previews"; + }; + preview_segment_duration = mkOption { + type = types.float; + default = 0.75; + description = "Preview segment duration, in seconds"; + }; + preview_segments = mkOption { + type = types.int; + default = 12; + description = "Number of segments in a preview file"; + }; + security_tripwire_accessed_from_public_internet = mkOption { + type = types.nullOr types.str; + default = ""; + description = "Learn more at https://docs.stashapp.cc/networking/authentication-required-when-accessing-stash-from-the-internet/"; + }; + sequential_scanning = mkOption { + type = types.bool; + default = false; + description = "Modifies behaviour of the scanning functionality to generate support files (previews/sprites/phash) at the same time as fingerprinting/screenshotting"; + }; + show_one_time_moved_notification = mkOption { + type = types.bool; + default = true; + 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"; + }; + sound_on_preview = mkOption { + type = types.bool; + default = false; + description = "Enable sound on mouseover previews"; + }; + theme_color = mkOption { + type = types.str; + default = "#202b33"; + description = "Sets the `theme-color` property in the UI"; + }; + video_file_naming_algorithm = mkOption { + type = types.enum [ + "OSHASH" + "MD5" + ]; + default = "OSHASH"; + description = "Hash algorithm to use for generated file naming"; + }; + write_image_thumbnails = mkOption { + type = types.bool; + default = true; + description = "Write image thumbnails to disk when generating on the fly"; + }; + }; + }; + + pluginType = + kind: + mkOption { + type = types.listOf types.package; + default = [ ]; + description = '' + The ${kind} Stash should be started with. + ''; + apply = + srcs: + optionalString (srcs != [ ]) ( + pkgs.runCommand "stash-${kind}" + { + inherit srcs; + nativeBuildInputs = [ pkgs.yq-go ]; + preferLocalBuild = true; + } + '' + find $srcs -mindepth 1 -name '*.yml' | while read plugin_file; do + grep -q "^#pkgignore" "$plugin_file" && continue + + plugin_dir=$(dirname $plugin_file) + out_path=$out/$(basename $plugin_dir) + mkdir -p $out_path + ls $plugin_dir | xargs -I{} ln -sf "$plugin_dir/{}" $out_path + + env \ + plugin_id=$(basename $plugin_file .yml) \ + plugin_name="$(yq '.name' $plugin_file)" \ + plugin_description="$(yq '.description' $plugin_file)" \ + plugin_version="$(yq '.version' $plugin_file)" \ + plugin_files="$(find -L $out_path -mindepth 1 -type f -printf "%P\n")" \ + yq -n ' + .id = strenv(plugin_id) | + .name = strenv(plugin_name) | + ( + strenv(plugin_description) as $desc | + with(select($desc == "null"); .metadata = {}) | + with(select($desc != "null"); .metadata.description = $desc) + ) | + ( + strenv(plugin_version) as $ver | + with(select($ver == "null"); .version = "Unknown") | + with(select($ver != "null"); .version = $ver) + ) | + .date = (now | format_datetime("2006-01-02 15:04:05")) | + .files = (strenv(plugin_files) | split("\n")) + ' > $out_path/manifest + done + '' + ); + }; +in +{ + meta = { + buildDocsInSandbox = false; + maintainers = with lib.maintainers; [ DrakeTDL ]; + }; + + options = { + services.stash = { + enable = mkEnableOption "stash"; + + package = mkPackageOption pkgs "stash" { }; + + user = mkOption { + type = types.str; + default = "stash"; + description = "User under which Stash runs."; + }; + + group = mkOption { + type = types.str; + default = "stash"; + description = "Group under which Stash runs."; + }; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/stash"; + description = "The directory where Stash stores its files."; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = "Open ports in the firewall for the Stash web interface."; + }; + + username = mkOption { + type = types.nullOr types.nonEmptyStr; + default = null; + example = "admin"; + description = '' + Username for login. + + ::: {.warning} + This option takes precedence over {option}`services.stash.settings.username` + :: + + ''; + }; + + passwordFile = mkOption { + type = types.nullOr types.path; + default = null; + example = "/path/to/password/file"; + description = '' + Path to file containing password for login. + + ::: {.warning} + This option takes precedence over {option}`services.stash.settings.password` + :: + + ''; + }; + + jwtSecretKeyFile = mkOption { + type = types.path; + description = "Path to file containing a secret used to sign JWT tokens."; + }; + sessionStoreKeyFile = mkOption { + type = types.path; + description = "Path to file containing a secret for session store."; + }; + + mutableSettings = mkOption { + description = '' + Whether the Stash config.yml is writeable by Stash. + + 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. + 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. + ''; + type = types.bool; + default = true; + }; + mutablePlugins = mkEnableOption "Whether plugins/themes can be installed, updated, uninstalled manually."; + mutableScrapers = mkEnableOption "Whether scrapers can be installed, updated, uninstalled manually."; + plugins = pluginType "plugins"; + scrapers = pluginType "scrapers"; + + settings = mkOption { + type = settingsType; + description = "Stash configuration"; + }; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = + !lib.xor (cfg.username != null || cfg.settings.username or null != null) ( + cfg.passwordFile != null || cfg.settings.password or null != null + ); + message = "You must set either both username and password, or neither."; + } + ]; + + services.stash.settings = { + username = mkIf (cfg.username != null) cfg.username; + plugins_path = mkIf (!cfg.mutablePlugins) cfg.plugins; + scrapers_path = mkIf (!cfg.mutableScrapers) cfg.scrapers; + }; + + networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.settings.port ]; + + users.users.${cfg.user} = { + inherit (cfg) group; + isSystemUser = true; + home = cfg.dataDir; + }; + users.groups.${cfg.group} = { }; + + systemd = { + tmpfiles.settings."10-stash-datadir".${cfg.dataDir}."d" = { + inherit (cfg) user group; + mode = "0755"; + }; + services.stash = { + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + path = with pkgs; [ + ffmpeg-full + python3 + ruby + ]; + environment.STASH_CONFIG_FILE = "${cfg.dataDir}/config.yml"; + serviceConfig = { + DynamicUser = false; + User = cfg.user; + Group = cfg.group; + Restart = "on-failure"; + WorkingDirectory = cfg.dataDir; + StateDirectory = mkIf (cfg.dataDir == "/var/lib/stash") (baseNameOf cfg.dataDir); + ExecStartPre = pkgs.writers.writeBash "stash-setup.bash" ( + '' + install -d ${cfg.settings.generated} + if [[ ! -z "${toString cfg.mutableSettings}" || ! -f ${cfg.dataDir}/config.yml ]]; then + env \ + password=$(< ${cfg.passwordFile}) \ + jwtSecretKeyFile=$(< ${cfg.jwtSecretKeyFile}) \ + sessionStoreKeyFile=$(< ${cfg.sessionStoreKeyFile}) \ + ${lib.getExe pkgs.yq-go} ' + .jwt_secret_key = strenv(jwtSecretKeyFile) | + .session_store_key = strenv(sessionStoreKeyFile) | + ( + strenv(password) as $password | + with(select($password != ""); .password = $password) + ) + ' ${settingsFile} > ${cfg.dataDir}/config.yml + fi + '' + + optionalString cfg.mutablePlugins '' + install -d ${cfg.settings.plugins_path} + ls ${cfg.plugins} | xargs -I{} ln -sf '${cfg.plugins}/{}' ${cfg.settings.plugins_path} + '' + + optionalString cfg.mutableScrapers '' + install -d ${cfg.settings.scrapers_path} + ls ${cfg.scrapers} | xargs -I{} ln -sf '${cfg.scrapers}/{}' ${cfg.settings.scrapers_path} + '' + ); + ExecStart = getExe cfg.package; + + ProtectHome = "tmpfs"; + BindReadOnlyPaths = mkIf (cfg.settings != { }) (map (stash: "${stash.path}") cfg.settings.stash); + + # hardening + + DevicePolicy = "auto"; # needed for hardware acceleration + PrivateDevices = false; # needed for hardware acceleration + AmbientCapabilities = [ "" ]; + CapabilityBoundingSet = [ "" ]; + ProtectSystem = "full"; + LockPersonality = true; + NoNewPrivileges = true; + PrivateTmp = true; + PrivateUsers = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProcSubset = "pid"; + ProtectProc = "invisible"; + RemoveIPC = true; + RestrictAddressFamilies = [ + "AF_UNIX" + "AF_INET" + "AF_INET6" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + MemoryDenyWriteExecute = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "~@cpu-emulation" + "~@debug" + "~@mount" + "~@obsolete" + "~@privileged" + ]; + }; + }; + }; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 2952c67d19c28e..922b7c4a45bff8 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -969,6 +969,7 @@ in { stalwart-mail = handleTest ./stalwart-mail.nix {}; stargazer = runTest ./web-servers/stargazer.nix; starship = handleTest ./starship.nix {}; + stash = handleTestOn [ "x86_64-linux" "aarch64-linux" ] ./stash.nix {}; static-web-server = handleTest ./web-servers/static-web-server.nix {}; step-ca = handleTestOn ["x86_64-linux"] ./step-ca.nix {}; stratis = handleTest ./stratis {}; diff --git a/nixos/tests/stash.nix b/nixos/tests/stash.nix new file mode 100644 index 00000000000000..838a5e8a43c6dd --- /dev/null +++ b/nixos/tests/stash.nix @@ -0,0 +1,80 @@ +import ./make-test-python.nix ( + let + host = "127.0.0.1"; + port = 1234; + dataDir = "/stash"; + in + { pkgs, ... }: + { + name = "stash"; + meta.maintainers = pkgs.stash.meta.maintainers; + + nodes.machine = { + services.stash = { + inherit dataDir; + enable = true; + + username = "test"; + passwordFile = pkgs.writeText "stash-password" "MyPassword"; + + jwtSecretKeyFile = pkgs.writeText "jwt_secret_key" "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + sessionStoreKeyFile = pkgs.writeText "session_store_key" "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + plugins = + let + src = pkgs.fetchFromGitHub { + owner = "stashapp"; + repo = "CommunityScripts"; + rev = "9b6fac4934c2fac2ef0859ea68ebee5111fc5be5"; + hash = "sha256-PO3J15vaA7SD4r/LyHlXjnpaeYAN9Q++O94bIWdz7OA="; + }; + in + [ + (pkgs.runCommand "stashNotes" { inherit src; } '' + mkdir -p $out/plugins + cp -r $src/plugins/stashNotes $out/plugins/stashNotes + '') + (pkgs.runCommand "Theme-Plex" { inherit src; } '' + mkdir -p $out/plugins + cp -r $src/themes/Theme-Plex $out/plugins/Theme-Plex + '') + ]; + + mutableScrapers = true; + scrapers = + let + src = pkgs.fetchFromGitHub { + owner = "stashapp"; + repo = "CommunityScrapers"; + rev = "2ece82d17ddb0952c16842b0775274bcda598d81"; + hash = "sha256-AEmnvM8Nikhue9LNF9dkbleYgabCvjKHtzFpMse4otM="; + }; + in + [ + (pkgs.runCommand "FTV" { inherit src; } '' + mkdir -p $out/scrapers/FTV + cp -r $src/scrapers/FTV.yml $out/scrapers/FTV + '') + ]; + + settings = { + inherit host port; + + stash = [ { path = "/srv"; } ]; + }; + }; + }; + + testScript = '' + machine.wait_for_unit("stash.service") + machine.wait_for_open_port(${toString port}, "${host}") + machine.succeed("curl --fail http://${host}:${toString port}/") + + with subtest("Test plugins/scrapers"): + with subtest("mutable plugins directory should not exist"): + machine.fail("test -d ${dataDir}/plugins") + with subtest("mutable scrapers directory should exist and scraper FTV should be linked"): + machine.succeed("test -L ${dataDir}/scrapers/FTV") + ''; + } +) diff --git a/pkgs/by-name/st/stash/package.nix b/pkgs/by-name/st/stash/package.nix index c3f8b66ce39081..5088847950dddf 100644 --- a/pkgs/by-name/st/stash/package.nix +++ b/pkgs/by-name/st/stash/package.nix @@ -3,6 +3,7 @@ fetchFromGitHub, fetchYarnDeps, lib, + nixosTests, nodejs, stash, stdenv, @@ -108,6 +109,7 @@ buildGoModule { inherit frontend; updateScript = ./update.py; tests = { + inherit (nixosTests) stash; version = testers.testVersion { package = stash; version = "v${version} (${gitHash}) - Unofficial Build - 1970-01-01 00:00:00";