mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-06-30 17:34:39 -05:00
* [#1349] Stub basic cookie authentication * [#1349] Basic login page Also adjusts WebView header color and shadow to match WebUI. WebUI uses a background-image gradient to change the perceived color, which was not noticed originally. * [#1349] Handle login post * [#1349] Redirect to previous URL * [#1349] Return a basic 401 for api endpoints Instead of redirecting to a visual login page, API should just indicate the bad state * Use more appropriate 303 redirect * Update server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt Co-authored-by: Mitchell Syer <Syer10@users.noreply.github.com> * Update server/src/main/kotlin/suwayomi/tachidesk/server/JavalinSetup.kt Co-authored-by: Mitchell Syer <Syer10@users.noreply.github.com> * Lint * Transition to AuthMode enum with migration path * Make basicAuthEnabled auto property, Lint * ConfigManager: Make sure to re-parse the config after migration * basicAuth{Username,Password} -> auth{Username,Password} * Lint * Update server settings backup model * Update comment * Minor cleanup * Improve backup legacy settings fix * Lint * Simplify config value migration --------- Co-authored-by: Mitchell Syer <Syer10@users.noreply.github.com>
This commit is contained in:
@@ -138,7 +138,7 @@ open class ConfigManager {
|
|||||||
* - adds missing settings
|
* - adds missing settings
|
||||||
* - removes outdated settings
|
* - removes outdated settings
|
||||||
*/
|
*/
|
||||||
fun updateUserConfig() {
|
fun updateUserConfig(migrate: ConfigDocument.(Config) -> ConfigDocument) {
|
||||||
val serverConfig = ConfigFactory.parseResources("server-reference.conf")
|
val serverConfig = ConfigFactory.parseResources("server-reference.conf")
|
||||||
val userConfig = getUserConfig()
|
val userConfig = getUserConfig()
|
||||||
|
|
||||||
@@ -162,7 +162,11 @@ open class ConfigManager {
|
|||||||
)
|
)
|
||||||
}.forEach { newUserConfigDoc = newUserConfigDoc.withValue(it.key, it.value) }
|
}.forEach { newUserConfigDoc = newUserConfigDoc.withValue(it.key, it.value) }
|
||||||
|
|
||||||
|
newUserConfigDoc =
|
||||||
|
migrate(newUserConfigDoc, internalConfig)
|
||||||
|
|
||||||
userConfigFile.writeText(newUserConfigDoc.render())
|
userConfigFile.writeText(newUserConfigDoc.render())
|
||||||
|
getUserConfig().entrySet().forEach { internalConfig = internalConfig.withValue(it.key, it.value) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ object WebView : Websocket<String>() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable public sealed class TypeObject
|
@Serializable
|
||||||
|
sealed class TypeObject
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("loadUrl")
|
@SerialName("loadUrl")
|
||||||
@@ -62,7 +63,7 @@ object WebView : Websocket<String>() {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("event")
|
@SerialName("event")
|
||||||
public data class JsEventMessage(
|
data class JsEventMessage(
|
||||||
val eventType: String,
|
val eventType: String,
|
||||||
val clickX: Float,
|
val clickX: Float,
|
||||||
val clickY: Float,
|
val clickY: Float,
|
||||||
@@ -94,7 +95,6 @@ object WebView : Websocket<String>() {
|
|||||||
logger.info { "Resize browser" }
|
logger.info { "Resize browser" }
|
||||||
}
|
}
|
||||||
is JsEventMessage -> {
|
is JsEventMessage -> {
|
||||||
val type = event.eventType
|
|
||||||
dr.event(event)
|
dr.event(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,9 @@ class SettingsMutation {
|
|||||||
updateSetting(settings.updateMangas, serverConfig.updateMangas)
|
updateSetting(settings.updateMangas, serverConfig.updateMangas)
|
||||||
|
|
||||||
// Authentication
|
// Authentication
|
||||||
|
updateSetting(settings.authMode, serverConfig.authMode)
|
||||||
|
updateSetting(settings.authUsername, serverConfig.authUsername)
|
||||||
|
updateSetting(settings.authPassword, serverConfig.authPassword)
|
||||||
updateSetting(settings.basicAuthEnabled, serverConfig.basicAuthEnabled)
|
updateSetting(settings.basicAuthEnabled, serverConfig.basicAuthEnabled)
|
||||||
updateSetting(settings.basicAuthUsername, serverConfig.basicAuthUsername)
|
updateSetting(settings.basicAuthUsername, serverConfig.basicAuthUsername)
|
||||||
updateSetting(settings.basicAuthPassword, serverConfig.basicAuthPassword)
|
updateSetting(settings.basicAuthPassword, serverConfig.basicAuthPassword)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package suwayomi.tachidesk.graphql.types
|
||||||
|
|
||||||
|
enum class AuthMode {
|
||||||
|
NONE,
|
||||||
|
BASIC_AUTH,
|
||||||
|
SIMPLE_LOGIN,
|
||||||
|
// TODO: ACCOUNT for #623
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(channel: String): AuthMode = entries.find { it.name.lowercase() == channel.lowercase() } ?: NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,8 +63,17 @@ interface Settings : Node {
|
|||||||
val updateMangas: Boolean?
|
val updateMangas: Boolean?
|
||||||
|
|
||||||
// Authentication
|
// Authentication
|
||||||
|
val authMode: AuthMode?
|
||||||
|
val authUsername: String?
|
||||||
|
val authPassword: String?
|
||||||
|
|
||||||
|
@GraphQLDeprecated("Removed - prefer authMode")
|
||||||
val basicAuthEnabled: Boolean?
|
val basicAuthEnabled: Boolean?
|
||||||
|
|
||||||
|
@GraphQLDeprecated("Removed - prefer authUsername")
|
||||||
val basicAuthUsername: String?
|
val basicAuthUsername: String?
|
||||||
|
|
||||||
|
@GraphQLDeprecated("Removed - prefer authPassword")
|
||||||
val basicAuthPassword: String?
|
val basicAuthPassword: String?
|
||||||
|
|
||||||
// misc
|
// misc
|
||||||
@@ -144,8 +153,14 @@ data class PartialSettingsType(
|
|||||||
override val globalUpdateInterval: Double?,
|
override val globalUpdateInterval: Double?,
|
||||||
override val updateMangas: Boolean?,
|
override val updateMangas: Boolean?,
|
||||||
// Authentication
|
// Authentication
|
||||||
|
override val authMode: AuthMode?,
|
||||||
|
override val authUsername: String?,
|
||||||
|
override val authPassword: String?,
|
||||||
|
@GraphQLDeprecated("Removed - prefer authMode")
|
||||||
override val basicAuthEnabled: Boolean?,
|
override val basicAuthEnabled: Boolean?,
|
||||||
|
@GraphQLDeprecated("Removed - prefer authUsername")
|
||||||
override val basicAuthUsername: String?,
|
override val basicAuthUsername: String?,
|
||||||
|
@GraphQLDeprecated("Removed - prefer authPassword")
|
||||||
override val basicAuthPassword: String?,
|
override val basicAuthPassword: String?,
|
||||||
// misc
|
// misc
|
||||||
override val debugLogsEnabled: Boolean?,
|
override val debugLogsEnabled: Boolean?,
|
||||||
@@ -219,8 +234,14 @@ class SettingsType(
|
|||||||
override val globalUpdateInterval: Double,
|
override val globalUpdateInterval: Double,
|
||||||
override val updateMangas: Boolean,
|
override val updateMangas: Boolean,
|
||||||
// Authentication
|
// Authentication
|
||||||
|
override val authMode: AuthMode,
|
||||||
|
override val authUsername: String,
|
||||||
|
override val authPassword: String,
|
||||||
|
@GraphQLDeprecated("Removed - prefer authMode")
|
||||||
override val basicAuthEnabled: Boolean,
|
override val basicAuthEnabled: Boolean,
|
||||||
|
@GraphQLDeprecated("Removed - prefer authUsername")
|
||||||
override val basicAuthUsername: String,
|
override val basicAuthUsername: String,
|
||||||
|
@GraphQLDeprecated("Removed - prefer authPassword")
|
||||||
override val basicAuthPassword: String,
|
override val basicAuthPassword: String,
|
||||||
// misc
|
// misc
|
||||||
override val debugLogsEnabled: Boolean,
|
override val debugLogsEnabled: Boolean,
|
||||||
@@ -289,6 +310,9 @@ class SettingsType(
|
|||||||
config.globalUpdateInterval.value,
|
config.globalUpdateInterval.value,
|
||||||
config.updateMangas.value,
|
config.updateMangas.value,
|
||||||
// Authentication
|
// Authentication
|
||||||
|
config.authMode.value,
|
||||||
|
config.authUsername.value,
|
||||||
|
config.authPassword.value,
|
||||||
config.basicAuthEnabled.value,
|
config.basicAuthEnabled.value,
|
||||||
config.basicAuthUsername.value,
|
config.basicAuthUsername.value,
|
||||||
config.basicAuthPassword.value,
|
config.basicAuthPassword.value,
|
||||||
|
|||||||
@@ -419,9 +419,12 @@ object ProtoBackupExport : ProtoBackupBase() {
|
|||||||
globalUpdateInterval = serverConfig.globalUpdateInterval.value,
|
globalUpdateInterval = serverConfig.globalUpdateInterval.value,
|
||||||
updateMangas = serverConfig.updateMangas.value,
|
updateMangas = serverConfig.updateMangas.value,
|
||||||
// Authentication
|
// Authentication
|
||||||
basicAuthEnabled = serverConfig.basicAuthEnabled.value,
|
authMode = serverConfig.authMode.value,
|
||||||
basicAuthUsername = serverConfig.basicAuthUsername.value,
|
authUsername = serverConfig.authUsername.value,
|
||||||
basicAuthPassword = serverConfig.basicAuthPassword.value,
|
authPassword = serverConfig.authPassword.value,
|
||||||
|
basicAuthEnabled = false,
|
||||||
|
basicAuthUsername = null,
|
||||||
|
basicAuthPassword = null,
|
||||||
// misc
|
// misc
|
||||||
debugLogsEnabled = serverConfig.debugLogsEnabled.value,
|
debugLogsEnabled = serverConfig.debugLogsEnabled.value,
|
||||||
gqlDebugLogsEnabled = false, // deprecated
|
gqlDebugLogsEnabled = false, // deprecated
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import org.jetbrains.exposed.sql.transactions.transaction
|
|||||||
import org.jetbrains.exposed.sql.update
|
import org.jetbrains.exposed.sql.update
|
||||||
import suwayomi.tachidesk.global.impl.GlobalMeta
|
import suwayomi.tachidesk.global.impl.GlobalMeta
|
||||||
import suwayomi.tachidesk.graphql.mutations.SettingsMutation
|
import suwayomi.tachidesk.graphql.mutations.SettingsMutation
|
||||||
|
import suwayomi.tachidesk.graphql.types.AuthMode
|
||||||
import suwayomi.tachidesk.graphql.types.toStatus
|
import suwayomi.tachidesk.graphql.types.toStatus
|
||||||
import suwayomi.tachidesk.manga.impl.Category
|
import suwayomi.tachidesk.manga.impl.Category
|
||||||
import suwayomi.tachidesk.manga.impl.Category.modifyCategoriesMetas
|
import suwayomi.tachidesk.manga.impl.Category.modifyCategoriesMetas
|
||||||
@@ -57,6 +58,7 @@ import suwayomi.tachidesk.manga.model.dataclass.TrackRecordDataClass
|
|||||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||||
import suwayomi.tachidesk.server.database.dbTransaction
|
import suwayomi.tachidesk.server.database.dbTransaction
|
||||||
|
import suwayomi.tachidesk.server.serverConfig
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Timer
|
import java.util.Timer
|
||||||
@@ -525,7 +527,15 @@ object ProtoBackupImport : ProtoBackupBase() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsMutation().updateSettings(backupServerSettings)
|
SettingsMutation().updateSettings(
|
||||||
|
backupServerSettings.copy(
|
||||||
|
// legacy settings cannot overwrite new settings
|
||||||
|
basicAuthEnabled =
|
||||||
|
backupServerSettings.basicAuthEnabled.takeIf {
|
||||||
|
serverConfig.authMode.value == AuthMode.NONE
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun TrackRecordDataClass.forComparison() = this.copy(id = 0, mangaId = 0)
|
private fun TrackRecordDataClass.forComparison() = this.copy(id = 0, mangaId = 0)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package suwayomi.tachidesk.manga.impl.backup.proto.models
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.protobuf.ProtoNumber
|
import kotlinx.serialization.protobuf.ProtoNumber
|
||||||
import org.jetbrains.exposed.sql.SortOrder
|
import org.jetbrains.exposed.sql.SortOrder
|
||||||
|
import suwayomi.tachidesk.graphql.types.AuthMode
|
||||||
import suwayomi.tachidesk.graphql.types.Settings
|
import suwayomi.tachidesk.graphql.types.Settings
|
||||||
import suwayomi.tachidesk.graphql.types.WebUIChannel
|
import suwayomi.tachidesk.graphql.types.WebUIChannel
|
||||||
import suwayomi.tachidesk.graphql.types.WebUIFlavor
|
import suwayomi.tachidesk.graphql.types.WebUIFlavor
|
||||||
@@ -45,9 +46,13 @@ data class BackupServerSettings(
|
|||||||
@ProtoNumber(27) override var globalUpdateInterval: Double,
|
@ProtoNumber(27) override var globalUpdateInterval: Double,
|
||||||
@ProtoNumber(28) override var updateMangas: Boolean,
|
@ProtoNumber(28) override var updateMangas: Boolean,
|
||||||
// Authentication
|
// Authentication
|
||||||
@ProtoNumber(29) override var basicAuthEnabled: Boolean,
|
@ProtoNumber(56) override var authMode: AuthMode,
|
||||||
@ProtoNumber(30) override var basicAuthUsername: String,
|
@ProtoNumber(29) override var basicAuthEnabled: Boolean?,
|
||||||
@ProtoNumber(31) override var basicAuthPassword: String,
|
@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
|
// misc
|
||||||
@ProtoNumber(32) override var debugLogsEnabled: Boolean,
|
@ProtoNumber(32) override var debugLogsEnabled: Boolean,
|
||||||
@ProtoNumber(33) override var gqlDebugLogsEnabled: Boolean,
|
@ProtoNumber(33) override var gqlDebugLogsEnabled: Boolean,
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import io.github.oshai.kotlinlogging.KotlinLogging
|
|||||||
import io.javalin.Javalin
|
import io.javalin.Javalin
|
||||||
import io.javalin.apibuilder.ApiBuilder.path
|
import io.javalin.apibuilder.ApiBuilder.path
|
||||||
import io.javalin.http.HandlerType
|
import io.javalin.http.HandlerType
|
||||||
|
import io.javalin.http.HttpStatus
|
||||||
|
import io.javalin.http.RedirectResponse
|
||||||
import io.javalin.http.UnauthorizedResponse
|
import io.javalin.http.UnauthorizedResponse
|
||||||
import io.javalin.http.staticfiles.Location
|
import io.javalin.http.staticfiles.Location
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -22,14 +24,19 @@ import kotlinx.coroutines.runBlocking
|
|||||||
import org.eclipse.jetty.server.ServerConnector
|
import org.eclipse.jetty.server.ServerConnector
|
||||||
import suwayomi.tachidesk.global.GlobalAPI
|
import suwayomi.tachidesk.global.GlobalAPI
|
||||||
import suwayomi.tachidesk.graphql.GraphQL
|
import suwayomi.tachidesk.graphql.GraphQL
|
||||||
|
import suwayomi.tachidesk.graphql.types.AuthMode
|
||||||
import suwayomi.tachidesk.manga.MangaAPI
|
import suwayomi.tachidesk.manga.MangaAPI
|
||||||
import suwayomi.tachidesk.opds.OpdsAPI
|
import suwayomi.tachidesk.opds.OpdsAPI
|
||||||
|
import suwayomi.tachidesk.server.generated.BuildConfig
|
||||||
import suwayomi.tachidesk.server.util.Browser
|
import suwayomi.tachidesk.server.util.Browser
|
||||||
import suwayomi.tachidesk.server.util.WebInterfaceManager
|
import suwayomi.tachidesk.server.util.WebInterfaceManager
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
import java.util.concurrent.CompletableFuture
|
import java.util.concurrent.CompletableFuture
|
||||||
import kotlin.concurrent.thread
|
import kotlin.concurrent.thread
|
||||||
|
import kotlin.time.Duration.Companion.days
|
||||||
|
|
||||||
object JavalinSetup {
|
object JavalinSetup {
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
@@ -111,8 +118,49 @@ object JavalinSetup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.get("/login.html") { ctx ->
|
||||||
|
var page =
|
||||||
|
this::class.java
|
||||||
|
.getResourceAsStream("/static/login.html")!!
|
||||||
|
.use { it.readAllBytes() }
|
||||||
|
.toString(Charsets.UTF_8)
|
||||||
|
page = page.replace("[VERSION]", BuildConfig.VERSION).replace("[ERROR]", "")
|
||||||
|
ctx.header("content-type", "text/html")
|
||||||
|
val httpCacheSeconds = 1.days.inWholeSeconds
|
||||||
|
ctx.header("cache-control", "max-age=$httpCacheSeconds")
|
||||||
|
ctx.result(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post("/login.html") { ctx ->
|
||||||
|
val username = ctx.formParam("user")
|
||||||
|
val password = ctx.formParam("pass")
|
||||||
|
val isValid =
|
||||||
|
username == serverConfig.authUsername.value &&
|
||||||
|
password == serverConfig.authPassword.value
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
val redirect = ctx.queryParam("redirect") ?: "/"
|
||||||
|
// NOTE: We currently have no session handler attached.
|
||||||
|
// Thus, all sessions are stored in memory and not persisted.
|
||||||
|
// Furthermore, default session timeout appears to be 30m
|
||||||
|
ctx.header("Location", redirect)
|
||||||
|
ctx.sessionAttribute("logged-in", username)
|
||||||
|
throw RedirectResponse(HttpStatus.SEE_OTHER)
|
||||||
|
}
|
||||||
|
|
||||||
|
var page =
|
||||||
|
this::class.java
|
||||||
|
.getResourceAsStream("/static/login.html")!!
|
||||||
|
.use { it.readAllBytes() }
|
||||||
|
.toString(Charsets.UTF_8)
|
||||||
|
page = page.replace("[VERSION]", BuildConfig.VERSION).replace("[ERROR]", "Invalid username or password")
|
||||||
|
ctx.header("content-type", "text/html")
|
||||||
|
ctx.req().session.invalidate()
|
||||||
|
ctx.result(page)
|
||||||
|
}
|
||||||
|
|
||||||
app.beforeMatched { ctx ->
|
app.beforeMatched { ctx ->
|
||||||
val isWebManifest = listOf("site.webmanifest", "manifest.json").any { ctx.path().endsWith(it) }
|
val isWebManifest = listOf("site.webmanifest", "manifest.json", "login.html").any { ctx.path().endsWith(it) }
|
||||||
val isPreFlight = ctx.method() == HandlerType.OPTIONS
|
val isPreFlight = ctx.method() == HandlerType.OPTIONS
|
||||||
|
|
||||||
val requiresAuthentication = !isPreFlight && !isWebManifest
|
val requiresAuthentication = !isPreFlight && !isWebManifest
|
||||||
@@ -120,14 +168,31 @@ object JavalinSetup {
|
|||||||
return@beforeMatched
|
return@beforeMatched
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val authMode = serverConfig.authMode.value ?: AuthMode.NONE
|
||||||
|
|
||||||
fun credentialsValid(): Boolean {
|
fun credentialsValid(): Boolean {
|
||||||
val basicAuthCredentials = ctx.basicAuthCredentials() ?: return false
|
val basicAuthCredentials = ctx.basicAuthCredentials() ?: return false
|
||||||
val (username, password) = basicAuthCredentials
|
val (username, password) = basicAuthCredentials
|
||||||
return username == serverConfig.basicAuthUsername.value &&
|
return username == serverConfig.authUsername.value &&
|
||||||
password == serverConfig.basicAuthPassword.value
|
password == serverConfig.authPassword.value
|
||||||
}
|
}
|
||||||
|
|
||||||
if (serverConfig.basicAuthEnabled.value && !credentialsValid()) {
|
fun cookieValid(): Boolean {
|
||||||
|
val username = ctx.sessionAttribute<String>("logged-in") ?: return false
|
||||||
|
return username == serverConfig.authUsername.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authMode == AuthMode.SIMPLE_LOGIN && !cookieValid() && ctx.path().startsWith("/api")) {
|
||||||
|
throw UnauthorizedResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authMode == AuthMode.SIMPLE_LOGIN && !cookieValid()) {
|
||||||
|
val url = "/login.html?redirect=" + URLEncoder.encode(ctx.fullUrl(), StandardCharsets.UTF_8)
|
||||||
|
ctx.header("Location", url)
|
||||||
|
throw RedirectResponse(HttpStatus.SEE_OTHER)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authMode == AuthMode.BASIC_AUTH && !credentialsValid()) {
|
||||||
ctx.header("WWW-Authenticate", "Basic")
|
ctx.header("WWW-Authenticate", "Basic")
|
||||||
throw UnauthorizedResponse()
|
throw UnauthorizedResponse()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.launchIn
|
|||||||
import kotlinx.coroutines.flow.mapLatest
|
import kotlinx.coroutines.flow.mapLatest
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import org.jetbrains.exposed.sql.SortOrder
|
import org.jetbrains.exposed.sql.SortOrder
|
||||||
|
import suwayomi.tachidesk.graphql.types.AuthMode
|
||||||
import suwayomi.tachidesk.graphql.types.WebUIChannel
|
import suwayomi.tachidesk.graphql.types.WebUIChannel
|
||||||
import suwayomi.tachidesk.graphql.types.WebUIFlavor
|
import suwayomi.tachidesk.graphql.types.WebUIFlavor
|
||||||
import suwayomi.tachidesk.graphql.types.WebUIInterface
|
import suwayomi.tachidesk.graphql.types.WebUIInterface
|
||||||
@@ -89,6 +90,42 @@ class ServerConfig(
|
|||||||
.map { configAdapter.toType(it) }
|
.map { configAdapter.toType(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
open inner class MigratedConfigValue<T>(
|
||||||
|
private val readMigrated: () -> Any,
|
||||||
|
private val setMigrated: (T) -> Unit,
|
||||||
|
) {
|
||||||
|
private var flow: MutableStateFlow<T>? = null
|
||||||
|
|
||||||
|
open fun getValueFromConfig(
|
||||||
|
thisRef: ServerConfig,
|
||||||
|
property: KProperty<*>,
|
||||||
|
): Any = readMigrated()
|
||||||
|
|
||||||
|
operator fun getValue(
|
||||||
|
thisRef: ServerConfig,
|
||||||
|
property: KProperty<*>,
|
||||||
|
): MutableStateFlow<T> {
|
||||||
|
if (flow != null) {
|
||||||
|
return flow!!
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val value = getValueFromConfig(thisRef, property) as T
|
||||||
|
|
||||||
|
val stateFlow = MutableStateFlow(value)
|
||||||
|
flow = stateFlow
|
||||||
|
|
||||||
|
stateFlow
|
||||||
|
.drop(1)
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.filter { it != getValueFromConfig(thisRef, property) }
|
||||||
|
.onEach(setMigrated)
|
||||||
|
.launchIn(mutableConfigValueScope)
|
||||||
|
|
||||||
|
return stateFlow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val ip: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
|
val ip: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
|
||||||
val port: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
|
val port: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
|
||||||
|
|
||||||
@@ -120,14 +157,6 @@ class ServerConfig(
|
|||||||
// extensions
|
// extensions
|
||||||
val extensionRepos: MutableStateFlow<List<String>> by OverrideConfigValues(StringConfigAdapter)
|
val extensionRepos: MutableStateFlow<List<String>> by OverrideConfigValues(StringConfigAdapter)
|
||||||
|
|
||||||
// playwright webview
|
|
||||||
val playwrightBrowser: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
|
|
||||||
val playwrightWsEndpoint: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
|
|
||||||
val playwrightSandbox: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
|
|
||||||
|
|
||||||
// webview
|
|
||||||
val webviewImpl: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
|
|
||||||
|
|
||||||
// requests
|
// requests
|
||||||
val maxSourcesInParallel: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
|
val maxSourcesInParallel: MutableStateFlow<Int> by OverrideConfigValue(IntConfigAdapter)
|
||||||
|
|
||||||
@@ -139,9 +168,20 @@ class ServerConfig(
|
|||||||
val updateMangas: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
|
val updateMangas: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
|
||||||
|
|
||||||
// Authentication
|
// Authentication
|
||||||
val basicAuthEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
|
val authMode: MutableStateFlow<AuthMode> by OverrideConfigValue(EnumConfigAdapter(AuthMode::class.java))
|
||||||
val basicAuthUsername: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
|
val authUsername: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
|
||||||
val basicAuthPassword: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
|
val authPassword: MutableStateFlow<String> by OverrideConfigValue(StringConfigAdapter)
|
||||||
|
val basicAuthEnabled: MutableStateFlow<Boolean> by MigratedConfigValue({
|
||||||
|
authMode.value == AuthMode.BASIC_AUTH
|
||||||
|
}) {
|
||||||
|
authMode.value = if (it) AuthMode.BASIC_AUTH else AuthMode.NONE
|
||||||
|
}
|
||||||
|
val basicAuthUsername: MutableStateFlow<String> by MigratedConfigValue({ authUsername.value }) {
|
||||||
|
authUsername.value = it
|
||||||
|
}
|
||||||
|
val basicAuthPassword: MutableStateFlow<String> by MigratedConfigValue({ authPassword.value }) {
|
||||||
|
authPassword.value = it
|
||||||
|
}
|
||||||
|
|
||||||
// misc
|
// misc
|
||||||
val debugLogsEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
|
val debugLogsEnabled: MutableStateFlow<Boolean> by OverrideConfigValue(BooleanConfigAdapter)
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ package suwayomi.tachidesk.server
|
|||||||
|
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import ch.qos.logback.classic.Level
|
import ch.qos.logback.classic.Level
|
||||||
|
import com.typesafe.config.Config
|
||||||
|
import com.typesafe.config.ConfigException
|
||||||
import com.typesafe.config.ConfigRenderOptions
|
import com.typesafe.config.ConfigRenderOptions
|
||||||
|
import com.typesafe.config.ConfigValue
|
||||||
|
import com.typesafe.config.ConfigValueFactory
|
||||||
|
import com.typesafe.config.parser.ConfigDocument
|
||||||
import dev.datlag.kcef.KCEF
|
import dev.datlag.kcef.KCEF
|
||||||
import eu.kanade.tachiyomi.App
|
import eu.kanade.tachiyomi.App
|
||||||
import eu.kanade.tachiyomi.createAppModule
|
import eu.kanade.tachiyomi.createAppModule
|
||||||
@@ -32,12 +37,14 @@ import org.koin.core.context.startKoin
|
|||||||
import org.koin.core.module.Module
|
import org.koin.core.module.Module
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import suwayomi.tachidesk.global.impl.KcefWebView.Companion.toCefCookie
|
import suwayomi.tachidesk.global.impl.KcefWebView.Companion.toCefCookie
|
||||||
|
import suwayomi.tachidesk.graphql.types.AuthMode
|
||||||
import suwayomi.tachidesk.i18n.LocalizationHelper
|
import suwayomi.tachidesk.i18n.LocalizationHelper
|
||||||
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupExport
|
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupExport
|
||||||
import suwayomi.tachidesk.manga.impl.download.DownloadManager
|
import suwayomi.tachidesk.manga.impl.download.DownloadManager
|
||||||
import suwayomi.tachidesk.manga.impl.update.IUpdater
|
import suwayomi.tachidesk.manga.impl.update.IUpdater
|
||||||
import suwayomi.tachidesk.manga.impl.update.Updater
|
import suwayomi.tachidesk.manga.impl.update.Updater
|
||||||
import suwayomi.tachidesk.manga.impl.util.lang.renameTo
|
import suwayomi.tachidesk.manga.impl.util.lang.renameTo
|
||||||
|
import suwayomi.tachidesk.server.BooleanConfigAdapter
|
||||||
import suwayomi.tachidesk.server.database.databaseUp
|
import suwayomi.tachidesk.server.database.databaseUp
|
||||||
import suwayomi.tachidesk.server.generated.BuildConfig
|
import suwayomi.tachidesk.server.generated.BuildConfig
|
||||||
import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex
|
import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex
|
||||||
@@ -127,6 +134,29 @@ fun setupLogLevelUpdating(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun <T : Any> migrateConfig(
|
||||||
|
configDocument: ConfigDocument,
|
||||||
|
config: Config,
|
||||||
|
configKey: String,
|
||||||
|
toConfigKey: String,
|
||||||
|
toType: (ConfigValue) -> T?,
|
||||||
|
): ConfigDocument {
|
||||||
|
try {
|
||||||
|
val configValue = config.getValue(configKey)
|
||||||
|
val typedValue = toType(configValue)
|
||||||
|
if (typedValue != null) {
|
||||||
|
return configDocument.withValue(
|
||||||
|
toConfigKey,
|
||||||
|
ConfigValueFactory.fromAnyRef(typedValue),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: ConfigException) {
|
||||||
|
// ignore, likely already migrated
|
||||||
|
}
|
||||||
|
|
||||||
|
return configDocument
|
||||||
|
}
|
||||||
|
|
||||||
fun serverModule(applicationDirs: ApplicationDirs): Module =
|
fun serverModule(applicationDirs: ApplicationDirs): Module =
|
||||||
module {
|
module {
|
||||||
single { applicationDirs }
|
single { applicationDirs }
|
||||||
@@ -268,7 +298,40 @@ fun applicationSetup() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// make sure the user config file is up-to-date
|
// make sure the user config file is up-to-date
|
||||||
GlobalConfigManager.updateUserConfig()
|
GlobalConfigManager.updateUserConfig { config ->
|
||||||
|
var updatedConfig = this
|
||||||
|
updatedConfig =
|
||||||
|
migrateConfig(
|
||||||
|
updatedConfig,
|
||||||
|
config,
|
||||||
|
"server.basicAuthEnabled",
|
||||||
|
"server.authMode",
|
||||||
|
toType = {
|
||||||
|
if (it.unwrapped() as? Boolean == true) {
|
||||||
|
AuthMode.BASIC_AUTH.name
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
updatedConfig =
|
||||||
|
migrateConfig(
|
||||||
|
updatedConfig,
|
||||||
|
config,
|
||||||
|
"server.basicAuthUsername",
|
||||||
|
"server.authUsername",
|
||||||
|
toType = { it.unwrapped() as? String },
|
||||||
|
)
|
||||||
|
updatedConfig =
|
||||||
|
migrateConfig(
|
||||||
|
updatedConfig,
|
||||||
|
config,
|
||||||
|
"server.basicAuthPassword",
|
||||||
|
"server.authPassword",
|
||||||
|
toType = { it.unwrapped() as? String },
|
||||||
|
)
|
||||||
|
updatedConfig
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error(e) { "Exception while creating initial server.conf" }
|
logger.error(e) { "Exception while creating initial server.conf" }
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ server.globalUpdateInterval = 12 # time in hours - 0 to disable it - (doesn't ha
|
|||||||
server.updateMangas = false # if the mangas should be updated along with the chapter list during a library/category update
|
server.updateMangas = false # if the mangas should be updated along with the chapter list during a library/category update
|
||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
server.basicAuthEnabled = false
|
server.authMode = "none" # none, basic_auth or simple_login
|
||||||
server.basicAuthUsername = ""
|
server.authUsername = ""
|
||||||
server.basicAuthPassword = ""
|
server.authPassword = ""
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
server.debugLogsEnabled = false
|
server.debugLogsEnabled = false
|
||||||
|
|||||||
164
server/src/main/resources/static/login.html
Normal file
164
server/src/main/resources/static/login.html
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Suwayomi Login</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: rgb(12, 16, 33);
|
||||||
|
font-family: "Roboto","Helvetica","Arial",sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0em;
|
||||||
|
}
|
||||||
|
button[disabled], input[disabled] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
background-color: rgb(34, 38, 53);
|
||||||
|
box-shadow: rgba(0, 0, 0, 0.2) 0px 2px 4px -1px, rgba(0, 0, 0, 0.14) 0px 4px 5px 0px, rgba(0, 0, 0, 0.12) 0px 1px 10px 0px;
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 32px;
|
||||||
|
}
|
||||||
|
header h1, header p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
footer p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
position: relative;
|
||||||
|
padding-top: 24px;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
margin: 8px;
|
||||||
|
padding: 8px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgb(12, 16, 33);
|
||||||
|
background-color: rgb(6, 8, 16);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
margin: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #b71c1c;
|
||||||
|
background-color: #c62828;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.error:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
form label {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
form button {
|
||||||
|
all: unset;
|
||||||
|
padding: 8px;
|
||||||
|
line-height: 1.75;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 64px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
color: rgb(91, 116, 239);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02857em;
|
||||||
|
}
|
||||||
|
form button:not([disabled]) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
form button:not([disabled]):hover {
|
||||||
|
background-color: rgba(91, 116, 239, 0.08);
|
||||||
|
}
|
||||||
|
form input {
|
||||||
|
all: unset;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.23);
|
||||||
|
padding: 6px 12px;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
form input:hover {
|
||||||
|
border-color: white;
|
||||||
|
}
|
||||||
|
form input:focus {
|
||||||
|
border-color: rgb(91, 116, 239);
|
||||||
|
}
|
||||||
|
form .controls {
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
form .controls > :nth-child(even):not(:last-child) {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
form .submit {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 500px) {
|
||||||
|
form {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
margin: 8px auto;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
margin: 8px auto;
|
||||||
|
}
|
||||||
|
form .controls {
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
column-gap: 16px;
|
||||||
|
row-gap: 6px;
|
||||||
|
}
|
||||||
|
form .controls > :nth-child(even):not(:last-child) {
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Suwayomi</h1>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div class="error">[ERROR]</div>
|
||||||
|
<form method="POST">
|
||||||
|
<h2>Login</h2>
|
||||||
|
<div class="controls">
|
||||||
|
<label for="user">Username:</label>
|
||||||
|
<input type="text" name="user" id="user" required placeholder="Type username..."/>
|
||||||
|
<label for="pass">Password:</label>
|
||||||
|
<input type="password" name="pass" id="pass" required placeholder="Secret..."/>
|
||||||
|
</div>
|
||||||
|
<div class="submit">
|
||||||
|
<button type="submit">Log In</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<p>Suwayomi: Version [VERSION]</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -15,6 +15,9 @@
|
|||||||
body {
|
body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
font-family: "Roboto","Helvetica","Arial",sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0em;
|
||||||
}
|
}
|
||||||
body.disconnected::after {
|
body.disconnected::after {
|
||||||
content: 'Disconnected, please refresh';
|
content: 'Disconnected, please refresh';
|
||||||
@@ -30,7 +33,8 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
header {
|
header {
|
||||||
background-color: rgb(12, 16, 33);
|
background-color: rgb(34, 38, 53);
|
||||||
|
box-shadow: rgba(0, 0, 0, 0.2) 0px 2px 4px -1px, rgba(0, 0, 0, 0.14) 0px 4px 5px 0px, rgba(0, 0, 0, 0.12) 0px 1px 10px 0px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 8px 32px;
|
padding: 8px 32px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ server.globalUpdateInterval = 12 # time in hours - 0 to disable it - (doesn't ha
|
|||||||
server.updateMangas = false # if the mangas should be updated along with the chapter list during a library/category update
|
server.updateMangas = false # if the mangas should be updated along with the chapter list during a library/category update
|
||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
server.basicAuthEnabled = false
|
server.authMode = "none" # none, basic_auth or simple_login
|
||||||
server.basicAuthUsername = ""
|
server.authUsername = ""
|
||||||
server.basicAuthPassword = ""
|
server.authPassword = ""
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
server.debugLogsEnabled = false
|
server.debugLogsEnabled = false
|
||||||
|
|||||||
Reference in New Issue
Block a user