diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt index 123cf53a..9060a831 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/SettingsMutation.kt @@ -182,6 +182,9 @@ class SettingsMutation { // Authentication updateSetting(settings.authMode, serverConfig.authMode) + updateSetting(settings.jwtAudience, serverConfig.jwtAudience) + updateSetting(settings.jwtTokenExpiry, serverConfig.jwtTokenExpiry) + updateSetting(settings.jwtRefreshExpiry, serverConfig.jwtRefreshExpiry) updateSetting(settings.authUsername, serverConfig.authUsername) updateSetting(settings.authPassword, serverConfig.authPassword) updateSetting(settings.basicAuthEnabled, serverConfig.basicAuthEnabled) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt index c9231c5d..6c8034cc 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskGraphQLSchema.kt @@ -43,6 +43,7 @@ import suwayomi.tachidesk.graphql.queries.TrackQuery import suwayomi.tachidesk.graphql.queries.UpdateQuery import suwayomi.tachidesk.graphql.server.primitives.Cursor import suwayomi.tachidesk.graphql.server.primitives.GraphQLCursor +import suwayomi.tachidesk.graphql.server.primitives.GraphQLDurationAsString import suwayomi.tachidesk.graphql.server.primitives.GraphQLLongAsString import suwayomi.tachidesk.graphql.server.primitives.GraphQLUpload import suwayomi.tachidesk.graphql.subscriptions.DownloadSubscription @@ -50,11 +51,13 @@ import suwayomi.tachidesk.graphql.subscriptions.InfoSubscription import suwayomi.tachidesk.graphql.subscriptions.UpdateSubscription import kotlin.reflect.KClass import kotlin.reflect.KType +import kotlin.time.Duration class CustomSchemaGeneratorHooks : FlowSubscriptionSchemaGeneratorHooks() { override fun willGenerateGraphQLType(type: KType): GraphQLType? = when (type.classifier as? KClass<*>) { Long::class -> GraphQLLongAsString // encode to string for JS + Duration::class -> GraphQLDurationAsString // encode Duration as ISO-8601 string Cursor::class -> GraphQLCursor UploadedFile::class -> GraphQLUpload else -> super.willGenerateGraphQLType(type) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/DurationAsString.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/DurationAsString.kt new file mode 100644 index 00000000..81f28dfd --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/primitives/DurationAsString.kt @@ -0,0 +1,122 @@ +package suwayomi.tachidesk.graphql.server.primitives + +import graphql.GraphQLContext +import graphql.execution.CoercedVariables +import graphql.language.StringValue +import graphql.language.Value +import graphql.scalar.CoercingUtil +import graphql.schema.Coercing +import graphql.schema.CoercingParseLiteralException +import graphql.schema.CoercingParseValueException +import graphql.schema.CoercingSerializeException +import graphql.schema.GraphQLScalarType +import java.util.Locale +import kotlin.time.Duration + +val GraphQLDurationAsString: GraphQLScalarType = + GraphQLScalarType + .newScalar() + .name("Duration") + .description("An ISO-8601 encoded duration string") + .coercing(GraphqlDurationAsStringCoercing()) + .build() + +private class GraphqlDurationAsStringCoercing : Coercing { + private fun toStringImpl(input: Any): String = + when (input) { + is Duration -> input.toIsoString() + is String -> Duration.parse(input).toIsoString() + else -> throw CoercingSerializeException( + "Expected a Duration or String but was ${CoercingUtil.typeName(input)}", + ) + } + + private fun parseValueImpl( + input: Any, + locale: Locale, + ): Duration { + if (input !is String) { + throw CoercingParseValueException( + CoercingUtil.i18nMsg( + locale, + "String.unexpectedRawValueType", + CoercingUtil.typeName(input), + ), + ) + } + return try { + Duration.parse(input) + } catch (e: IllegalArgumentException) { + throw CoercingParseValueException( + "Invalid duration format: $input. Expected ISO-8601 duration string (e.g., 'PT30M', 'P1D')", + e, + ) + } + } + + private fun parseLiteralImpl( + input: Any, + locale: Locale, + ): Duration { + if (input !is StringValue) { + throw CoercingParseLiteralException( + CoercingUtil.i18nMsg( + locale, + "Scalar.unexpectedAstType", + "StringValue", + CoercingUtil.typeName(input), + ), + ) + } + return try { + Duration.parse(input.value) + } catch (e: IllegalArgumentException) { + throw CoercingParseLiteralException( + "Invalid duration format: ${input.value}. Expected ISO-8601 duration string (e.g., 'PT30M', 'P1D')", + e, + ) + } + } + + private fun valueToLiteralImpl(input: Any): StringValue = StringValue.newStringValue(toStringImpl(input)).build() + + @Deprecated("") + override fun serialize(dataFetcherResult: Any): String = toStringImpl(dataFetcherResult) + + @Throws(CoercingSerializeException::class) + override fun serialize( + dataFetcherResult: Any, + graphQLContext: GraphQLContext, + locale: Locale, + ): String = toStringImpl(dataFetcherResult) + + @Deprecated("") + override fun parseValue(input: Any): Duration = parseValueImpl(input, Locale.getDefault()) + + @Throws(CoercingParseValueException::class) + override fun parseValue( + input: Any, + graphQLContext: GraphQLContext, + locale: Locale, + ): Duration = parseValueImpl(input, locale) + + @Deprecated("") + override fun parseLiteral(input: Any): Duration = parseLiteralImpl(input, Locale.getDefault()) + + @Throws(CoercingParseLiteralException::class) + override fun parseLiteral( + input: Value<*>, + variables: CoercedVariables, + graphQLContext: GraphQLContext, + locale: Locale, + ): Duration = parseLiteralImpl(input, locale) + + @Deprecated("") + override fun valueToLiteral(input: Any): Value<*> = valueToLiteralImpl(input) + + override fun valueToLiteral( + input: Any, + graphQLContext: GraphQLContext, + locale: Locale, + ): Value<*> = valueToLiteralImpl(input) +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt index 2bac6679..9896b054 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt @@ -12,6 +12,7 @@ import org.jetbrains.exposed.sql.SortOrder import suwayomi.tachidesk.graphql.server.primitives.Node import suwayomi.tachidesk.server.ServerConfig import suwayomi.tachidesk.server.serverConfig +import kotlin.time.Duration interface Settings : Node { val ip: String? @@ -65,6 +66,9 @@ interface Settings : Node { // Authentication val authMode: AuthMode? + val jwtAudience: String? + val jwtTokenExpiry: Duration? + val jwtRefreshExpiry: Duration? val authUsername: String? val authPassword: String? @@ -177,6 +181,9 @@ data class PartialSettingsType( override val updateMangas: Boolean?, // Authentication override val authMode: AuthMode?, + override val jwtAudience: String?, + override val jwtTokenExpiry: Duration?, + override val jwtRefreshExpiry: Duration?, override val authUsername: String?, override val authPassword: String?, @GraphQLDeprecated("Removed - prefer authMode") @@ -267,6 +274,9 @@ class SettingsType( override val updateMangas: Boolean, // Authentication override val authMode: AuthMode, + override val jwtAudience: String, + override val jwtTokenExpiry: Duration, + override val jwtRefreshExpiry: Duration, override val authUsername: String, override val authPassword: String, @GraphQLDeprecated("Removed - prefer authMode") @@ -358,6 +368,9 @@ class SettingsType( config.updateMangas.value, // Authentication config.authMode.value, + config.jwtAudience.value, + config.jwtTokenExpiry.value, + config.jwtRefreshExpiry.value, config.authUsername.value, config.authPassword.value, config.basicAuthEnabled.value, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt index c2003ac1..24bd2c7f 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupExport.kt @@ -429,6 +429,9 @@ object ProtoBackupExport : ProtoBackupBase() { updateMangas = serverConfig.updateMangas.value, // Authentication authMode = serverConfig.authMode.value, + jwtAudience = serverConfig.jwtAudience.value, + jwtTokenExpiry = serverConfig.jwtTokenExpiry.value, + jwtRefreshExpiry = serverConfig.jwtRefreshExpiry.value, authUsername = serverConfig.authUsername.value, authPassword = serverConfig.authPassword.value, basicAuthEnabled = false, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt index 6e9c1510..f0f4b245 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt @@ -11,26 +11,24 @@ import suwayomi.tachidesk.graphql.types.SettingsDownloadConversion import suwayomi.tachidesk.graphql.types.WebUIChannel import suwayomi.tachidesk.graphql.types.WebUIFlavor import suwayomi.tachidesk.graphql.types.WebUIInterface +import kotlin.time.Duration @Serializable data class BackupServerSettings( @ProtoNumber(1) override var ip: String, @ProtoNumber(2) override var port: Int, - // socks @ProtoNumber(3) override var socksProxyEnabled: Boolean, @ProtoNumber(4) override var socksProxyVersion: Int, @ProtoNumber(5) override var socksProxyHost: String, @ProtoNumber(6) override var socksProxyPort: String, @ProtoNumber(7) override var socksProxyUsername: String, @ProtoNumber(8) override var socksProxyPassword: String, - // webUI @ProtoNumber(9) override var webUIFlavor: WebUIFlavor, @ProtoNumber(10) override var initialOpenInBrowserEnabled: Boolean, @ProtoNumber(11) override var webUIInterface: WebUIInterface, @ProtoNumber(12) override var electronPath: String, @ProtoNumber(13) override var webUIChannel: WebUIChannel, @ProtoNumber(14) override var webUIUpdateCheckInterval: Double, - // downloader @ProtoNumber(15) override var downloadAsCbz: Boolean, @ProtoNumber(16) override var downloadsPath: String, @ProtoNumber(17) override var autoDownloadNewChapters: Boolean, @@ -38,47 +36,33 @@ data class BackupServerSettings( @ProtoNumber(19) override var autoDownloadAheadLimit: Int, @ProtoNumber(20) override var autoDownloadNewChaptersLimit: Int, @ProtoNumber(21) override var autoDownloadIgnoreReUploads: Boolean, - @ProtoNumber(57) override val downloadConversions: List?, - // extension @ProtoNumber(22) override var extensionRepos: List, - // requests @ProtoNumber(23) override var maxSourcesInParallel: Int, - // updater @ProtoNumber(24) override var excludeUnreadChapters: Boolean, @ProtoNumber(25) override var excludeNotStarted: Boolean, @ProtoNumber(26) override var excludeCompleted: Boolean, @ProtoNumber(27) override var globalUpdateInterval: Double, @ProtoNumber(28) override var updateMangas: Boolean, - // Authentication - @ProtoNumber(56) override var authMode: AuthMode, @ProtoNumber(29) override var basicAuthEnabled: Boolean?, @ProtoNumber(30) override var authUsername: String, @ProtoNumber(31) override var authPassword: String, - // deprecated - @ProtoNumber(99991) override var basicAuthUsername: String?, - @ProtoNumber(99992) override var basicAuthPassword: String?, - // misc @ProtoNumber(32) override var debugLogsEnabled: Boolean, @ProtoNumber(33) override var gqlDebugLogsEnabled: Boolean, @ProtoNumber(34) override var systemTrayEnabled: Boolean, @ProtoNumber(35) override var maxLogFiles: Int, @ProtoNumber(36) override var maxLogFileSize: String, @ProtoNumber(37) override var maxLogFolderSize: String, - // backup @ProtoNumber(38) override var backupPath: String, @ProtoNumber(39) override var backupTime: String, @ProtoNumber(40) override var backupInterval: Int, @ProtoNumber(41) override var backupTTL: Int, - // local source @ProtoNumber(42) override var localSourcePath: String, - // cloudflare bypass @ProtoNumber(43) override var flareSolverrEnabled: Boolean, @ProtoNumber(44) override var flareSolverrUrl: String, @ProtoNumber(45) override var flareSolverrTimeout: Int, @ProtoNumber(46) override var flareSolverrSessionName: String, @ProtoNumber(47) override var flareSolverrSessionTtl: Int, @ProtoNumber(48) override var flareSolverrAsResponseFallback: Boolean, - // opds @ProtoNumber(49) override var opdsUseBinaryFileSizes: Boolean, @ProtoNumber(50) override var opdsItemsPerPage: Int, @ProtoNumber(51) override var opdsEnablePageReadProgress: Boolean, @@ -86,7 +70,9 @@ data class BackupServerSettings( @ProtoNumber(53) override var opdsShowOnlyUnreadChapters: Boolean, @ProtoNumber(54) override var opdsShowOnlyDownloadedChapters: Boolean, @ProtoNumber(55) override var opdsChapterSortOrder: SortOrder, - // koreader sync + @ProtoNumber(56) override var authMode: AuthMode, + @ProtoNumber(57) override val downloadConversions: List?, + @ProtoNumber(58) override var jwtAudience: String?, @ProtoNumber(59) override var koreaderSyncServerUrl: String, @ProtoNumber(60) override var koreaderSyncUsername: String, @ProtoNumber(61) override var koreaderSyncUserkey: String, @@ -94,6 +80,11 @@ data class BackupServerSettings( @ProtoNumber(63) override var koreaderSyncChecksumMethod: KoreaderSyncChecksumMethod, @ProtoNumber(64) override var koreaderSyncStrategy: KoreaderSyncStrategy, @ProtoNumber(65) override var koreaderSyncPercentageTolerance: Double, + @ProtoNumber(66) override var jwtTokenExpiry: Duration?, + @ProtoNumber(67) override var jwtRefreshExpiry: Duration?, + // Deprecated settings + @ProtoNumber(99991) override var basicAuthUsername: String?, + @ProtoNumber(99992) override var basicAuthPassword: String?, ) : Settings { @Serializable class BackupSettingsDownloadConversionType(