This commit is contained in:
WithoutPants 2026-02-06 11:12:17 +00:00 committed by GitHub
commit 36822d7550
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1778 additions and 744 deletions

View file

@ -125,6 +125,8 @@ models:
model: github.com/stashapp/stash/internal/identify.FieldStrategy
ScraperSource:
model: github.com/stashapp/stash/pkg/scraper.Source
RoleEnum:
model: github.com/stashapp/stash/pkg/models.RoleEnum
IdentifySourceInput:
model: github.com/stashapp/stash/internal/identify.Source
IdentifyFieldOptionsInput:

View file

@ -269,7 +269,11 @@ type Query {
allStudios: [Studio!]! @deprecated(reason: "Use findStudios instead")
allMovies: [Movie!]! @deprecated(reason: "Use findGroups instead")
# Get everything with minimal metadata
users: [User!]! @hasRole(role: ADMIN)
"""
Returns currently authenticated user
"""
me: User
# Version
version: Version!
@ -279,137 +283,169 @@ type Query {
}
type Mutation {
setup(input: SetupInput!): Boolean!
setup(input: SetupInput!): Boolean! @hasRole(role: ADMIN)
"Migrates the schema to the required version. Returns the job ID"
migrate(input: MigrateInput!): ID!
migrate(input: MigrateInput!): ID! @hasRole(role: ADMIN)
"Downloads and installs ffmpeg and ffprobe binaries into the configuration directory. Returns the job ID."
downloadFFMpeg: ID!
downloadFFMpeg: ID! @hasRole(role: ADMIN)
sceneCreate(input: SceneCreateInput!): Scene
sceneUpdate(input: SceneUpdateInput!): Scene
sceneMerge(input: SceneMergeInput!): Scene
bulkSceneUpdate(input: BulkSceneUpdateInput!): [Scene!]
sceneDestroy(input: SceneDestroyInput!): Boolean!
scenesDestroy(input: ScenesDestroyInput!): Boolean!
scenesUpdate(input: [SceneUpdateInput!]!): [Scene]
sceneCreate(input: SceneCreateInput!): Scene @hasRole(role: MODIFY)
sceneUpdate(input: SceneUpdateInput!): Scene @hasRole(role: MODIFY)
sceneMerge(input: SceneMergeInput!): Scene @hasRole(role: MODIFY)
bulkSceneUpdate(input: BulkSceneUpdateInput!): [Scene!] @hasRole(role: MODIFY)
sceneDestroy(input: SceneDestroyInput!): Boolean! @hasRole(role: MODIFY)
scenesDestroy(input: ScenesDestroyInput!): Boolean! @hasRole(role: MODIFY)
scenesUpdate(input: [SceneUpdateInput!]!): [Scene] @hasRole(role: MODIFY)
"Increments the o-counter for a scene. Returns the new value"
sceneIncrementO(id: ID!): Int! @deprecated(reason: "Use sceneAddO instead")
sceneIncrementO(id: ID!): Int!
@deprecated(reason: "Use sceneAddO instead")
@hasRole(role: MODIFY)
"Decrements the o-counter for a scene. Returns the new value"
sceneDecrementO(id: ID!): Int! @deprecated(reason: "Use sceneRemoveO instead")
sceneDecrementO(id: ID!): Int!
@deprecated(reason: "Use sceneRemoveO instead")
@hasRole(role: MODIFY)
"Increments the o-counter for a scene. Uses the current time if none provided."
sceneAddO(id: ID!, times: [Timestamp!]): HistoryMutationResult!
@hasRole(role: MODIFY)
"Decrements the o-counter for a scene, removing the last recorded time if specific time not provided. Returns the new value"
sceneDeleteO(id: ID!, times: [Timestamp!]): HistoryMutationResult!
@hasRole(role: MODIFY)
"Resets the o-counter for a scene to 0. Returns the new value"
sceneResetO(id: ID!): Int!
sceneResetO(id: ID!): Int! @hasRole(role: MODIFY)
"Sets the resume time point (if provided) and adds the provided duration to the scene's play duration"
sceneSaveActivity(id: ID!, resume_time: Float, playDuration: Float): Boolean!
@hasRole(role: MODIFY)
"Resets the resume time point and play duration"
sceneResetActivity(
id: ID!
reset_resume: Boolean
reset_duration: Boolean
): Boolean!
): Boolean! @hasRole(role: MODIFY)
"Increments the play count for the scene. Returns the new play count value."
sceneIncrementPlayCount(id: ID!): Int!
@deprecated(reason: "Use sceneAddPlay instead")
@hasRole(role: MODIFY)
"Increments the play count for the scene. Uses the current time if none provided."
sceneAddPlay(id: ID!, times: [Timestamp!]): HistoryMutationResult!
@hasRole(role: MODIFY)
"Decrements the play count for the scene, removing the specific times or the last recorded time if not provided."
sceneDeletePlay(id: ID!, times: [Timestamp!]): HistoryMutationResult!
@hasRole(role: MODIFY)
"Resets the play count for a scene to 0. Returns the new play count value."
sceneResetPlayCount(id: ID!): Int!
sceneResetPlayCount(id: ID!): Int! @hasRole(role: MODIFY)
"Generates screenshot at specified time in seconds. Leave empty to generate default screenshot"
sceneGenerateScreenshot(id: ID!, at: Float): String!
sceneGenerateScreenshot(id: ID!, at: Float): String! @hasRole(role: ADMIN)
sceneMarkerCreate(input: SceneMarkerCreateInput!): SceneMarker
@hasRole(role: MODIFY)
sceneMarkerUpdate(input: SceneMarkerUpdateInput!): SceneMarker
@hasRole(role: MODIFY)
bulkSceneMarkerUpdate(input: BulkSceneMarkerUpdateInput!): [SceneMarker!]
sceneMarkerDestroy(id: ID!): Boolean!
sceneMarkersDestroy(ids: [ID!]!): Boolean!
@hasRole(role: MODIFY)
sceneMarkerDestroy(id: ID!): Boolean! @hasRole(role: MODIFY)
sceneMarkersDestroy(ids: [ID!]!): Boolean! @hasRole(role: MODIFY)
sceneAssignFile(input: AssignSceneFileInput!): Boolean!
sceneAssignFile(input: AssignSceneFileInput!): Boolean! @hasRole(role: MODIFY)
imageUpdate(input: ImageUpdateInput!): Image
bulkImageUpdate(input: BulkImageUpdateInput!): [Image!]
imageDestroy(input: ImageDestroyInput!): Boolean!
imagesDestroy(input: ImagesDestroyInput!): Boolean!
imagesUpdate(input: [ImageUpdateInput!]!): [Image]
imageUpdate(input: ImageUpdateInput!): Image @hasRole(role: MODIFY)
bulkImageUpdate(input: BulkImageUpdateInput!): [Image!] @hasRole(role: MODIFY)
imageDestroy(input: ImageDestroyInput!): Boolean! @hasRole(role: MODIFY)
imagesDestroy(input: ImagesDestroyInput!): Boolean! @hasRole(role: MODIFY)
imagesUpdate(input: [ImageUpdateInput!]!): [Image] @hasRole(role: MODIFY)
"Increments the o-counter for an image. Returns the new value"
imageIncrementO(id: ID!): Int!
imageIncrementO(id: ID!): Int! @hasRole(role: MODIFY)
"Decrements the o-counter for an image. Returns the new value"
imageDecrementO(id: ID!): Int!
imageDecrementO(id: ID!): Int! @hasRole(role: MODIFY)
"Resets the o-counter for a image to 0. Returns the new value"
imageResetO(id: ID!): Int!
imageResetO(id: ID!): Int! @hasRole(role: MODIFY)
galleryCreate(input: GalleryCreateInput!): Gallery
galleryUpdate(input: GalleryUpdateInput!): Gallery
galleryCreate(input: GalleryCreateInput!): Gallery @hasRole(role: MODIFY)
galleryUpdate(input: GalleryUpdateInput!): Gallery @hasRole(role: MODIFY)
bulkGalleryUpdate(input: BulkGalleryUpdateInput!): [Gallery!]
galleryDestroy(input: GalleryDestroyInput!): Boolean!
@hasRole(role: MODIFY)
galleryDestroy(input: GalleryDestroyInput!): Boolean! @hasRole(role: MODIFY)
galleriesUpdate(input: [GalleryUpdateInput!]!): [Gallery]
@hasRole(role: MODIFY)
addGalleryImages(input: GalleryAddInput!): Boolean!
addGalleryImages(input: GalleryAddInput!): Boolean! @hasRole(role: MODIFY)
removeGalleryImages(input: GalleryRemoveInput!): Boolean!
setGalleryCover(input: GallerySetCoverInput!): Boolean!
@hasRole(role: MODIFY)
setGalleryCover(input: GallerySetCoverInput!): Boolean! @hasRole(role: MODIFY)
resetGalleryCover(input: GalleryResetCoverInput!): Boolean!
@hasRole(role: MODIFY)
galleryChapterCreate(input: GalleryChapterCreateInput!): GalleryChapter
@hasRole(role: MODIFY)
galleryChapterUpdate(input: GalleryChapterUpdateInput!): GalleryChapter
galleryChapterDestroy(id: ID!): Boolean!
@hasRole(role: MODIFY)
galleryChapterDestroy(id: ID!): Boolean! @hasRole(role: MODIFY)
performerCreate(input: PerformerCreateInput!): Performer
@hasRole(role: MODIFY)
performerUpdate(input: PerformerUpdateInput!): Performer
@hasRole(role: MODIFY)
performerDestroy(input: PerformerDestroyInput!): Boolean!
performersDestroy(ids: [ID!]!): Boolean!
@hasRole(role: MODIFY)
performersDestroy(ids: [ID!]!): Boolean! @hasRole(role: MODIFY)
bulkPerformerUpdate(input: BulkPerformerUpdateInput!): [Performer!]
performerMerge(input: PerformerMergeInput!): Performer!
@hasRole(role: MODIFY)
performerMerge(input: PerformerMergeInput!): Performer! @hasRole(role: MODIFY)
studioCreate(input: StudioCreateInput!): Studio
studioUpdate(input: StudioUpdateInput!): Studio
studioDestroy(input: StudioDestroyInput!): Boolean!
studiosDestroy(ids: [ID!]!): Boolean!
studioCreate(input: StudioCreateInput!): Studio @hasRole(role: MODIFY)
studioUpdate(input: StudioUpdateInput!): Studio @hasRole(role: MODIFY)
studioDestroy(input: StudioDestroyInput!): Boolean! @hasRole(role: MODIFY)
studiosDestroy(ids: [ID!]!): Boolean! @hasRole(role: MODIFY)
bulkStudioUpdate(input: BulkStudioUpdateInput!): [Studio!]
@hasRole(role: MODIFY)
movieCreate(input: MovieCreateInput!): Movie
@deprecated(reason: "Use groupCreate instead")
@hasRole(role: MODIFY)
movieUpdate(input: MovieUpdateInput!): Movie
@deprecated(reason: "Use groupUpdate instead")
@hasRole(role: MODIFY)
movieDestroy(input: MovieDestroyInput!): Boolean!
@deprecated(reason: "Use groupDestroy instead")
@hasRole(role: MODIFY)
moviesDestroy(ids: [ID!]!): Boolean!
@deprecated(reason: "Use groupsDestroy instead")
@hasRole(role: MODIFY)
bulkMovieUpdate(input: BulkMovieUpdateInput!): [Movie!]
@deprecated(reason: "Use bulkGroupUpdate instead")
@hasRole(role: MODIFY)
groupCreate(input: GroupCreateInput!): Group
groupUpdate(input: GroupUpdateInput!): Group
groupDestroy(input: GroupDestroyInput!): Boolean!
groupsDestroy(ids: [ID!]!): Boolean!
bulkGroupUpdate(input: BulkGroupUpdateInput!): [Group!]
groupCreate(input: GroupCreateInput!): Group @hasRole(role: MODIFY)
groupUpdate(input: GroupUpdateInput!): Group @hasRole(role: MODIFY)
groupDestroy(input: GroupDestroyInput!): Boolean! @hasRole(role: MODIFY)
groupsDestroy(ids: [ID!]!): Boolean! @hasRole(role: MODIFY)
bulkGroupUpdate(input: BulkGroupUpdateInput!): [Group!] @hasRole(role: MODIFY)
addGroupSubGroups(input: GroupSubGroupAddInput!): Boolean!
@hasRole(role: MODIFY)
removeGroupSubGroups(input: GroupSubGroupRemoveInput!): Boolean!
@hasRole(role: MODIFY)
"Reorder sub groups within a group. Returns true if successful."
reorderSubGroups(input: ReorderSubGroupsInput!): Boolean!
@hasRole(role: MODIFY)
tagCreate(input: TagCreateInput!): Tag
tagUpdate(input: TagUpdateInput!): Tag
tagDestroy(input: TagDestroyInput!): Boolean!
tagsDestroy(ids: [ID!]!): Boolean!
tagsMerge(input: TagsMergeInput!): Tag
bulkTagUpdate(input: BulkTagUpdateInput!): [Tag!]
tagCreate(input: TagCreateInput!): Tag @hasRole(role: MODIFY)
tagUpdate(input: TagUpdateInput!): Tag @hasRole(role: MODIFY)
tagDestroy(input: TagDestroyInput!): Boolean! @hasRole(role: MODIFY)
tagsDestroy(ids: [ID!]!): Boolean! @hasRole(role: MODIFY)
tagsMerge(input: TagsMergeInput!): Tag @hasRole(role: MODIFY)
bulkTagUpdate(input: BulkTagUpdateInput!): [Tag!] @hasRole(role: MODIFY)
"""
Moves the given files to the given destination. Returns true if successful.
@ -420,90 +456,98 @@ type Mutation {
matches one of the media extensions.
Creates folder hierarchy if needed.
"""
moveFiles(input: MoveFilesInput!): Boolean!
deleteFiles(ids: [ID!]!): Boolean!
moveFiles(input: MoveFilesInput!): Boolean! @hasRole(role: MODIFY)
deleteFiles(ids: [ID!]!): Boolean! @hasRole(role: MODIFY)
"Deletes file entries from the database without deleting the files from the filesystem"
destroyFiles(ids: [ID!]!): Boolean!
destroyFiles(ids: [ID!]!): Boolean! @hasRole(role: MODIFY)
fileSetFingerprints(input: FileSetFingerprintsInput!): Boolean!
# Saved filters
saveFilter(input: SaveFilterInput!): SavedFilter!
saveFilter(input: SaveFilterInput!): SavedFilter! @hasRole(role: MODIFY)
destroySavedFilter(input: DestroyFilterInput!): Boolean!
@hasRole(role: MODIFY)
setDefaultFilter(input: SetDefaultFilterInput!): Boolean!
@deprecated(reason: "now uses UI config")
@hasRole(role: MODIFY)
"Change general configuration options"
configureGeneral(input: ConfigGeneralInput!): ConfigGeneralResult!
@hasRole(role: ADMIN)
configureInterface(input: ConfigInterfaceInput!): ConfigInterfaceResult!
@hasRole(role: ADMIN)
configureDLNA(input: ConfigDLNAInput!): ConfigDLNAResult!
@hasRole(role: ADMIN)
configureScraping(input: ConfigScrapingInput!): ConfigScrapingResult!
@hasRole(role: ADMIN)
configureDefaults(
input: ConfigDefaultSettingsInput!
): ConfigDefaultSettingsResult!
): ConfigDefaultSettingsResult! @hasRole(role: ADMIN)
"overwrites the entire plugin configuration for the given plugin"
configurePlugin(plugin_id: ID!, input: Map!): Map!
configurePlugin(plugin_id: ID!, input: Map!): Map! @hasRole(role: ADMIN)
"""
overwrites the UI configuration
if input is provided, then the entire UI configuration is replaced
if partial is provided, then the partial UI configuration is merged into the existing UI configuration
"""
configureUI(input: Map, partial: Map): Map!
configureUI(input: Map, partial: Map): Map! @hasRole(role: ADMIN)
"""
sets a single UI key value
key is a dot separated path to the value
"""
configureUISetting(key: String!, value: Any): Map!
configureUISetting(key: String!, value: Any): Map! @hasRole(role: ADMIN)
"Generate and set (or clear) API key"
"Generate and set (or clear) API key for the current user"
generateAPIKey(input: GenerateAPIKeyInput!): String!
"Returns a link to download the result"
exportObjects(input: ExportObjectsInput!): String
exportObjects(input: ExportObjectsInput!): String @hasRole(role: ADMIN)
"Performs an incremental import. Returns the job ID"
importObjects(input: ImportObjectsInput!): ID!
importObjects(input: ImportObjectsInput!): ID! @hasRole(role: ADMIN)
"Start an full import. Completely wipes the database and imports from the metadata directory. Returns the job ID"
metadataImport: ID!
metadataImport: ID! @hasRole(role: ADMIN)
"Start a full export. Outputs to the metadata directory. Returns the job ID"
metadataExport: ID!
metadataExport: ID! @hasRole(role: ADMIN)
"Start a scan. Returns the job ID"
metadataScan(input: ScanMetadataInput!): ID!
metadataScan(input: ScanMetadataInput!): ID! @hasRole(role: ADMIN)
"Start generating content. Returns the job ID"
metadataGenerate(input: GenerateMetadataInput!): ID!
metadataGenerate(input: GenerateMetadataInput!): ID! @hasRole(role: ADMIN)
"Start auto-tagging. Returns the job ID"
metadataAutoTag(input: AutoTagMetadataInput!): ID!
metadataAutoTag(input: AutoTagMetadataInput!): ID! @hasRole(role: ADMIN)
"Clean metadata. Returns the job ID"
metadataClean(input: CleanMetadataInput!): ID!
metadataClean(input: CleanMetadataInput!): ID! @hasRole(role: ADMIN)
"Clean generated files. Returns the job ID"
metadataCleanGenerated(input: CleanGeneratedInput!): ID!
metadataCleanGenerated(input: CleanGeneratedInput!): ID! @hasRole(role: ADMIN)
"Identifies scenes using scrapers. Returns the job ID"
metadataIdentify(input: IdentifyMetadataInput!): ID!
metadataIdentify(input: IdentifyMetadataInput!): ID! @hasRole(role: ADMIN)
"Migrate generated files for the current hash naming"
migrateHashNaming: ID!
migrateHashNaming: ID! @hasRole(role: ADMIN)
"Migrates legacy scene screenshot files into the blob storage"
migrateSceneScreenshots(input: MigrateSceneScreenshotsInput!): ID!
@hasRole(role: ADMIN)
"Migrates blobs from the old storage system to the current one"
migrateBlobs(input: MigrateBlobsInput!): ID!
migrateBlobs(input: MigrateBlobsInput!): ID! @hasRole(role: ADMIN)
"Anonymise the database in a separate file. Optionally returns a link to download the database file"
anonymiseDatabase(input: AnonymiseDatabaseInput!): String
@hasRole(role: ADMIN)
"Optimises the database. Returns the job ID"
optimiseDatabase: ID!
optimiseDatabase: ID! @hasRole(role: ADMIN)
"Reload scrapers"
reloadScrapers: Boolean!
reloadScrapers: Boolean! @hasRole(role: ADMIN)
"""
Enable/disable plugins - enabledMap is a map of plugin IDs to enabled booleans.
Plugins not in the map are not affected.
"""
setPluginsEnabled(enabledMap: BoolMap!): Boolean!
setPluginsEnabled(enabledMap: BoolMap!): Boolean! @hasRole(role: ADMIN)
"""
Run a plugin task.
@ -520,15 +564,15 @@ type Mutation {
description: String
args: [PluginArgInput!] @deprecated(reason: "Use args_map instead")
args_map: Map
): ID!
): ID! @hasRole(role: MODIFY)
"""
Runs a plugin operation. The operation is run immediately and does not use the job queue.
Returns a map of the result.
"""
runPluginOperation(plugin_id: ID!, args: Map): Any
runPluginOperation(plugin_id: ID!, args: Map): Any @hasRole(role: MODIFY)
reloadPlugins: Boolean!
reloadPlugins: Boolean! @hasRole(role: ADMIN)
"""
Installs the given packages.
@ -537,6 +581,7 @@ type Mutation {
Returns the job ID
"""
installPackages(type: PackageType!, packages: [PackageSpecInput!]!): ID!
@hasRole(role: ADMIN)
"""
Updates the given packages.
If a package is not installed, it will not be installed.
@ -546,48 +591,62 @@ type Mutation {
Returns the job ID.
"""
updatePackages(type: PackageType!, packages: [PackageSpecInput!]): ID!
@hasRole(role: ADMIN)
"""
Uninstalls the given packages.
If an error occurs when uninstalling a package, the job will continue to uninstall the remaining packages.
Returns the job ID
"""
uninstallPackages(type: PackageType!, packages: [PackageSpecInput!]!): ID!
@hasRole(role: ADMIN)
stopJob(job_id: ID!): Boolean!
stopAllJobs: Boolean!
stopJob(job_id: ID!): Boolean! @hasRole(role: ADMIN)
stopAllJobs: Boolean! @hasRole(role: ADMIN)
"Submit fingerprints to stash-box instance"
submitStashBoxFingerprints(
input: StashBoxFingerprintSubmissionInput!
): Boolean!
): Boolean! @hasRole(role: MODIFY)
"Submit scene as draft to stash-box instance"
submitStashBoxSceneDraft(input: StashBoxDraftSubmissionInput!): ID
@hasRole(role: MODIFY)
"Submit performer as draft to stash-box instance"
submitStashBoxPerformerDraft(input: StashBoxDraftSubmissionInput!): ID
@hasRole(role: MODIFY)
"Backup the database. Optionally returns a link to download the database file"
backupDatabase(input: BackupDatabaseInput!): String
backupDatabase(input: BackupDatabaseInput!): String @hasRole(role: ADMIN)
"DANGEROUS: Execute an arbitrary SQL statement that returns rows."
querySQL(sql: String!, args: [Any]): SQLQueryResult!
querySQL(sql: String!, args: [Any]): SQLQueryResult! @hasRole(role: ADMIN)
"DANGEROUS: Execute an arbitrary SQL statement without returning any rows."
execSQL(sql: String!, args: [Any]): SQLExecResult!
execSQL(sql: String!, args: [Any]): SQLExecResult! @hasRole(role: ADMIN)
"Run batch performer tag task. Returns the job ID."
stashBoxBatchPerformerTag(input: StashBoxBatchTagInput!): String!
@hasRole(role: ADMIN)
"Run batch studio tag task. Returns the job ID."
stashBoxBatchStudioTag(input: StashBoxBatchTagInput!): String!
@hasRole(role: ADMIN)
"Enables DLNA for an optional duration. Has no effect if DLNA is enabled by default"
enableDLNA(input: EnableDLNAInput!): Boolean!
enableDLNA(input: EnableDLNAInput!): Boolean! @hasRole(role: ADMIN)
"Disables DLNA for an optional duration. Has no effect if DLNA is disabled by default"
disableDLNA(input: DisableDLNAInput!): Boolean!
disableDLNA(input: DisableDLNAInput!): Boolean! @hasRole(role: ADMIN)
"Enables an IP address for DLNA for an optional duration"
addTempDLNAIP(input: AddTempDLNAIPInput!): Boolean!
addTempDLNAIP(input: AddTempDLNAIPInput!): Boolean! @hasRole(role: ADMIN)
"Removes an IP address from the temporary DLNA whitelist"
removeTempDLNAIP(input: RemoveTempDLNAIPInput!): Boolean!
@hasRole(role: ADMIN)
userCreate(input: UserCreateInput!): User @hasRole(role: ADMIN)
userUpdate(input: UserUpdateInput!): User @hasRole(role: ADMIN)
userDestroy(input: UserDestroyInput!): Boolean! @hasRole(role: ADMIN)
changeUserPassword(input: ChangeUserPasswordInput!): Boolean!
@hasRole(role: ADMIN)
changePassword(input: UserChangePasswordInput!): Boolean!
}
type Subscription {

View file

@ -0,0 +1,53 @@
enum RoleEnum {
ADMIN
READ
MODIFY
}
directive @hasRole(role: RoleEnum!) on FIELD_DEFINITION
directive @isUserOwner on FIELD_DEFINITION
type User {
name: String!
"""
If the user has no roles, they are considered locked and cannot log in.
Should not be visible to other users
"""
roles: [RoleEnum!] @isUserOwner
"""
Should not be visible to other users
"""
api_key: String @isUserOwner
}
input UserCreateInput {
name: String!
"""
Password in plain text
"""
password: String!
roles: [RoleEnum!]!
}
input UserUpdateInput {
existingName: String!
name: String!
roles: [RoleEnum!]!
}
input UserDestroyInput {
name: String!
}
input UserChangePasswordInput {
"""
Password in plain text
"""
existingPassword: String!
newPassword: String!
}
input ChangeUserPasswordInput {
name: String!
newPassword: String!
}

View file

@ -1,6 +1,7 @@
package api
import (
"context"
"errors"
"net"
"net/http"
@ -11,7 +12,9 @@ import (
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/session"
"github.com/stashapp/stash/pkg/user"
)
const (
@ -29,21 +32,47 @@ func allowUnauthenticated(r *http.Request) bool {
return strings.HasPrefix(r.URL.Path, loginEndpoint) || r.URL.Path == logoutEndpoint || r.URL.Path == "/css" || strings.HasPrefix(r.URL.Path, "/assets")
}
func authenticateHandler() func(http.Handler) http.Handler {
type UserAuthenticator interface {
AuthenticateByAPIKey(ctx context.Context, apiKey string) (*models.User, error)
AuthenticateUserByID(ctx context.Context, username string) (*models.User, error)
}
func authenticateHandler(g UserAuthenticator) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c := config.GetInstance()
s := c.UserStore
// error if external access tripwire activated
if accessErr := session.CheckExternalAccessTripwire(c); accessErr != nil {
if accessErr := session.CheckExternalAccessTripwire(s, c); accessErr != nil {
http.Error(w, tripwireActivatedErrMsg, http.StatusForbidden)
return
}
userID, err := manager.GetInstance().SessionStore.Authenticate(w, r)
// try to authenticate using api key first
var u *models.User
var err error
ctx := r.Context()
apiKey := session.GetRequestApiKey(r)
if apiKey != "" {
u, err = g.AuthenticateByAPIKey(ctx, apiKey)
} else {
userID, getErr := manager.GetInstance().SessionStore.GetSessionUserID(w, r)
if getErr != nil {
logger.Errorf("error getting session user ID: %v", getErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if userID != "" {
u, err = g.AuthenticateUserByID(ctx, userID)
}
}
if err != nil {
if !errors.Is(err, session.ErrUnauthorized) {
http.Error(w, err.Error(), http.StatusInternalServerError)
if errors.Is(err, user.ErrInternalError) {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
@ -53,7 +82,7 @@ func authenticateHandler() func(http.Handler) http.Handler {
return
}
if err := session.CheckAllowPublicWithoutAuth(c, r); err != nil {
if err := session.CheckAllowPublicWithoutAuth(s, c, r); err != nil {
var accessErr session.ExternalAccessError
if errors.As(err, &accessErr) {
session.LogExternalAccessError(accessErr)
@ -71,11 +100,9 @@ func authenticateHandler() func(http.Handler) http.Handler {
return
}
ctx := r.Context()
if c.HasCredentials() {
if hc := s.LoginRequired(ctx); hc {
// authentication is required
if userID == "" && !allowUnauthenticated(r) {
if u == nil && !allowUnauthenticated(r) {
// if graphql or a non-webpage was requested, we just return a forbidden error
ext := path.Ext(r.URL.Path)
if r.URL.Path == gqlEndpoint || (ext != "" && ext != ".html") {
@ -102,7 +129,10 @@ func authenticateHandler() func(http.Handler) http.Handler {
}
}
ctx = session.SetCurrentUserID(ctx, userID)
if u != nil {
// set the user object in the context
ctx = session.SetCurrentUser(ctx, *u)
}
r = r.WithContext(ctx)

View file

@ -0,0 +1,48 @@
package api
import (
"context"
"github.com/99designs/gqlgen/graphql"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/session"
)
func HasRoleDirective(ctx context.Context, obj interface{}, next graphql.Resolver, role models.RoleEnum) (interface{}, error) {
currentUser := session.GetCurrentUser(ctx)
// if there is no current user, this is an anonymous request
// we should not end up here unless there are no credentials required
if currentUser == nil {
return next(ctx)
}
if !currentUser.Roles.HasRole(role) {
return nil, session.ErrUnauthorized
}
return next(ctx)
}
func IsUserOwnerDirective(ctx context.Context, obj any, next graphql.Resolver) (res any, err error) {
currentUser := session.GetCurrentUser(ctx)
// if there is no current user, this is an anonymous request
// we should not end up here unless there are no credentials required
if currentUser == nil {
return next(ctx)
}
// get the user from the object
userObj, ok := obj.(*models.User)
if !ok {
return nil, session.ErrUnauthorized
}
// allow admin access
if !currentUser.Roles.HasRole(models.RoleEnumAdmin) && currentUser.Username != userObj.Username {
return nil, session.ErrUnauthorized
}
return next(ctx)
}

View file

@ -37,6 +37,7 @@ type Resolver struct {
imageService manager.ImageService
galleryService manager.GalleryService
groupService manager.GroupService
userService manager.UserService
hookExecutor hookExecutor
}
@ -110,6 +111,9 @@ func (r *Resolver) Plugin() PluginResolver {
func (r *Resolver) ConfigResult() ConfigResultResolver {
return &configResultResolver{r}
}
func (r *Resolver) User() UserResolver {
return &userResolver{r}
}
type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
@ -136,6 +140,7 @@ type folderResolver struct{ *Resolver }
type savedFilterResolver struct{ *Resolver }
type pluginResolver struct{ *Resolver }
type configResultResolver struct{ *Resolver }
type userResolver struct{ *Resolver }
func (r *Resolver) withTxn(ctx context.Context, fn func(ctx context.Context) error) error {
return r.repository.WithTxn(ctx, fn)

View file

@ -0,0 +1,19 @@
package api
import (
"context"
"github.com/stashapp/stash/pkg/models"
)
func (r *userResolver) Name(ctx context.Context, obj *models.User) (string, error) {
return obj.Username, nil
}
func (r *userResolver) Roles(ctx context.Context, obj *models.User) ([]models.RoleEnum, error) {
ret := make([]models.RoleEnum, len(obj.Roles))
for i, role := range obj.Roles {
ret[i] = models.RoleEnum(role)
}
return ret, nil
}

View file

@ -637,29 +637,6 @@ func (r *mutationResolver) ConfigureDefaults(ctx context.Context, input ConfigDe
return makeConfigDefaultsResult(), nil
}
func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input GenerateAPIKeyInput) (string, error) {
c := config.GetInstance()
var newAPIKey string
if input.Clear == nil || !*input.Clear {
username := c.GetUsername()
if username != "" {
var err error
newAPIKey, err = manager.GenerateAPIKey(username)
if err != nil {
return "", err
}
}
}
c.SetString(config.ApiKey, newAPIKey)
if err := c.Write(); err != nil {
return newAPIKey, err
}
return newAPIKey, nil
}
func (r *mutationResolver) ConfigureUI(ctx context.Context, input map[string]interface{}, partial map[string]interface{}) (map[string]interface{}, error) {
c := config.GetInstance()

View file

@ -0,0 +1,86 @@
package api
import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/session"
)
func (r *mutationResolver) UserCreate(ctx context.Context, input UserCreateInput) (*models.User, error) {
err := r.userService.CreateUser(ctx, models.User{
Username: input.Name,
Roles: models.Roles(input.Roles),
}, input.Password)
if err != nil {
return nil, err
}
return r.userService.GetUser(ctx, input.Name)
}
func (r *mutationResolver) UserUpdate(ctx context.Context, input UserUpdateInput) (*models.User, error) {
err := r.userService.UpdateUser(ctx, input.ExistingName, models.User{
Username: input.Name,
Roles: models.Roles(input.Roles),
})
if err != nil {
return nil, err
}
return r.userService.GetUser(ctx, input.Name)
}
func (r *mutationResolver) UserDestroy(ctx context.Context, input UserDestroyInput) (bool, error) {
err := r.userService.DeleteUser(ctx, input.Name)
if err != nil {
return false, err
}
return true, nil
}
func (r *mutationResolver) ChangePassword(ctx context.Context, input UserChangePasswordInput) (bool, error) {
// get current user
u := session.GetCurrentUser(ctx)
err := r.userService.ChangePassword(ctx, u.Username, input.ExistingPassword, input.NewPassword)
if err != nil {
return false, err
}
return true, nil
}
func (r *mutationResolver) ChangeUserPassword(ctx context.Context, input ChangeUserPasswordInput) (bool, error) {
err := r.userService.ChangeUserPassword(ctx, input.Name, input.NewPassword)
if err != nil {
return false, err
}
return true, nil
}
func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input GenerateAPIKeyInput) (string, error) {
u := session.GetCurrentUser(ctx)
if u == nil {
return "", fmt.Errorf("no current user in context")
}
if input.Clear != nil && *input.Clear {
err := r.userService.ClearAPIKey(ctx, u.Username)
if err != nil {
return "", err
}
return "", nil
}
newAPIKey, err := r.userService.GenerateAPIKey(ctx, u.Username)
if err != nil {
return "", err
}
return newAPIKey, nil
}

View file

@ -0,0 +1,17 @@
package api
import (
"context"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/session"
)
func (r *queryResolver) Users(ctx context.Context) ([]*models.User, error) {
return r.userService.AllUsers(ctx)
}
func (r *queryResolver) Me(ctx context.Context) (*models.User, error) {
// get current user
return session.GetCurrentUser(ctx), nil
}

View file

@ -122,9 +122,11 @@ func Initialize() (*Server, error) {
manager: mgr,
}
userStore := manager.GetInstance().UserService
r.Use(middleware.Heartbeat("/healthz"))
r.Use(cors.AllowAll().Handler)
r.Use(authenticateHandler())
r.Use(authenticateHandler(userStore))
visitedPluginHandler := mgr.SessionStore.VisitedPluginHandler()
r.Use(visitedPluginHandler)
@ -162,16 +164,26 @@ func Initialize() (*Server, error) {
imageService := mgr.ImageService
galleryService := mgr.GalleryService
groupService := mgr.GroupService
userService := mgr.UserService
resolver := &Resolver{
repository: repo,
sceneService: sceneService,
imageService: imageService,
galleryService: galleryService,
groupService: groupService,
userService: userService,
hookExecutor: pluginCache,
}
gqlSrv := gqlHandler.New(NewExecutableSchema(Config{Resolvers: resolver}))
gqlCfg := Config{
Resolvers: resolver,
Directives: DirectiveRoot{
HasRole: HasRoleDirective,
IsUserOwner: IsUserOwnerDirective,
},
}
gqlSrv := gqlHandler.New(NewExecutableSchema(gqlCfg))
gqlSrv.SetRecoverFunc(recoverFunc)
gqlSrv.AddTransport(gqlTransport.Websocket{
Upgrader: websocket.Upgrader{
@ -227,9 +239,11 @@ func Initialize() (*Server, error) {
staticLoginUI := statigz.FileServer(ui.LoginUIBox.(fs.ReadDirFS))
r.Get(loginEndpoint, handleLogin())
r.Post(loginEndpoint, handleLoginPost())
r.Get(logoutEndpoint, handleLogout())
sessionStore := mgr.SessionStore
r.Get(loginEndpoint, handleLogin(userService))
r.Post(loginEndpoint, handleLoginPost(sessionStore))
r.Get(logoutEndpoint, handleLogout(sessionStore))
r.Get(loginLocaleEndpoint, handleLoginLocale(cfg))
r.HandleFunc(loginEndpoint+"/*", func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = strings.TrimPrefix(r.URL.Path, loginEndpoint)

View file

@ -103,11 +103,11 @@ func getLoginLocale(lang string) ([]byte, error) {
return data, nil
}
func handleLogin() http.HandlerFunc {
func handleLogin(s manager.UserService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
returnURL := r.URL.Query().Get(returnURLParam)
if !config.GetInstance().HasCredentials() {
if hc := s.LoginRequired(r.Context()); !hc {
if returnURL != "" {
http.Redirect(w, r, returnURL, http.StatusFound)
} else {
@ -121,9 +121,9 @@ func handleLogin() http.HandlerFunc {
}
}
func handleLoginPost() http.HandlerFunc {
func handleLoginPost(s *session.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
err := manager.GetInstance().SessionStore.Login(w, r)
err := s.Login(w, r)
if err != nil {
// always log the error
logger.Errorf("Error logging in: %v from IP: %s", err, r.RemoteAddr)
@ -146,7 +146,7 @@ func handleLoginPost() http.HandlerFunc {
}
}
func handleLogout() http.HandlerFunc {
func handleLogout(s *session.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := manager.GetInstance().SessionStore.Logout(w, r); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@ -155,7 +155,7 @@ func handleLogout() http.HandlerFunc {
// redirect to the login page if credentials are required
prefix := getProxyPrefix(r)
if config.GetInstance().HasCredentials() {
if hc := s.LoginRequired(r.Context()); hc {
http.Redirect(w, r, prefix+loginEndpoint, http.StatusFound)
} else {
http.Redirect(w, r, prefix+"/", http.StatusFound)

File diff suppressed because it is too large Load diff

View file

@ -22,8 +22,6 @@ func TestConcurrentConfigAccess(t *testing.T) {
t.Errorf("Failure setting initial configuration in worker %v iteration %v: %v", wk, l, err)
}
i.HasCredentials()
i.ValidateCredentials("", "")
i.GetConfigFile()
i.GetConfigPath()
i.GetDefaultDatabaseFilePath()
@ -75,7 +73,6 @@ func TestConcurrentConfigAccess(t *testing.T) {
i.SetInterface(ApiKey, i.GetAPIKey())
i.SetInterface(Username, i.GetUsername())
i.SetInterface(Password, i.GetPasswordHash())
i.GetCredentials()
i.SetInterface(MaxSessionAge, i.GetMaxSessionAge())
i.SetInterface(CustomServedFolders, i.GetCustomServedFolders())
i.SetInterface(LegacyCustomUILocation, i.GetUILocation())

View file

@ -62,6 +62,9 @@ func Initialize() (*Config, error) {
main: koanf.New("."),
overrides: koanf.New("."),
}
cfg.UserStore = &UserStore{
Config: cfg,
}
cfg.initOverrides()
@ -96,6 +99,10 @@ func Initialize() (*Config, error) {
}
}
if err := cfg.UserStore.loadUsers(); err != nil {
return nil, fmt.Errorf("failed to load users: %v", err)
}
instance = cfg
return instance, nil
}
@ -110,8 +117,8 @@ func InitializeEmpty() *Config {
return instance
}
func (i *Config) loadFromCommandLine() {
v := i.overrides
func (s *Config) loadFromCommandLine() {
v := s.overrides
if err := v.Load(posflag.ProviderWithFlag(pflag.CommandLine, ".", v, func(f *pflag.Flag) (string, interface{}) {
// ignore flags that have not been changed
@ -125,8 +132,8 @@ func (i *Config) loadFromCommandLine() {
}
}
func (i *Config) loadFromEnv() {
v := i.overrides
func (s *Config) loadFromEnv() {
v := s.overrides
if err := v.Load(env.ProviderWithValue("STASH_", ".", func(key, value string) (string, interface{}) {
key = strings.ToLower(strings.TrimPrefix(key, "STASH_"))
@ -140,12 +147,12 @@ func (i *Config) loadFromEnv() {
}
}
func (i *Config) initOverrides() {
i.loadFromCommandLine()
i.loadFromEnv()
func (s *Config) initOverrides() {
s.loadFromCommandLine()
s.loadFromEnv()
}
func (i *Config) initConfig() error {
func (s *Config) initConfig() error {
configFile := ""
envConfigFile := os.Getenv("STASH_CONFIG_FILE")
@ -158,8 +165,8 @@ func (i *Config) initConfig() error {
if configFile != "" {
// if file does not exist, assume it is a new system
if exists, _ := fsutil.FileExists(configFile); !exists {
i.isNewSystem = true
i.SetConfigFile(configFile)
s.isNewSystem = true
s.SetConfigFile(configFile)
// ensure we can write to the file
if err := fsutil.Touch(configFile); err != nil {
@ -172,15 +179,15 @@ func (i *Config) initConfig() error {
return nil
} else {
// load from provided config file
if err := i.loadFirstFromFiles([]string{configFile}); err != nil {
if err := s.loadFirstFromFiles([]string{configFile}); err != nil {
return err
}
}
} else {
// load from default locations
if err := i.loadFirstFromFiles(defaultConfigLocations); err != nil {
if err := s.loadFirstFromFiles(defaultConfigLocations); err != nil {
if errors.Is(err, errConfigNotFound) {
i.isNewSystem = true
s.isNewSystem = true
return nil
}
@ -191,10 +198,10 @@ func (i *Config) initConfig() error {
return nil
}
func (i *Config) loadFirstFromFiles(f []string) error {
func (s *Config) loadFirstFromFiles(f []string) error {
for _, ff := range f {
if exists, _ := fsutil.FileExists(ff); exists {
return i.load(ff)
return s.load(ff)
}
}

View file

@ -0,0 +1,252 @@
package config
import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/models"
"golang.org/x/crypto/bcrypt"
)
const (
Username = "username"
Password = "password"
Users = "users"
Roles = "roles"
)
type StoredUser struct {
Username string `json:"username" koanf:"username"`
PasswordHash string `json:"passwordhash" koanf:"passwordhash"`
Roles []models.RoleEnum `json:"roles" koanf:"roles"`
ApiKey string `json:"api_key" koanf:"api_key"`
}
type UserStore struct {
*Config
cachedUsers map[string]StoredUser
}
func (s *Config) GetUsername() string {
return s.getString(Username)
}
func (s *Config) GetPasswordHash() string {
return s.getString(Password)
}
func (s *UserStore) legacyUser() *StoredUser {
un := s.getString(Username)
pwHash := s.getString(Password)
apiKey := s.getString(ApiKey)
if un != "" && pwHash != "" {
return &StoredUser{
Username: un,
PasswordHash: pwHash,
Roles: []models.RoleEnum{models.RoleEnumAdmin},
ApiKey: apiKey,
}
}
return nil
}
func (s *UserStore) loadUsers() error {
// done outside lock to avoid deadlock
legacyUser := s.legacyUser()
s.RLock()
defer s.RUnlock()
var ret []*StoredUser
err := s.unmarshalKey(Users, &ret)
if err != nil {
return err
}
// add legacy username
if legacyUser != nil {
ret = append(ret, legacyUser)
}
s.cachedUsers = make(map[string]StoredUser)
for _, u := range ret {
s.cachedUsers[u.Username] = *u
}
return nil
}
func (s *UserStore) convertUser(su StoredUser) *models.User {
return &models.User{
Username: su.Username,
Roles: su.Roles,
ApiKey: su.ApiKey,
}
}
func (s *UserStore) getUser(username string) *StoredUser {
u, ok := s.cachedUsers[username]
if !ok {
return nil
}
return &u
}
func (s *UserStore) GetUser(ctx context.Context, username string) (*models.User, error) {
s.RLock()
defer s.RUnlock()
u := s.getUser(username)
if u == nil {
return nil, nil
}
return s.convertUser(*u), nil
}
func (s *UserStore) AllUsers(ctx context.Context) ([]*models.User, error) {
var users []*models.User
s.RLock()
defer s.RUnlock()
for _, su := range s.cachedUsers {
users = append(users, s.convertUser(su))
}
return users, nil
}
func (s *UserStore) LoginRequired(ctx context.Context) bool {
return len(s.cachedUsers) > 0
}
func hashPassword(password string) string {
hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
return string(hash)
}
func (s *UserStore) ValidateCredentials(ctx context.Context, username string, password string) bool {
u := s.getUser(username)
if u == nil {
return false
}
err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password))
return err == nil
}
func (s *UserStore) saveUsers() error {
// convert to list
users := make([]StoredUser, 0, len(s.cachedUsers))
for _, u := range s.cachedUsers {
users = append(users, u)
}
s.setInterfaceNoLock(Users, users)
return s.writeNoLock()
}
func (s *UserStore) ChangeUserPassword(ctx context.Context, username string, newPassword string) error {
s.Lock()
defer s.Unlock()
u := s.getUser(username)
if u == nil {
return fmt.Errorf("user not found")
}
newHash := hashPassword(newPassword)
updatedUser := *u
updatedUser.PasswordHash = newHash
s.cachedUsers[username] = updatedUser
return s.saveUsers()
}
func (s *UserStore) ChangeUserAPIKey(ctx context.Context, username string, newAPIKey string) error {
s.Lock()
defer s.Unlock()
u := s.getUser(username)
if u == nil {
return fmt.Errorf("user not found")
}
updatedUser := *u
updatedUser.ApiKey = newAPIKey
s.cachedUsers[username] = updatedUser
return s.saveUsers()
}
func (s *UserStore) CreateUser(ctx context.Context, u models.User, password string) error {
s.Lock()
defer s.Unlock()
existingUser := s.getUser(u.Username)
if existingUser != nil {
return fmt.Errorf("user already exists")
}
newUser := StoredUser{
Username: u.Username,
PasswordHash: hashPassword(password),
Roles: u.Roles,
ApiKey: u.ApiKey,
}
s.cachedUsers[u.Username] = newUser
return s.saveUsers()
}
// ReplaceUser replaces an existing user with updated information.
// ApiKey is ignored and not changed by this method.
func (s *UserStore) ReplaceUser(ctx context.Context, username string, updated models.User) error {
s.Lock()
defer s.Unlock()
existingUser := s.getUser(username)
if existingUser == nil {
return fmt.Errorf("user not found")
}
updatedUser := StoredUser{
Username: updated.Username,
PasswordHash: existingUser.PasswordHash,
Roles: updated.Roles,
// don't allow changing apikey with this method
ApiKey: existingUser.ApiKey,
}
// if username changed, remove old entry
if username != updated.Username {
delete(s.cachedUsers, username)
}
s.cachedUsers[updated.Username] = updatedUser
return s.saveUsers()
}
func (s *UserStore) DeleteUser(ctx context.Context, username string) error {
s.Lock()
defer s.Unlock()
existingUser := s.getUser(username)
if existingUser == nil {
return fmt.Errorf("user not found")
}
delete(s.cachedUsers, username)
return s.saveUsers()
}

View file

@ -27,6 +27,7 @@ import (
"github.com/stashapp/stash/pkg/scraper"
"github.com/stashapp/stash/pkg/session"
"github.com/stashapp/stash/pkg/sqlite"
"github.com/stashapp/stash/pkg/user"
"github.com/stashapp/stash/pkg/utils"
"github.com/stashapp/stash/ui"
)
@ -109,6 +110,10 @@ func Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) {
scanSubs: &subscriptionManager{},
}
mgr.UserService = &user.Service{
Store: cfg.UserStore,
}
if !cfg.IsNewSystem() {
logger.Infof("using config file: %s", cfg.GetConfigFile())
@ -130,7 +135,7 @@ func Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) {
// create temporary session store - this will be re-initialised
// after config is complete
mgr.SessionStore = session.NewStore(cfg)
mgr.SessionStore = session.NewStore(cfg, instance.UserService)
logger.Warnf("config file %snot found. Assuming new system...", cfgFile)
}
@ -189,7 +194,7 @@ func initJobManager(cfg *config.Config) *job.Manager {
func (s *Manager) postInit(ctx context.Context) error {
s.RefreshConfig()
s.SessionStore = session.NewStore(s.Config)
s.SessionStore = session.NewStore(s.Config, s.UserService)
s.PluginCache.RegisterSessionStore(s.SessionStore)
s.RefreshPluginCache()
@ -251,7 +256,7 @@ func (s *Manager) postInit(ctx context.Context) error {
}
func (s *Manager) checkSecurityTripwire() {
if err := session.CheckExternalAccessTripwire(s.Config); err != nil {
if err := session.CheckExternalAccessTripwire(s.Config.UserStore, s.Config); err != nil {
session.LogExternalAccessError(*err)
}
}

View file

@ -67,6 +67,7 @@ type Manager struct {
ImageService ImageService
GalleryService GalleryService
GroupService GroupService
UserService UserService
scanSubs *subscriptionManager
}

View file

@ -7,6 +7,7 @@ import (
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/session"
)
type SceneService interface {
@ -46,3 +47,20 @@ type GroupService interface {
RemoveSubGroups(ctx context.Context, groupID int, subGroupIDs []int) error
ReorderSubGroups(ctx context.Context, groupID int, subGroupIDs []int, insertPointID int, insertAfter bool) error
}
type UserService interface {
session.Authenticator
AllUsers(ctx context.Context) ([]*models.User, error)
GetUser(ctx context.Context, username string) (*models.User, error)
LoginRequired(ctx context.Context) bool
AuthenticateByAPIKey(ctx context.Context, apiKey string) (*models.User, error)
AuthenticateUserByID(ctx context.Context, username string) (*models.User, error)
CreateUser(ctx context.Context, u models.User, password string) error
UpdateUser(ctx context.Context, username string, updated models.User) error
ChangePassword(ctx context.Context, username, existingPassword, newPassword string) error
ChangeUserPassword(ctx context.Context, username string, newPassword string) error
GenerateAPIKey(ctx context.Context, username string) (string, error)
ClearAPIKey(ctx context.Context, username string) error
DeleteUser(ctx context.Context, username string) error
}

13
pkg/models/model_user.go Normal file
View file

@ -0,0 +1,13 @@
package models
type User struct {
Username string
Roles Roles
ApiKey string
}
type UserInput struct {
Username string
Roles Roles
Password string
}

70
pkg/models/role.go Normal file
View file

@ -0,0 +1,70 @@
package models
import (
"fmt"
"io"
"strconv"
)
type RoleEnum string
const (
RoleEnumAdmin RoleEnum = "ADMIN"
RoleEnumRead RoleEnum = "READ"
RoleEnumModify RoleEnum = "MODIFY"
)
func (e RoleEnum) Implies(other RoleEnum) bool {
// admin has all roles
if e == RoleEnumAdmin {
return true
}
// until we add a NONE value, all values imply read
if e.IsValid() && other == RoleEnumRead {
return true
}
// all others only imply themselves
return e == other
}
func (e RoleEnum) IsValid() bool {
switch e {
case RoleEnumRead, RoleEnumModify, RoleEnumAdmin:
return true
}
return false
}
func (e RoleEnum) String() string {
return string(e)
}
func (e *RoleEnum) UnmarshalGQL(v interface{}) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = RoleEnum(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid RoleEnum", str)
}
return nil
}
func (e RoleEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
type Roles []RoleEnum
func (r Roles) HasRole(role RoleEnum) bool {
for _, r := range r {
if r.Implies(role) {
return true
}
}
return false
}

View file

@ -1,6 +1,7 @@
package session
import (
"context"
"fmt"
"net"
"net/http"
@ -15,8 +16,8 @@ func (e ExternalAccessError) Error() string {
return fmt.Sprintf("stash accessed from external IP %s", net.IP(e).String())
}
func CheckAllowPublicWithoutAuth(c ExternalAccessConfig, r *http.Request) error {
if !c.HasCredentials() && !c.GetDangerousAllowPublicWithoutAuth() && !c.IsNewSystem() {
func CheckAllowPublicWithoutAuth(s CredentialStore, c ExternalAccessConfig, r *http.Request) error {
if hc := s.LoginRequired(context.Background()); !hc && !c.GetDangerousAllowPublicWithoutAuth() && !c.IsNewSystem() {
requestIPString, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return fmt.Errorf("error parsing remote host (%s): %w", r.RemoteAddr, err)
@ -59,8 +60,8 @@ func CheckAllowPublicWithoutAuth(c ExternalAccessConfig, r *http.Request) error
return nil
}
func CheckExternalAccessTripwire(c ExternalAccessConfig) *ExternalAccessError {
if !c.HasCredentials() && !c.GetDangerousAllowPublicWithoutAuth() {
func CheckExternalAccessTripwire(s CredentialStore, c ExternalAccessConfig) *ExternalAccessError {
if hc := s.LoginRequired(context.Background()); !hc && !c.GetDangerousAllowPublicWithoutAuth() {
if remoteIP := c.GetSecurityTripwireAccessedFromPublicInternet(); remoteIP != "" {
err := ExternalAccessError(net.ParseIP(remoteIP))
return &err

View file

@ -1,6 +1,7 @@
package session
import (
"context"
"errors"
"net/http"
"testing"
@ -13,7 +14,7 @@ type config struct {
securityTripwireAccessedFromPublicInternet string
}
func (c *config) HasCredentials() bool {
func (c *config) LoginRequired(ctx context.Context) bool {
return c.username != "" && c.password != ""
}
@ -34,7 +35,7 @@ func TestCheckAllowPublicWithoutAuth(t *testing.T) {
doTest := func(caseIndex int, r *http.Request, expectedErr interface{}) {
t.Helper()
err := CheckAllowPublicWithoutAuth(c, r)
err := CheckAllowPublicWithoutAuth(c, c, r)
if expectedErr == nil && err == nil {
return
@ -120,7 +121,7 @@ func TestCheckAllowPublicWithoutAuth(t *testing.T) {
RemoteAddr: remoteAddr,
}
err := CheckAllowPublicWithoutAuth(c, r)
err := CheckAllowPublicWithoutAuth(c, c, r)
if err == nil {
t.Errorf("[%s]: expected error", remoteAddr)
continue
@ -137,7 +138,7 @@ func TestCheckAllowPublicWithoutAuth(t *testing.T) {
c.username = "admin"
c.password = "admin"
if err := CheckAllowPublicWithoutAuth(c, r); err != nil {
if err := CheckAllowPublicWithoutAuth(c, c, r); err != nil {
t.Errorf("unexpected error: %v", err)
}
@ -146,7 +147,7 @@ func TestCheckAllowPublicWithoutAuth(t *testing.T) {
c.dangerousAllowPublicWithoutAuth = true
if err := CheckAllowPublicWithoutAuth(c, r); err != nil {
if err := CheckAllowPublicWithoutAuth(c, c, r); err != nil {
t.Errorf("unexpected error: %v", err)
}
}
@ -160,7 +161,7 @@ func TestCheckExternalAccessTripwire(t *testing.T) {
c.username = "admin"
c.password = "admin"
if err := CheckExternalAccessTripwire(c); err != nil {
if err := CheckExternalAccessTripwire(c, c); err != nil {
t.Errorf("unexpected error %v", err)
}
@ -170,19 +171,19 @@ func TestCheckExternalAccessTripwire(t *testing.T) {
// HACK - this key isn't publically exposed
c.dangerousAllowPublicWithoutAuth = true
if err := CheckExternalAccessTripwire(c); err != nil {
if err := CheckExternalAccessTripwire(c, c); err != nil {
t.Errorf("unexpected error %v", err)
}
c.dangerousAllowPublicWithoutAuth = false
if err := CheckExternalAccessTripwire(c); err == nil {
if err := CheckExternalAccessTripwire(c, c); err == nil {
t.Errorf("expected error %v", ExternalAccessError("4.4.4.4"))
}
c.securityTripwireAccessedFromPublicInternet = ""
if err := CheckExternalAccessTripwire(c); err != nil {
if err := CheckExternalAccessTripwire(c, c); err != nil {
t.Errorf("unexpected error %v", err)
}
}

View file

@ -1,17 +1,21 @@
package session
import "context"
type ExternalAccessConfig interface {
HasCredentials() bool
GetDangerousAllowPublicWithoutAuth() bool
GetSecurityTripwireAccessedFromPublicInternet() string
IsNewSystem() bool
}
type CredentialStore interface {
LoginRequired(ctx context.Context) bool
}
type SessionConfig interface {
GetUsername() string
GetAPIKey() string
GetSessionStoreKey() []byte
GetMaxSessionAge() int
ValidateCredentials(username string, password string) bool
}

View file

@ -61,7 +61,7 @@ func setVisitedPluginHooks(ctx context.Context, visitedPlugins []VisitedPluginHo
}
func (s *Store) MakePluginCookie(ctx context.Context) *http.Cookie {
currentUser := GetCurrentUserID(ctx)
currentUser := GetCurrentUser(ctx)
visitedPlugins := GetVisitedPluginHooks(ctx)
session := sessions.NewSession(s.sessionStore, cookieName)

View file

@ -8,6 +8,7 @@ import (
"github.com/gorilla/sessions"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
)
type key int
@ -44,15 +45,22 @@ func (e InvalidCredentialsError) Error() string {
var ErrUnauthorized = errors.New("unauthorized")
type Store struct {
sessionStore *sessions.CookieStore
config SessionConfig
type Authenticator interface {
LoginRequired(ctx context.Context) bool
ValidateCredentials(ctx context.Context, username string, password string) error
}
func NewStore(c SessionConfig) *Store {
type Store struct {
sessionStore *sessions.CookieStore
authenticator Authenticator
config SessionConfig
}
func NewStore(c SessionConfig, a Authenticator) *Store {
ret := &Store{
sessionStore: sessions.NewCookieStore(c.GetSessionStoreKey()),
config: c,
sessionStore: sessions.NewCookieStore(c.GetSessionStoreKey()),
config: c,
authenticator: a,
}
ret.sessionStore.MaxAge(c.GetMaxSessionAge())
@ -61,6 +69,10 @@ func NewStore(c SessionConfig) *Store {
return ret
}
func (s *Store) LoginRequired(ctx context.Context) bool {
return s.authenticator.LoginRequired(ctx)
}
func (s *Store) Login(w http.ResponseWriter, r *http.Request) error {
// ignore error - we want a new session regardless
newSession, _ := s.sessionStore.Get(r, cookieName)
@ -69,16 +81,16 @@ func (s *Store) Login(w http.ResponseWriter, r *http.Request) error {
password := r.FormValue(passwordFormKey)
// authenticate the user
if !s.config.ValidateCredentials(username, password) {
err := s.authenticator.ValidateCredentials(r.Context(), username, password)
if err != nil {
return &InvalidCredentialsError{Username: username}
}
// since we only have one user, don't leak the name
logger.Info("User logged in")
logger.Infof("User %s logged in", username)
newSession.Values[userIDKey] = username
err := newSession.Save(r, w)
err = newSession.Save(r, w)
if err != nil {
return err
}
@ -92,6 +104,8 @@ func (s *Store) Logout(w http.ResponseWriter, r *http.Request) error {
return err
}
userID, _ := session.Values[userIDKey].(string)
delete(session.Values, userIDKey)
session.Options.MaxAge = -1
@ -100,8 +114,7 @@ func (s *Store) Logout(w http.ResponseWriter, r *http.Request) error {
return err
}
// since we only have one user, don't leak the name
logger.Infof("User logged out")
logger.Infof("User %s logged out", userID)
return nil
}
@ -131,25 +144,22 @@ func (s *Store) GetSessionUserID(w http.ResponseWriter, r *http.Request) (string
return "", nil
}
func SetCurrentUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, contextUser, userID)
func SetCurrentUser(ctx context.Context, u models.User) context.Context {
return context.WithValue(ctx, contextUser, u)
}
// GetCurrentUserID gets the current user id from the provided context
func GetCurrentUserID(ctx context.Context) *string {
// GetCurrentUser gets the current user id from the provided context
func GetCurrentUser(ctx context.Context) *models.User {
userCtxVal := ctx.Value(contextUser)
if userCtxVal != nil {
currentUser := userCtxVal.(string)
currentUser := userCtxVal.(models.User)
return &currentUser
}
return nil
}
func (s *Store) Authenticate(w http.ResponseWriter, r *http.Request) (userID string, err error) {
c := s.config
// translate api key into current user, if present
func GetRequestApiKey(r *http.Request) string {
apiKey := r.Header.Get(ApiKeyHeader)
// try getting the api key as a query parameter
@ -157,23 +167,5 @@ func (s *Store) Authenticate(w http.ResponseWriter, r *http.Request) (userID str
apiKey = r.URL.Query().Get(ApiKeyParameter)
}
if apiKey != "" {
// match against configured API and set userID to the
// configured username. In future, we'll want to
// get the username from the key.
if c.GetAPIKey() != apiKey {
return "", ErrUnauthorized
}
userID = c.GetUsername()
} else {
// handle session
userID, err = s.GetSessionUserID(w, r)
}
if err != nil {
return "", err
}
return
return apiKey
}

View file

@ -1,4 +1,4 @@
package manager
package user
import (
"errors"
@ -17,7 +17,7 @@ type APIKeyClaims struct {
jwt.RegisteredClaims
}
func GenerateAPIKey(userID string) (string, error) {
func generateAPIKey(userID string) (string, error) {
claims := &APIKeyClaims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{

1
pkg/user/authenticate.go Normal file
View file

@ -0,0 +1 @@
package user

397
pkg/user/service.go Normal file
View file

@ -0,0 +1,397 @@
package user
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
)
var (
ErrUserNotExist = errors.New("user not found")
ErrEmptyUsername = errors.New("empty username")
ErrUsernameHasWhitespace = errors.New("username has leading or trailing whitespace")
ErrDeleteLastAdminUser = errors.New("final admin user cannot be deleted")
ErrRemoveLastAdminRole = errors.New("final admin role cannot be removed")
ErrInternalError = errors.New("internal error")
ErrAccessDenied = errors.New("access denied")
ErrCurrentPasswordIncorrect = errors.New("current password incorrect")
ErrUserAlreadyExists = errors.New("user with that username already exists")
)
type UserSource interface {
AllUsers(ctx context.Context) ([]*models.User, error)
GetUser(ctx context.Context, username string) (*models.User, error)
ValidateCredentials(ctx context.Context, username string, password string) bool
CreateUser(ctx context.Context, u models.User, password string) error
ReplaceUser(ctx context.Context, username string, updated models.User) error
ChangeUserPassword(ctx context.Context, username string, newPassword string) error
ChangeUserAPIKey(ctx context.Context, username string, newAPIKey string) error
DeleteUser(ctx context.Context, username string) error
}
type Service struct {
Store UserSource
}
func (s *Service) LoginRequired(ctx context.Context) bool {
u, _ := s.Store.AllUsers(ctx)
return len(u) > 0
}
func (s *Service) GetUser(ctx context.Context, username string) (*models.User, error) {
return s.Store.GetUser(ctx, username)
}
func (s *Service) AllUsers(ctx context.Context) ([]*models.User, error) {
return s.Store.AllUsers(ctx)
}
func userIsLocked(u *models.User) bool {
return len(u.Roles) == 0
}
func (s *Service) ValidateCredentials(ctx context.Context, username string, password string) error {
// ensure user is not locked
u, err := s.GetUser(ctx, username)
if err != nil {
logger.Errorf("error getting user for credential validation: %v", err)
return ErrInternalError
}
if u == nil {
logger.Infof("[login attempt] user %s not found during credential validation", username)
return ErrAccessDenied
}
if userIsLocked(u) {
logger.Infof("[login attempt] user %s is locked", username)
return ErrAccessDenied
}
if !s.Store.ValidateCredentials(ctx, username, password) {
logger.Infof("[login attempt] invalid credentials for user %s", username)
return ErrAccessDenied
}
return nil
}
// AuthenticateUserByID authenticates a user by their username and returns the user object if successful.
// This is used for session-based authentication.
// It will return an error if the user does not exist or if the user is locked.
func (s *Service) AuthenticateUserByID(ctx context.Context, username string) (*models.User, error) {
u, err := s.GetUser(ctx, username)
if err != nil {
logger.Errorf("error getting user for authentication: %v", err)
return nil, ErrInternalError
}
if u == nil {
logger.Infof("[authentication] user %s not found", username)
return nil, ErrAccessDenied
}
if userIsLocked(u) {
logger.Infof("[authentication] user %s is locked", username)
return nil, ErrAccessDenied
}
return u, nil
}
func (s *Service) AuthenticateByAPIKey(ctx context.Context, apiKey string) (*models.User, error) {
username, err := GetUserIDFromAPIKey(apiKey)
if err != nil {
logger.Errorf("error getting user ID from api key: %v", err)
return nil, ErrInternalError
}
user, err := s.GetUser(ctx, username)
if err != nil {
logger.Errorf("error getting user by username: %v", err)
return nil, ErrInternalError
}
if user == nil {
logger.Infof("[apikey authentication] user %s not found", username)
return nil, ErrAccessDenied
}
if userIsLocked(user) {
logger.Infof("[apikey authentication] user %s is locked", username)
return nil, ErrAccessDenied
}
// ensure apikey matches
if user.ApiKey != apiKey {
logger.Infof("[apikey authentication] invalid api key for user %s", username)
return nil, ErrAccessDenied
}
return user, nil
}
func (s *Service) validateUsername(username string) error {
if username == "" {
return ErrEmptyUsername
}
// username must not have leading or trailing whitespace
trimmed := strings.TrimSpace(username)
if trimmed != username {
return ErrUsernameHasWhitespace
}
return nil
}
func (s *Service) validatePassword(password string) error {
if password == "" {
return errors.New("password cannot be empty")
}
// add more password validation as needed
return nil
}
func (s *Service) CreateUser(ctx context.Context, u models.User, password string) error {
// validate input
// ensure username is valid
if err := s.validateUsername(u.Username); err != nil {
return err
}
// check if user exists
existingUser, err := s.GetUser(ctx, u.Username)
if err != nil {
return fmt.Errorf("error checking existing users: %w", err)
}
if existingUser != nil {
return ErrUserAlreadyExists
}
// validate password
if err := s.validatePassword(password); err != nil {
return err
}
// if this is the first user, make them an admin
users, err := s.AllUsers(ctx)
if err != nil {
return fmt.Errorf("error getting existing users: %w", err)
}
if len(users) == 0 && !u.Roles.HasRole(models.RoleEnumAdmin) {
return errors.New("the first user must be an admin")
}
// create user in store
if err := s.Store.CreateUser(ctx, u, password); err != nil {
return fmt.Errorf("error creating user: %w", err)
}
logger.Infof("[user] created %q", u.Username)
return nil
}
func (s *Service) UpdateUser(ctx context.Context, username string, updated models.User) error {
// validate input
// check if user exists
existingUser, err := s.GetUser(ctx, username)
if err != nil {
return fmt.Errorf("error getting existing user: %w", err)
}
if existingUser == nil {
return ErrUserNotExist
}
existingRoles := existingUser.Roles
// ensure username is valid
if username != updated.Username {
if err := s.validateUsername(updated.Username); err != nil {
return err
}
// ensure new username doesn't already exist
otherUser, err := s.GetUser(ctx, updated.Username)
if err != nil {
return fmt.Errorf("error checking existing user: %w", err)
}
if otherUser != nil {
return ErrUserAlreadyExists
}
}
// validate roles
// don't allow removing admin from last admin user
if existingRoles.HasRole(models.RoleEnumAdmin) && !updated.Roles.HasRole(models.RoleEnumAdmin) {
users, err := s.AllUsers(ctx)
if err != nil {
return fmt.Errorf("error getting all users: %w", err)
}
hasAdmin := false
for _, u := range users {
if u.Username != existingUser.Username && u.Roles.HasRole(models.RoleEnumAdmin) {
hasAdmin = true
break
}
}
if !hasAdmin {
return ErrRemoveLastAdminRole
}
}
// update user in store
if err := s.Store.ReplaceUser(ctx, username, updated); err != nil {
return fmt.Errorf("error updating user: %w", err)
}
if username != updated.Username {
logger.Infof("[user] updated name %q -> %q", username, updated.Username)
}
if !slices.Equal(existingRoles, updated.Roles) {
logger.Infof("[user] updated roles for user %q", updated.Username)
}
return nil
}
func (s *Service) ChangePassword(ctx context.Context, username, currentPassword, newPassword string) error {
// validate current credentials
if err := s.ValidateCredentials(ctx, username, currentPassword); err != nil {
logger.Infof("[user] failed password change attempt for %q: incorrect current password", username)
return ErrCurrentPasswordIncorrect
}
return s.ChangeUserPassword(ctx, username, newPassword)
}
func (s *Service) ChangeUserPassword(ctx context.Context, username, newPassword string) error {
// check if user exists
existingUser, err := s.GetUser(ctx, username)
if err != nil {
return fmt.Errorf("error getting existing user: %w", err)
}
if existingUser == nil {
return ErrUserNotExist
}
// validate new password
if err := s.validatePassword(newPassword); err != nil {
return err
}
// change password in store
if err := s.Store.ChangeUserPassword(ctx, username, newPassword); err != nil {
return fmt.Errorf("error changing user password: %w", err)
}
logger.Infof("[user] changed password for %q", username)
return nil
}
func (s *Service) GenerateAPIKey(ctx context.Context, username string) (string, error) {
// check if user exists
existingUser, err := s.GetUser(ctx, username)
if err != nil {
return "", fmt.Errorf("error getting existing user: %w", err)
}
if existingUser == nil {
return "", ErrUserNotExist
}
// generate new api key
newAPIKey, err := generateAPIKey(username)
if err != nil {
return "", fmt.Errorf("error generating api key: %w", err)
}
if err := s.Store.ChangeUserAPIKey(ctx, username, newAPIKey); err != nil {
return "", fmt.Errorf("error updating user with new api key: %w", err)
}
logger.Infof("[user] generated new API key for %q", username)
return newAPIKey, nil
}
func (s *Service) ClearAPIKey(ctx context.Context, username string) error {
// check if user exists
existingUser, err := s.GetUser(ctx, username)
if err != nil {
return fmt.Errorf("error getting existing user: %w", err)
}
if existingUser == nil {
return ErrUserNotExist
}
// clear api key
if err := s.Store.ChangeUserAPIKey(ctx, username, ""); err != nil {
return fmt.Errorf("error clearing user api key: %w", err)
}
logger.Infof("[user] cleared API key for %q", username)
return nil
}
func (s *Service) DeleteUser(ctx context.Context, username string) error {
// check if user exists
existingUser, err := s.GetUser(ctx, username)
if err != nil {
return fmt.Errorf("error getting existing user: %w", err)
}
if existingUser == nil {
return ErrUserNotExist
}
// don't allow deleting last admin user unless it is the last user
if existingUser.Roles.HasRole(models.RoleEnumAdmin) {
users, err := s.AllUsers(ctx)
if err != nil {
return fmt.Errorf("error getting all users: %w", err)
}
hasAdmin := false
for _, u := range users {
if u.Username != username && u.Roles.HasRole(models.RoleEnumAdmin) {
hasAdmin = true
break
}
}
// allow deleting last admin if it is the only user
if !hasAdmin && len(users) > 1 {
return ErrDeleteLastAdminUser
}
}
// delete user from store
if err := s.Store.DeleteUser(ctx, username); err != nil {
return fmt.Errorf("error deleting user: %w", err)
}
logger.Infof("[user] deleted %q", username)
return nil
}