stash/internal/manager/config/config.go

1278 lines
33 KiB
Go

package config
import (
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
// "github.com/sasha-s/go-deadlock" // if you have deadlock issues
"golang.org/x/crypto/bcrypt"
"github.com/spf13/viper"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/hash"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/paths"
)
var officialBuild string
const (
Stash = "stash"
Cache = "cache"
Generated = "generated"
Metadata = "metadata"
Downloads = "downloads"
ApiKey = "api_key"
Username = "username"
Password = "password"
MaxSessionAge = "max_session_age"
DefaultMaxSessionAge = 60 * 60 * 1 // 1 hours
Database = "database"
Exclude = "exclude"
ImageExclude = "image_exclude"
VideoExtensions = "video_extensions"
ImageExtensions = "image_extensions"
GalleryExtensions = "gallery_extensions"
CreateGalleriesFromFolders = "create_galleries_from_folders"
// CalculateMD5 is the config key used to determine if MD5 should be calculated
// for video files.
CalculateMD5 = "calculate_md5"
// VideoFileNamingAlgorithm is the config key used to determine what hash
// should be used when generating and using generated files for scenes.
VideoFileNamingAlgorithm = "video_file_naming_algorithm"
MaxTranscodeSize = "max_transcode_size"
MaxStreamingTranscodeSize = "max_streaming_transcode_size"
ParallelTasks = "parallel_tasks"
parallelTasksDefault = 1
PreviewPreset = "preview_preset"
PreviewAudio = "preview_audio"
previewAudioDefault = true
PreviewSegmentDuration = "preview_segment_duration"
previewSegmentDurationDefault = 0.75
PreviewSegments = "preview_segments"
previewSegmentsDefault = 12
PreviewExcludeStart = "preview_exclude_start"
previewExcludeStartDefault = "0"
PreviewExcludeEnd = "preview_exclude_end"
previewExcludeEndDefault = "0"
WriteImageThumbnails = "write_image_thumbnails"
writeImageThumbnailsDefault = true
Host = "host"
hostDefault = "0.0.0.0"
Port = "port"
portDefault = 9999
ExternalHost = "external_host"
// key used to sign JWT tokens
JWTSignKey = "jwt_secret_key"
// key used for session store
SessionStoreKey = "session_store_key"
// scraping options
ScrapersPath = "scrapers_path"
ScraperUserAgent = "scraper_user_agent"
ScraperCertCheck = "scraper_cert_check"
ScraperCDPPath = "scraper_cdp_path"
ScraperExcludeTagPatterns = "scraper_exclude_tag_patterns"
// stash-box options
StashBoxes = "stash_boxes"
// plugin options
PluginsPath = "plugins_path"
// i18n
Language = "language"
// served directories
// this should be manually configured only
CustomServedFolders = "custom_served_folders"
// UI directory. Overrides to serve the UI from a specific location
// rather than use the embedded UI.
CustomUILocation = "custom_ui_location"
// Interface options
MenuItems = "menu_items"
SoundOnPreview = "sound_on_preview"
WallShowTitle = "wall_show_title"
defaultWallShowTitle = true
CustomPerformerImageLocation = "custom_performer_image_location"
MaximumLoopDuration = "maximum_loop_duration"
AutostartVideo = "autostart_video"
AutostartVideoOnPlaySelected = "autostart_video_on_play_selected"
autostartVideoOnPlaySelectedDefault = true
ContinuePlaylistDefault = "continue_playlist_default"
ShowStudioAsText = "show_studio_as_text"
CSSEnabled = "cssEnabled"
ShowScrubber = "show_scrubber"
showScrubberDefault = true
WallPlayback = "wall_playback"
defaultWallPlayback = "video"
SlideshowDelay = "slideshow_delay"
defaultSlideshowDelay = 5000
DisableDropdownCreatePerformer = "disable_dropdown_create.performer"
DisableDropdownCreateStudio = "disable_dropdown_create.studio"
DisableDropdownCreateTag = "disable_dropdown_create.tag"
HandyKey = "handy_key"
FunscriptOffset = "funscript_offset"
ThemeColor = "theme_color"
DefaultThemeColor = "#202b33"
// Security
dangerousAllowPublicWithoutAuth = "dangerous_allow_public_without_auth"
dangerousAllowPublicWithoutAuthDefault = "false"
SecurityTripwireAccessedFromPublicInternet = "security_tripwire_accessed_from_public_internet"
securityTripwireAccessedFromPublicInternetDefault = ""
// DLNA options
DLNAServerName = "dlna.server_name"
DLNADefaultEnabled = "dlna.default_enabled"
DLNADefaultIPWhitelist = "dlna.default_whitelist"
DLNAInterfaces = "dlna.interfaces"
// Logging options
LogFile = "logFile"
LogOut = "logOut"
defaultLogOut = true
LogLevel = "logLevel"
defaultLogLevel = "Info"
LogAccess = "logAccess"
defaultLogAccess = true
// Default settings
DefaultScanSettings = "defaults.scan_task"
DefaultIdentifySettings = "defaults.identify_task"
DefaultAutoTagSettings = "defaults.auto_tag_task"
DefaultGenerateSettings = "defaults.generate_task"
DeleteFileDefault = "defaults.delete_file"
DeleteGeneratedDefault = "defaults.delete_generated"
deleteGeneratedDefaultDefault = true
// Desktop Integration Options
NoBrowser = "noBrowser"
NoBrowserDefault = false
NotificationsEnabled = "notifications_enabled"
NotificationsEnabledDefault = true
ShowOneTimeMovedNotification = "show_one_time_moved_notification"
ShowOneTimeMovedNotificationDefault = false
// File upload options
MaxUploadSize = "max_upload_size"
)
// slice default values
var (
defaultVideoExtensions = []string{"m4v", "mp4", "mov", "wmv", "avi", "mpg", "mpeg", "rmvb", "rm", "flv", "asf", "mkv", "webm"}
defaultImageExtensions = []string{"png", "jpg", "jpeg", "gif", "webp"}
defaultGalleryExtensions = []string{"zip", "cbz"}
defaultMenuItems = []string{"scenes", "images", "movies", "markers", "galleries", "performers", "studios", "tags"}
)
type MissingConfigError struct {
missingFields []string
}
func (e MissingConfigError) Error() string {
return fmt.Sprintf("missing the following mandatory settings: %s", strings.Join(e.missingFields, ", "))
}
// StashBoxError represents configuration errors of Stash-Box
type StashBoxError struct {
msg string
}
func (s *StashBoxError) Error() string {
// "Stash-box" is a proper noun and is therefore capitcalized
return "Stash-box: " + s.msg
}
func IsOfficialBuild() bool {
return officialBuild == "true"
}
type Instance struct {
// main instance - backed by config file
main *viper.Viper
// override instance - populated from flags/environment
// not written to config file
overrides *viper.Viper
cpuProfilePath string
isNewSystem bool
// configUpdates chan int
certFile string
keyFile string
sync.RWMutex
// deadlock.RWMutex // for deadlock testing/issues
}
var instance *Instance
func (i *Instance) IsNewSystem() bool {
return i.isNewSystem
}
func (i *Instance) SetConfigFile(fn string) {
i.Lock()
defer i.Unlock()
i.main.SetConfigFile(fn)
}
func (i *Instance) InitTLS() {
configDirectory := i.GetConfigPath()
tlsPaths := []string{
configDirectory,
paths.GetStashHomeDirectory(),
}
i.certFile = fsutil.FindInPaths(tlsPaths, "stash.crt")
i.keyFile = fsutil.FindInPaths(tlsPaths, "stash.key")
}
func (i *Instance) GetTLSFiles() (certFile, keyFile string) {
return i.certFile, i.keyFile
}
func (i *Instance) HasTLSConfig() bool {
certFile, keyFile := i.GetTLSFiles()
return certFile != "" && keyFile != ""
}
// GetCPUProfilePath returns the path to the CPU profile file to output
// profiling info to. This is set only via a commandline flag. Returns an
// empty string if not set.
func (i *Instance) GetCPUProfilePath() string {
return i.cpuProfilePath
}
func (i *Instance) GetNoBrowser() bool {
return i.getBool(NoBrowser)
}
func (i *Instance) GetNotificationsEnabled() bool {
return i.getBool(NotificationsEnabled)
}
// func (i *Instance) GetConfigUpdatesChannel() chan int {
// return i.configUpdates
// }
// GetShowOneTimeMovedNotification shows 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, should be shown.
// It is true when an existing system is started after upgrading, and set to false forever after it is shown.
func (i *Instance) GetShowOneTimeMovedNotification() bool {
return i.getBool(ShowOneTimeMovedNotification)
}
func (i *Instance) Set(key string, value interface{}) {
// if key == MenuItems {
// i.configUpdates <- 0
// }
i.Lock()
defer i.Unlock()
i.main.Set(key, value)
}
func (i *Instance) SetPassword(value string) {
// if blank, don't bother hashing; we want it to be blank
if value == "" {
i.Set(Password, "")
} else {
i.Set(Password, hashPassword(value))
}
}
func (i *Instance) Write() error {
i.Lock()
defer i.Unlock()
return i.main.WriteConfig()
}
// FileEnvSet returns true if the configuration file environment parameter
// is set.
func FileEnvSet() bool {
return os.Getenv("STASH_CONFIG_FILE") != ""
}
// GetConfigFile returns the full path to the used configuration file.
func (i *Instance) GetConfigFile() string {
i.RLock()
defer i.RUnlock()
return i.main.ConfigFileUsed()
}
// GetConfigPath returns the path of the directory containing the used
// configuration file.
func (i *Instance) GetConfigPath() string {
return filepath.Dir(i.GetConfigFile())
}
// GetDefaultDatabaseFilePath returns the default database filename,
// which is located in the same directory as the config file.
func (i *Instance) GetDefaultDatabaseFilePath() string {
return filepath.Join(i.GetConfigPath(), "stash-go.sqlite")
}
// viper returns the viper instance that should be used to get the provided
// key. Returns the overrides instance if the key exists there, otherwise it
// returns the main instance. Assumes read lock held.
func (i *Instance) viper(key string) *viper.Viper {
v := i.main
if i.overrides.IsSet(key) {
v = i.overrides
}
return v
}
func (i *Instance) HasOverride(key string) bool {
i.RLock()
defer i.RUnlock()
return i.overrides.IsSet(key)
}
// These functions wrap the equivalent viper functions, checking the override
// instance first, then the main instance.
func (i *Instance) unmarshalKey(key string, rawVal interface{}) error {
i.RLock()
defer i.RUnlock()
return i.viper(key).UnmarshalKey(key, rawVal)
}
func (i *Instance) getStringSlice(key string) []string {
i.RLock()
defer i.RUnlock()
return i.viper(key).GetStringSlice(key)
}
func (i *Instance) getString(key string) string {
i.RLock()
defer i.RUnlock()
return i.viper(key).GetString(key)
}
func (i *Instance) getBool(key string) bool {
i.RLock()
defer i.RUnlock()
return i.viper(key).GetBool(key)
}
func (i *Instance) getBoolDefault(key string, def bool) bool {
i.RLock()
defer i.RUnlock()
ret := def
v := i.viper(key)
if v.IsSet(key) {
ret = v.GetBool(key)
}
return ret
}
func (i *Instance) getInt(key string) int {
i.RLock()
defer i.RUnlock()
return i.viper(key).GetInt(key)
}
func (i *Instance) getFloat64(key string) float64 {
i.RLock()
defer i.RUnlock()
return i.viper(key).GetFloat64(key)
}
func (i *Instance) getStringMapString(key string) map[string]string {
i.RLock()
defer i.RUnlock()
return i.viper(key).GetStringMapString(key)
}
// GetStathPaths returns the configured stash library paths.
// Works opposite to the usual case - it will return the override
// value only if the main value is not set.
func (i *Instance) GetStashPaths() []*models.StashConfig {
i.RLock()
defer i.RUnlock()
var ret []*models.StashConfig
v := i.main
if !v.IsSet(Stash) {
v = i.overrides
}
if err := v.UnmarshalKey(Stash, &ret); err != nil || len(ret) == 0 {
// fallback to legacy format
ss := v.GetStringSlice(Stash)
ret = nil
for _, path := range ss {
toAdd := &models.StashConfig{
Path: path,
}
ret = append(ret, toAdd)
}
}
return ret
}
func (i *Instance) GetCachePath() string {
return i.getString(Cache)
}
func (i *Instance) GetGeneratedPath() string {
return i.getString(Generated)
}
func (i *Instance) GetMetadataPath() string {
return i.getString(Metadata)
}
func (i *Instance) GetDatabasePath() string {
return i.getString(Database)
}
func (i *Instance) GetJWTSignKey() []byte {
return []byte(i.getString(JWTSignKey))
}
func (i *Instance) GetSessionStoreKey() []byte {
return []byte(i.getString(SessionStoreKey))
}
func (i *Instance) GetDefaultScrapersPath() string {
// default to the same directory as the config file
fn := filepath.Join(i.GetConfigPath(), "scrapers")
return fn
}
func (i *Instance) GetExcludes() []string {
return i.getStringSlice(Exclude)
}
func (i *Instance) GetImageExcludes() []string {
return i.getStringSlice(ImageExclude)
}
func (i *Instance) GetVideoExtensions() []string {
ret := i.getStringSlice(VideoExtensions)
if ret == nil {
ret = defaultVideoExtensions
}
return ret
}
func (i *Instance) GetImageExtensions() []string {
ret := i.getStringSlice(ImageExtensions)
if ret == nil {
ret = defaultImageExtensions
}
return ret
}
func (i *Instance) GetGalleryExtensions() []string {
ret := i.getStringSlice(GalleryExtensions)
if ret == nil {
ret = defaultGalleryExtensions
}
return ret
}
func (i *Instance) GetCreateGalleriesFromFolders() bool {
return i.getBool(CreateGalleriesFromFolders)
}
func (i *Instance) GetLanguage() string {
ret := i.getString(Language)
// default to English
if ret == "" {
return "en-US"
}
return ret
}
// IsCalculateMD5 returns true if MD5 checksums should be generated for
// scene video files.
func (i *Instance) IsCalculateMD5() bool {
return i.getBool(CalculateMD5)
}
// GetVideoFileNamingAlgorithm returns what hash algorithm should be used for
// naming generated scene video files.
func (i *Instance) GetVideoFileNamingAlgorithm() models.HashAlgorithm {
ret := i.getString(VideoFileNamingAlgorithm)
// default to oshash
if ret == "" {
return models.HashAlgorithmOshash
}
return models.HashAlgorithm(ret)
}
func (i *Instance) GetScrapersPath() string {
return i.getString(ScrapersPath)
}
func (i *Instance) GetScraperUserAgent() string {
return i.getString(ScraperUserAgent)
}
// GetScraperCDPPath gets the path to the Chrome executable or remote address
// to an instance of Chrome.
func (i *Instance) GetScraperCDPPath() string {
return i.getString(ScraperCDPPath)
}
// GetScraperCertCheck returns true if the scraper should check for insecure
// certificates when fetching an image or a page.
func (i *Instance) GetScraperCertCheck() bool {
return i.getBoolDefault(ScraperCertCheck, true)
}
func (i *Instance) GetScraperExcludeTagPatterns() []string {
return i.getStringSlice(ScraperExcludeTagPatterns)
}
func (i *Instance) GetStashBoxes() models.StashBoxes {
var boxes models.StashBoxes
if err := i.unmarshalKey(StashBoxes, &boxes); err != nil {
logger.Warnf("error in unmarshalkey: %v", err)
}
return boxes
}
func (i *Instance) GetDefaultPluginsPath() string {
// default to the same directory as the config file
fn := filepath.Join(i.GetConfigPath(), "plugins")
return fn
}
func (i *Instance) GetPluginsPath() string {
return i.getString(PluginsPath)
}
func (i *Instance) GetHost() string {
ret := i.getString(Host)
if ret == "" {
ret = hostDefault
}
return ret
}
func (i *Instance) GetPort() int {
ret := i.getInt(Port)
if ret == 0 {
ret = portDefault
}
return ret
}
func (i *Instance) GetThemeColor() string {
return i.getString(ThemeColor)
}
func (i *Instance) GetExternalHost() string {
return i.getString(ExternalHost)
}
// GetPreviewSegmentDuration returns the duration of a single segment in a
// scene preview file, in seconds.
func (i *Instance) GetPreviewSegmentDuration() float64 {
return i.getFloat64(PreviewSegmentDuration)
}
// GetParallelTasks returns the number of parallel tasks that should be started
// by scan or generate task.
func (i *Instance) GetParallelTasks() int {
return i.getInt(ParallelTasks)
}
func (i *Instance) GetParallelTasksWithAutoDetection() int {
parallelTasks := i.getInt(ParallelTasks)
if parallelTasks <= 0 {
parallelTasks = (runtime.NumCPU() / 4) + 1
}
return parallelTasks
}
func (i *Instance) GetPreviewAudio() bool {
return i.getBool(PreviewAudio)
}
// GetPreviewSegments returns the amount of segments in a scene preview file.
func (i *Instance) GetPreviewSegments() int {
return i.getInt(PreviewSegments)
}
// GetPreviewExcludeStart returns the configuration setting string for
// excluding the start of scene videos for preview generation. This can
// be in two possible formats. A float value is interpreted as the amount
// of seconds to exclude from the start of the video before it is included
// in the preview. If the value is suffixed with a '%' character (for example
// '2%'), then it is interpreted as a proportion of the total video duration.
func (i *Instance) GetPreviewExcludeStart() string {
return i.getString(PreviewExcludeStart)
}
// GetPreviewExcludeEnd returns the configuration setting string for
// excluding the end of scene videos for preview generation. A float value
// is interpreted as the amount of seconds to exclude from the end of the video
// when generating previews. If the value is suffixed with a '%' character,
// then it is interpreted as a proportion of the total video duration.
func (i *Instance) GetPreviewExcludeEnd() string {
return i.getString(PreviewExcludeEnd)
}
// GetPreviewPreset returns the preset when generating previews. Defaults to
// Slow.
func (i *Instance) GetPreviewPreset() models.PreviewPreset {
ret := i.getString(PreviewPreset)
// default to slow
if ret == "" {
return models.PreviewPresetSlow
}
return models.PreviewPreset(ret)
}
func (i *Instance) GetMaxTranscodeSize() models.StreamingResolutionEnum {
ret := i.getString(MaxTranscodeSize)
// default to original
if ret == "" {
return models.StreamingResolutionEnumOriginal
}
return models.StreamingResolutionEnum(ret)
}
func (i *Instance) GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum {
ret := i.getString(MaxStreamingTranscodeSize)
// default to original
if ret == "" {
return models.StreamingResolutionEnumOriginal
}
return models.StreamingResolutionEnum(ret)
}
// IsWriteImageThumbnails returns true if image thumbnails should be written
// to disk after generating on the fly.
func (i *Instance) IsWriteImageThumbnails() bool {
return i.getBool(WriteImageThumbnails)
}
func (i *Instance) GetAPIKey() string {
return i.getString(ApiKey)
}
func (i *Instance) GetUsername() string {
return i.getString(Username)
}
func (i *Instance) GetPasswordHash() string {
return i.getString(Password)
}
func (i *Instance) GetCredentials() (string, string) {
if i.HasCredentials() {
return i.getString(Username), i.getString(Password)
}
return "", ""
}
func (i *Instance) HasCredentials() bool {
username := i.getString(Username)
pwHash := i.getString(Password)
return username != "" && pwHash != ""
}
func hashPassword(password string) string {
hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
return string(hash)
}
func (i *Instance) ValidateCredentials(username string, password string) bool {
if !i.HasCredentials() {
// don't need to authenticate if no credentials saved
return true
}
authUser, authPWHash := i.GetCredentials()
err := bcrypt.CompareHashAndPassword([]byte(authPWHash), []byte(password))
return username == authUser && err == nil
}
var stashBoxRe = regexp.MustCompile("^http.*graphql$")
func (i *Instance) ValidateStashBoxes(boxes []*models.StashBoxInput) error {
isMulti := len(boxes) > 1
for _, box := range boxes {
// Validate each stash-box configuration field, return on error
if box.APIKey == "" {
return &StashBoxError{msg: "API Key cannot be blank"}
}
if box.Endpoint == "" {
return &StashBoxError{msg: "endpoint cannot be blank"}
}
if !stashBoxRe.Match([]byte(box.Endpoint)) {
return &StashBoxError{msg: "endpoint is invalid"}
}
if isMulti && box.Name == "" {
return &StashBoxError{msg: "name cannot be blank"}
}
}
return nil
}
// GetMaxSessionAge gets the maximum age for session cookies, in seconds.
// Session cookie expiry times are refreshed every request.
func (i *Instance) GetMaxSessionAge() int {
i.RLock()
defer i.RUnlock()
ret := DefaultMaxSessionAge
v := i.viper(MaxSessionAge)
if v.IsSet(MaxSessionAge) {
ret = v.GetInt(MaxSessionAge)
}
return ret
}
// GetCustomServedFolders gets the map of custom paths to their applicable
// filesystem locations
func (i *Instance) GetCustomServedFolders() URLMap {
return i.getStringMapString(CustomServedFolders)
}
func (i *Instance) GetCustomUILocation() string {
return i.getString(CustomUILocation)
}
// Interface options
func (i *Instance) GetMenuItems() []string {
i.RLock()
defer i.RUnlock()
v := i.viper(MenuItems)
if v.IsSet(MenuItems) {
return v.GetStringSlice(MenuItems)
}
return defaultMenuItems
}
func (i *Instance) GetSoundOnPreview() bool {
return i.getBool(SoundOnPreview)
}
func (i *Instance) GetWallShowTitle() bool {
i.RLock()
defer i.RUnlock()
ret := defaultWallShowTitle
v := i.viper(WallShowTitle)
if v.IsSet(WallShowTitle) {
ret = v.GetBool(WallShowTitle)
}
return ret
}
func (i *Instance) GetCustomPerformerImageLocation() string {
return i.getString(CustomPerformerImageLocation)
}
func (i *Instance) GetWallPlayback() string {
i.RLock()
defer i.RUnlock()
ret := defaultWallPlayback
v := i.viper(WallPlayback)
if v.IsSet(WallPlayback) {
ret = v.GetString(WallPlayback)
}
return ret
}
func (i *Instance) GetShowScrubber() bool {
return i.getBoolDefault(ShowScrubber, showScrubberDefault)
}
func (i *Instance) GetMaximumLoopDuration() int {
return i.getInt(MaximumLoopDuration)
}
func (i *Instance) GetAutostartVideo() bool {
return i.getBool(AutostartVideo)
}
func (i *Instance) GetAutostartVideoOnPlaySelected() bool {
return i.getBoolDefault(AutostartVideoOnPlaySelected, autostartVideoOnPlaySelectedDefault)
}
func (i *Instance) GetContinuePlaylistDefault() bool {
return i.getBool(ContinuePlaylistDefault)
}
func (i *Instance) GetShowStudioAsText() bool {
return i.getBool(ShowStudioAsText)
}
func (i *Instance) GetSlideshowDelay() int {
i.RLock()
defer i.RUnlock()
ret := defaultSlideshowDelay
v := i.viper(SlideshowDelay)
if v.IsSet(SlideshowDelay) {
ret = v.GetInt(SlideshowDelay)
}
return ret
}
func (i *Instance) GetDisableDropdownCreate() *models.ConfigDisableDropdownCreate {
return &models.ConfigDisableDropdownCreate{
Performer: i.getBool(DisableDropdownCreatePerformer),
Studio: i.getBool(DisableDropdownCreateStudio),
Tag: i.getBool(DisableDropdownCreateTag),
}
}
func (i *Instance) GetCSSPath() string {
// use custom.css in the same directory as the config file
configFileUsed := i.GetConfigFile()
configDir := filepath.Dir(configFileUsed)
fn := filepath.Join(configDir, "custom.css")
return fn
}
func (i *Instance) GetCSS() string {
fn := i.GetCSSPath()
exists, _ := fsutil.FileExists(fn)
if !exists {
return ""
}
buf, err := os.ReadFile(fn)
if err != nil {
return ""
}
return string(buf)
}
func (i *Instance) SetCSS(css string) {
fn := i.GetCSSPath()
i.Lock()
defer i.Unlock()
buf := []byte(css)
if err := os.WriteFile(fn, buf, 0777); err != nil {
logger.Warnf("error while writing %v bytes to %v: %v", len(buf), fn, err)
}
}
func (i *Instance) GetCSSEnabled() bool {
return i.getBool(CSSEnabled)
}
func (i *Instance) GetHandyKey() string {
return i.getString(HandyKey)
}
func (i *Instance) GetFunscriptOffset() int {
return i.getInt(FunscriptOffset)
}
func (i *Instance) GetDeleteFileDefault() bool {
return i.getBool(DeleteFileDefault)
}
func (i *Instance) GetDeleteGeneratedDefault() bool {
return i.getBoolDefault(DeleteGeneratedDefault, deleteGeneratedDefaultDefault)
}
// GetDefaultIdentifySettings returns the default Identify task settings.
// Returns nil if the settings could not be unmarshalled, or if it
// has not been set.
func (i *Instance) GetDefaultIdentifySettings() *models.IdentifyMetadataTaskOptions {
i.RLock()
defer i.RUnlock()
v := i.viper(DefaultIdentifySettings)
if v.IsSet(DefaultIdentifySettings) {
var ret models.IdentifyMetadataTaskOptions
if err := v.UnmarshalKey(DefaultIdentifySettings, &ret); err != nil {
return nil
}
return &ret
}
return nil
}
// GetDefaultScanSettings returns the default Scan task settings.
// Returns nil if the settings could not be unmarshalled, or if it
// has not been set.
func (i *Instance) GetDefaultScanSettings() *models.ScanMetadataOptions {
i.RLock()
defer i.RUnlock()
v := i.viper(DefaultScanSettings)
if v.IsSet(DefaultScanSettings) {
var ret models.ScanMetadataOptions
if err := v.UnmarshalKey(DefaultScanSettings, &ret); err != nil {
return nil
}
return &ret
}
return nil
}
// GetDefaultAutoTagSettings returns the default Scan task settings.
// Returns nil if the settings could not be unmarshalled, or if it
// has not been set.
func (i *Instance) GetDefaultAutoTagSettings() *models.AutoTagMetadataOptions {
i.RLock()
defer i.RUnlock()
v := i.viper(DefaultAutoTagSettings)
if v.IsSet(DefaultAutoTagSettings) {
var ret models.AutoTagMetadataOptions
if err := v.UnmarshalKey(DefaultAutoTagSettings, &ret); err != nil {
return nil
}
return &ret
}
return nil
}
// GetDefaultGenerateSettings returns the default Scan task settings.
// Returns nil if the settings could not be unmarshalled, or if it
// has not been set.
func (i *Instance) GetDefaultGenerateSettings() *models.GenerateMetadataOptions {
i.RLock()
defer i.RUnlock()
v := i.viper(DefaultGenerateSettings)
if v.IsSet(DefaultGenerateSettings) {
var ret models.GenerateMetadataOptions
if err := v.UnmarshalKey(DefaultGenerateSettings, &ret); err != nil {
return nil
}
return &ret
}
return nil
}
// GetDangerousAllowPublicWithoutAuth determines if the security feature is enabled.
// See https://github.com/stashapp/stash/wiki/Authentication-Required-When-Accessing-Stash-From-the-Internet
func (i *Instance) GetDangerousAllowPublicWithoutAuth() bool {
return i.getBool(dangerousAllowPublicWithoutAuth)
}
// GetSecurityTripwireAccessedFromPublicInternet returns a public IP address if stash
// has been accessed from the public internet, with no auth enabled, and
// DangerousAllowPublicWithoutAuth disabled. Returns an empty string otherwise.
func (i *Instance) GetSecurityTripwireAccessedFromPublicInternet() string {
return i.getString(SecurityTripwireAccessedFromPublicInternet)
}
// GetDLNAServerName returns the visible name of the DLNA server. If empty,
// "stash" will be used.
func (i *Instance) GetDLNAServerName() string {
return i.getString(DLNAServerName)
}
// GetDLNADefaultEnabled returns true if the DLNA is enabled by default.
func (i *Instance) GetDLNADefaultEnabled() bool {
return i.getBool(DLNADefaultEnabled)
}
// GetDLNADefaultIPWhitelist returns a list of IP addresses/wildcards that
// are allowed to use the DLNA service.
func (i *Instance) GetDLNADefaultIPWhitelist() []string {
return i.getStringSlice(DLNADefaultIPWhitelist)
}
// GetDLNAInterfaces returns a list of interface names to expose DLNA on. If
// empty, runs on all interfaces.
func (i *Instance) GetDLNAInterfaces() []string {
return i.getStringSlice(DLNAInterfaces)
}
// GetLogFile returns the filename of the file to output logs to.
// An empty string means that file logging will be disabled.
func (i *Instance) GetLogFile() string {
return i.getString(LogFile)
}
// GetLogOut returns true if logging should be output to the terminal
// in addition to writing to a log file. Logging will be output to the
// terminal if file logging is disabled. Defaults to true.
func (i *Instance) GetLogOut() bool {
return i.getBoolDefault(LogOut, defaultLogOut)
}
// GetLogLevel returns the lowest log level to write to the log.
// Should be one of "Debug", "Info", "Warning", "Error"
func (i *Instance) GetLogLevel() string {
value := i.getString(LogLevel)
if value != "Debug" && value != "Info" && value != "Warning" && value != "Error" && value != "Trace" {
value = defaultLogLevel
}
return value
}
// GetLogAccess returns true if http requests should be logged to the terminal.
// HTTP requests are not logged to the log file. Defaults to true.
func (i *Instance) GetLogAccess() bool {
return i.getBoolDefault(LogAccess, defaultLogAccess)
}
// Max allowed graphql upload size in megabytes
func (i *Instance) GetMaxUploadSize() int64 {
i.RLock()
defer i.RUnlock()
ret := int64(1024)
v := i.viper(MaxUploadSize)
if v.IsSet(MaxUploadSize) {
ret = v.GetInt64(MaxUploadSize)
}
return ret << 20
}
// ActivatePublicAccessTripwire sets the security_tripwire_accessed_from_public_internet
// config field to the provided IP address to indicate that stash has been accessed
// from this public IP without authentication.
func (i *Instance) ActivatePublicAccessTripwire(requestIP string) error {
i.Set(SecurityTripwireAccessedFromPublicInternet, requestIP)
return i.Write()
}
func (i *Instance) Validate() error {
i.RLock()
defer i.RUnlock()
mandatoryPaths := []string{
Database,
Generated,
}
var missingFields []string
for _, p := range mandatoryPaths {
if !i.viper(p).IsSet(p) || i.viper(p).GetString(p) == "" {
missingFields = append(missingFields, p)
}
}
if len(missingFields) > 0 {
return MissingConfigError{
missingFields: missingFields,
}
}
return nil
}
func (i *Instance) SetChecksumDefaultValues(defaultAlgorithm models.HashAlgorithm, usingMD5 bool) {
i.Lock()
defer i.Unlock()
i.main.SetDefault(VideoFileNamingAlgorithm, defaultAlgorithm)
i.main.SetDefault(CalculateMD5, usingMD5)
}
func (i *Instance) setDefaultValues(write bool) error {
// read data before write lock scope
defaultDatabaseFilePath := i.GetDefaultDatabaseFilePath()
defaultScrapersPath := i.GetDefaultScrapersPath()
defaultPluginsPath := i.GetDefaultPluginsPath()
i.Lock()
defer i.Unlock()
// set the default host and port so that these are written to the config
// file
i.main.SetDefault(Host, hostDefault)
i.main.SetDefault(Port, portDefault)
i.main.SetDefault(ParallelTasks, parallelTasksDefault)
i.main.SetDefault(PreviewSegmentDuration, previewSegmentDurationDefault)
i.main.SetDefault(PreviewSegments, previewSegmentsDefault)
i.main.SetDefault(PreviewExcludeStart, previewExcludeStartDefault)
i.main.SetDefault(PreviewExcludeEnd, previewExcludeEndDefault)
i.main.SetDefault(PreviewAudio, previewAudioDefault)
i.main.SetDefault(SoundOnPreview, false)
i.main.SetDefault(ThemeColor, DefaultThemeColor)
i.main.SetDefault(WriteImageThumbnails, writeImageThumbnailsDefault)
i.main.SetDefault(Database, defaultDatabaseFilePath)
i.main.SetDefault(dangerousAllowPublicWithoutAuth, dangerousAllowPublicWithoutAuthDefault)
i.main.SetDefault(SecurityTripwireAccessedFromPublicInternet, securityTripwireAccessedFromPublicInternetDefault)
// Set generated to the metadata path for backwards compat
i.main.SetDefault(Generated, i.main.GetString(Metadata))
i.main.SetDefault(NoBrowser, NoBrowserDefault)
i.main.SetDefault(NotificationsEnabled, NotificationsEnabledDefault)
i.main.SetDefault(ShowOneTimeMovedNotification, ShowOneTimeMovedNotificationDefault)
// Set default scrapers and plugins paths
i.main.SetDefault(ScrapersPath, defaultScrapersPath)
i.main.SetDefault(PluginsPath, defaultPluginsPath)
if write {
return i.main.WriteConfig()
}
return nil
}
// setExistingSystemDefaults sets config options that are new and unset in an existing install,
// but should have a separate default than for brand-new systems, to maintain behavior.
func (i *Instance) setExistingSystemDefaults() error {
i.Lock()
defer i.Unlock()
if !i.isNewSystem {
configDirtied := false
// Existing systems as of the introduction of auto-browser open should retain existing
// behavior and not start the browser automatically.
if !i.main.InConfig(NoBrowser) {
configDirtied = true
i.main.Set(NoBrowser, true)
}
// Existing systems as of the introduction of the taskbar should inform users.
if !i.main.InConfig(ShowOneTimeMovedNotification) {
configDirtied = true
i.main.Set(ShowOneTimeMovedNotification, true)
}
if configDirtied {
return i.main.WriteConfig()
}
}
return nil
}
// SetInitialConfig fills in missing required config fields
func (i *Instance) SetInitialConfig() error {
return i.setInitialConfig(true)
}
// SetInitialMemoryConfig fills in missing required config fields without writing the configuration
func (i *Instance) SetInitialMemoryConfig() error {
return i.setInitialConfig(false)
}
func (i *Instance) setInitialConfig(write bool) error {
// generate some api keys
const apiKeyLength = 32
if string(i.GetJWTSignKey()) == "" {
signKey, err := hash.GenerateRandomKey(apiKeyLength)
if err != nil {
return fmt.Errorf("error generating JWTSignKey: %w", err)
}
i.Set(JWTSignKey, signKey)
}
if string(i.GetSessionStoreKey()) == "" {
sessionStoreKey, err := hash.GenerateRandomKey(apiKeyLength)
if err != nil {
return fmt.Errorf("error generating session store key: %w", err)
}
i.Set(SessionStoreKey, sessionStoreKey)
}
return i.setDefaultValues(write)
}
func (i *Instance) FinalizeSetup() {
i.isNewSystem = false
// i.configUpdates <- 0
}