mirror of
https://github.com/stashapp/stash.git
synced 2026-02-08 16:31:52 +01:00
Merge 3e4ee0eaef into 8dec195c2d
This commit is contained in:
commit
36822d7550
29 changed files with 1778 additions and 744 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
53
graphql/schema/types/user.graphql
Normal file
53
graphql/schema/types/user.graphql
Normal 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!
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
48
internal/api/directives.go
Normal file
48
internal/api/directives.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
19
internal/api/resolver_model_user.go
Normal file
19
internal/api/resolver_model_user.go
Normal 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
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
86
internal/api/resolver_mutation_user.go
Normal file
86
internal/api/resolver_mutation_user.go
Normal 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
|
||||
}
|
||||
17
internal/api/resolver_query_user.go
Normal file
17
internal/api/resolver_query_user.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
252
internal/manager/config/users.go
Normal file
252
internal/manager/config/users.go
Normal 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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ type Manager struct {
|
|||
ImageService ImageService
|
||||
GalleryService GalleryService
|
||||
GroupService GroupService
|
||||
UserService UserService
|
||||
|
||||
scanSubs *subscriptionManager
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
13
pkg/models/model_user.go
Normal 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
70
pkg/models/role.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ¤tUser
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
1
pkg/user/authenticate.go
Normal file
|
|
@ -0,0 +1 @@
|
|||
package user
|
||||
397
pkg/user/service.go
Normal file
397
pkg/user/service.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue