nixpkgs/nixos/modules/services/web-apps/stash.nix
piegames 8a71705aba nixos/stash: Fix regex in default value
`\.` in a Nix string is just `.`, so it will match on any characters
instead of just dot.
2025-05-01 13:18:19 +02:00

585 lines
19 KiB
Nix

{
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: <FilterMode>) { 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"
];
};
};
};
};
}