mirror of
https://github.com/Darkrock-Studios/hammer-editor.git
synced 2026-10-19 09:34:23 +00:00
Extract a generic interface for user gating
Simplify allowed user management.
This commit is contained in:
parent
d424a346ed
commit
9ead030ab9
62 changed files with 468 additions and 2205 deletions
|
|
@ -13,7 +13,9 @@ import com.darkrockstudios.apps.hammer.frontend.configureFrontEnd
|
|||
import com.darkrockstudios.apps.hammer.monitoring.configureApiMetrics
|
||||
import com.darkrockstudios.apps.hammer.monitoring.configureMonitoringJob
|
||||
import com.darkrockstudios.apps.hammer.monitoring.configureRouteTemplateCapture
|
||||
import com.darkrockstudios.apps.hammer.patreon.configurePatreonPolling
|
||||
import com.darkrockstudios.apps.hammer.plugin.ServerPlugin
|
||||
import com.darkrockstudios.apps.hammer.plugin.installedPlugins
|
||||
import com.darkrockstudios.apps.hammer.plugin.launchPluginTasks
|
||||
import com.darkrockstudios.apps.hammer.plugins.SetupModePlugin
|
||||
import com.darkrockstudios.apps.hammer.plugins.configureDependencyInjection
|
||||
import com.darkrockstudios.apps.hammer.plugins.configureHTTP
|
||||
|
|
@ -414,8 +416,9 @@ fun Application.appMain(
|
|||
config: ServerConfig,
|
||||
addInModule: Module? = null,
|
||||
logLevel: Level? = null,
|
||||
plugins: List<ServerPlugin> = installedPlugins(),
|
||||
) {
|
||||
configureDependencyInjection(config, addInModule)
|
||||
configureDependencyInjection(config, addInModule, plugins)
|
||||
val encryptionBootstrap: EncryptionBootstrap by inject()
|
||||
runBlocking { encryptionBootstrap.run() }
|
||||
// Must complete before any route is installed: routes enforce the allowed
|
||||
|
|
@ -432,7 +435,7 @@ fun Application.appMain(
|
|||
install(SetupModePlugin)
|
||||
configureRouting(config)
|
||||
configureFrontEnd()
|
||||
configurePatreonPolling(config)
|
||||
launchPluginTasks()
|
||||
configureMonitoringJob()
|
||||
configureTokenMaintenanceJob()
|
||||
configureWhitelistExpiryJob()
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ data class ServerConfig(
|
|||
*/
|
||||
val richLinkPreviews: Boolean = false,
|
||||
val sslCert: SslCertConfig? = null,
|
||||
val patreonEnabled: Boolean? = null,
|
||||
/**
|
||||
* Path to a plaintext file whose contents are presented as a Terms of Service that
|
||||
* users must accept before an account is created. Null/absent disables the requirement.
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
package com.darkrockstudios.apps.hammer.account
|
||||
|
||||
import com.darkrockstudios.apps.hammer.Account
|
||||
import com.darkrockstudios.apps.hammer.ServerConfig
|
||||
import com.darkrockstudios.apps.hammer.admin.AdminServerConfig
|
||||
import com.darkrockstudios.apps.hammer.admin.ConfigRepository
|
||||
import com.darkrockstudios.apps.hammer.admin.WhiteListRepository
|
||||
import com.darkrockstudios.apps.hammer.base.http.TermsOfServiceChallenge
|
||||
import com.darkrockstudios.apps.hammer.base.http.Token
|
||||
import com.darkrockstudios.apps.hammer.patreon.PatreonConfig
|
||||
import com.darkrockstudios.apps.hammer.plugin.PluginRegistry
|
||||
import com.darkrockstudios.apps.hammer.projects.ProjectsRepository
|
||||
import com.darkrockstudios.apps.hammer.utilities.Msg
|
||||
import com.darkrockstudios.apps.hammer.utilities.SResult
|
||||
|
|
@ -18,9 +15,8 @@ class AccountsComponent(
|
|||
private val accountsRepository: AccountsRepository,
|
||||
private val whiteListRepository: WhiteListRepository,
|
||||
private val projectsRepository: ProjectsRepository,
|
||||
private val configRepository: ConfigRepository,
|
||||
private val termsOfServiceRepository: TermsOfServiceRepository,
|
||||
private val serverConfig: ServerConfig,
|
||||
private val pluginRegistry: PluginRegistry,
|
||||
) {
|
||||
suspend fun createAccount(
|
||||
email: String,
|
||||
|
|
@ -96,26 +92,12 @@ class AccountsComponent(
|
|||
whiteListRepository.isOnWhiteList(account.email).not()
|
||||
}
|
||||
|
||||
private suspend fun getActivePatreonConfig(): PatreonConfig? {
|
||||
if (serverConfig.patreonEnabled != true) return null
|
||||
val config = configRepository.get(AdminServerConfig.PATREON_CONFIG)
|
||||
return if (config.enabled && config.patreonUrl.isNotBlank()) config else null
|
||||
}
|
||||
|
||||
private suspend fun whiteListRejectedFailure(): ServerResult.Failure<Token> {
|
||||
val patreonConfig = getActivePatreonConfig()
|
||||
return if (patreonConfig != null) {
|
||||
val amount = "%.2f".format(patreonConfig.minimumAmountCents / 100.0)
|
||||
SResult.failure(
|
||||
error = "User not on whitelist - Patreon subscription required",
|
||||
displayMessage = Msg.r("login_failure_patreon_notice_message", amount, patreonConfig.patreonUrl)
|
||||
)
|
||||
} else {
|
||||
SResult.failure(
|
||||
error = "User not on whitelist",
|
||||
displayMessage = Msg.r("api_allowedusers_rejected")
|
||||
)
|
||||
}
|
||||
val message = pluginRegistry.activeAllowedUsersSource()?.rejectionMessage()
|
||||
return SResult.failure(
|
||||
error = "User not on whitelist",
|
||||
displayMessage = message ?: Msg.r("api_allowedusers_rejected")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import com.darkrockstudios.apps.hammer.email.SendGridConfig
|
|||
import com.darkrockstudios.apps.hammer.email.SmtpConfig
|
||||
import com.darkrockstudios.apps.hammer.monitoring.IgnoredErrorRule
|
||||
import com.darkrockstudios.apps.hammer.monitoring.MonitoringConfig
|
||||
import com.darkrockstudios.apps.hammer.patreon.PatreonConfig
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
|
|
@ -29,12 +28,6 @@ object AdminServerConfig {
|
|||
val ABOUT_SERVER = ServerConfigKey.string("about_server", "")
|
||||
val CONTACT_EMAIL = ServerConfigKey.string("contact_email", "")
|
||||
val DEFAULT_LOCALE = ServerConfigKey.string("default_locale", "en")
|
||||
val PATREON_CONFIG = ServerConfigKey(
|
||||
key = "patreon_config",
|
||||
default = PatreonConfig(),
|
||||
parse = { Json.decodeFromString<PatreonConfig>(it) },
|
||||
serialize = { Json.encodeToString(PatreonConfig.serializer(), it) }
|
||||
)
|
||||
val SMTP_CONFIG = ServerConfigKey(
|
||||
key = "smtp_config",
|
||||
default = SmtpConfig(),
|
||||
|
|
|
|||
|
|
@ -75,10 +75,6 @@ import com.darkrockstudios.apps.hammer.monitoring.StoryReaderCollector
|
|||
import com.darkrockstudios.apps.hammer.monitoring.StoryReaderRepository
|
||||
import com.darkrockstudios.apps.hammer.monitoring.UserActivityCollector
|
||||
import com.darkrockstudios.apps.hammer.monitoring.UserActivityRepository
|
||||
import com.darkrockstudios.apps.hammer.patreon.PatreonApiClient
|
||||
import com.darkrockstudios.apps.hammer.patreon.PatreonPollingJob
|
||||
import com.darkrockstudios.apps.hammer.patreon.PatreonSyncService
|
||||
import com.darkrockstudios.apps.hammer.patreon.PatreonWebhookHandler
|
||||
import com.darkrockstudios.apps.hammer.project.ProjectEntityDatabaseDatasource
|
||||
import com.darkrockstudios.apps.hammer.project.ProjectEntityDatasource
|
||||
import com.darkrockstudios.apps.hammer.project.ProjectEntityRepository
|
||||
|
|
@ -300,10 +296,6 @@ fun mainModule(
|
|||
single<AdminComponent>()
|
||||
single<AccountsComponent>()
|
||||
|
||||
single<PatreonApiClient>()
|
||||
single<PatreonSyncService>()
|
||||
single<PatreonWebhookHandler>()
|
||||
single<PatreonPollingJob>()
|
||||
|
||||
single<ServerSceneSynchronizer>()
|
||||
single<ServerNoteSynchronizer>()
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ import com.darkrockstudios.apps.hammer.monitoring.UserActivityRepository
|
|||
import com.darkrockstudios.apps.hammer.monitoring.isClientAbort
|
||||
import com.darkrockstudios.apps.hammer.monitoring.recordMonitoredError
|
||||
import com.darkrockstudios.apps.hammer.monitoring.toMonitoredStatus
|
||||
import com.darkrockstudios.apps.hammer.patreon.PatreonSyncService
|
||||
import com.darkrockstudios.apps.hammer.plugin.NoticeSlot
|
||||
import com.darkrockstudios.apps.hammer.plugin.pluginAdminNav
|
||||
import com.darkrockstudios.apps.hammer.plugin.putAllowedUsersNotice
|
||||
import com.darkrockstudios.apps.hammer.plugins.configureTemplating
|
||||
import com.darkrockstudios.apps.hammer.project.ProjectSyncKey
|
||||
import com.darkrockstudios.apps.hammer.project.ProjectSynchronizationSession
|
||||
|
|
@ -105,12 +107,6 @@ fun Route.frontend() {
|
|||
val projectsSyncManager: SyncSessionManager<Long, ProjectsSynchronizationSession> by inject(named(PROJECTS_SYNC_MANAGER))
|
||||
val projectSyncManager: SyncSessionManager<ProjectSyncKey, ProjectSynchronizationSession> by inject(named(PROJECT_SYNC_MANAGER))
|
||||
|
||||
// Only inject PatreonSyncService if Patreon is enabled at server level
|
||||
val patreonSyncService: PatreonSyncService? = if (serverConfig.patreonEnabled == true) {
|
||||
inject<PatreonSyncService>().value
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// Only inject EmailService if email is enabled at server level
|
||||
val emailService: EmailService? = if (serverConfig.emailProviderType != null) {
|
||||
|
|
@ -126,12 +122,12 @@ fun Route.frontend() {
|
|||
robotsRoutes()
|
||||
sitemapRoutes(serverConfig, accountsRepository, projectAccessRepository, configRepository)
|
||||
setupPage(serverConfig)
|
||||
homePage(configRepository, serverConfig, markdownService)
|
||||
homePage(configRepository, markdownService)
|
||||
aboutPage(configRepository, serverConfig, accountsRepository, projectAccessRepository, markdownService)
|
||||
termsOfServicePage()
|
||||
privacyPolicyPage()
|
||||
localeRoutes()
|
||||
authRoutes(accountsRepository, whiteListRepository, configRepository, serverConfig, securityRepository)
|
||||
authRoutes(accountsRepository, whiteListRepository, configRepository, securityRepository)
|
||||
signupPage(
|
||||
accountsComponent,
|
||||
accountsRepository,
|
||||
|
|
@ -191,7 +187,6 @@ fun Route.frontend() {
|
|||
projectsRepository,
|
||||
accountDeletionService,
|
||||
serverConfig,
|
||||
patreonSyncService,
|
||||
emailService,
|
||||
metricsRepository,
|
||||
errorRepository,
|
||||
|
|
@ -386,6 +381,13 @@ suspend fun ApplicationCall.withDefaults(data: Map<String, Any> = emptyMap()): M
|
|||
|
||||
val serverConfig = get<ServerConfig>()
|
||||
|
||||
// Only admin templates render the admin-nav partial that consumes these.
|
||||
if (request.path().startsWith("/admin")) {
|
||||
// Not putIfAbsent: the argument would compute the nav even when the key is present.
|
||||
if ("pluginNavEntries" !in model) model["pluginNavEntries"] = pluginAdminNav()
|
||||
model.putIfAbsent("emailFeatureEnabled", serverConfig.emailProviderType != null)
|
||||
}
|
||||
|
||||
val configRepository = get<ConfigRepository>()
|
||||
val aboutContent = configRepository.get(AdminServerConfig.ABOUT_SERVER)
|
||||
val hasAboutPage = aboutContent.isNotBlank()
|
||||
|
|
@ -404,13 +406,7 @@ suspend fun ApplicationCall.withDefaults(data: Map<String, Any> = emptyMap()): M
|
|||
model["footerExtraLinks"] = footerExtraLinks
|
||||
model["hasFooterNav"] = hasAboutPage || hasTermsPage || hasPrivacyPage || footerExtraLinks.isNotEmpty()
|
||||
|
||||
// Add Patreon link for footer if configured
|
||||
if (serverConfig.patreonEnabled == true) {
|
||||
val patreonConfig = configRepository.get(AdminServerConfig.PATREON_CONFIG)
|
||||
if (patreonConfig.enabled && patreonConfig.patreonUrl.isNotBlank()) {
|
||||
model["patreonUrl"] = patreonConfig.patreonUrl
|
||||
}
|
||||
}
|
||||
putAllowedUsersNotice(model, NoticeSlot.FOOTER, htmlKey = "footerNoticeHtml", providedKey = "footerNoticeProvided")
|
||||
|
||||
// Add community enabled flag for header nav
|
||||
if (serverConfig.communityEnabled) {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
package com.darkrockstudios.apps.hammer.frontend
|
||||
|
||||
import com.darkrockstudios.apps.hammer.ServerConfig
|
||||
import com.darkrockstudios.apps.hammer.admin.AdminServerConfig
|
||||
import com.darkrockstudios.apps.hammer.admin.ConfigRepository
|
||||
import com.darkrockstudios.apps.hammer.frontend.utils.canonicalUrl
|
||||
import com.darkrockstudios.apps.hammer.frontend.utils.msg
|
||||
import com.darkrockstudios.apps.hammer.frontend.utils.webSiteJsonLd
|
||||
import com.darkrockstudios.apps.hammer.utilities.MarkdownService
|
||||
import com.darkrockstudios.apps.hammer.plugin.NoticeSlot
|
||||
import com.darkrockstudios.apps.hammer.plugin.putAllowedUsersNotice
|
||||
import io.ktor.server.mustache.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
|
|
@ -34,7 +35,6 @@ private val MASTHEAD_PRELOADS = listOf(
|
|||
|
||||
fun Route.homePage(
|
||||
configRepository: ConfigRepository,
|
||||
serverConfig: ServerConfig,
|
||||
markdownService: MarkdownService,
|
||||
) {
|
||||
route("/") {
|
||||
|
|
@ -46,9 +46,10 @@ fun Route.homePage(
|
|||
configRepository.get(AdminServerConfig.SERVER_MESSAGE)
|
||||
)
|
||||
val contactEmail = configRepository.get(AdminServerConfig.CONTACT_EMAIL)
|
||||
val patreonConfig = configRepository.get(AdminServerConfig.PATREON_CONFIG)
|
||||
val patreonFeatureEnabled = serverConfig.patreonEnabled == true
|
||||
val patreonActive = patreonFeatureEnabled && patreonConfig.enabled && patreonConfig.patreonUrl.isNotBlank()
|
||||
val bannerNotice = call.putAllowedUsersNotice(
|
||||
model, NoticeSlot.HOME_BANNER,
|
||||
htmlKey = "allowedUsersBannerHtml", providedKey = "allowedUsersBannerProvided",
|
||||
)
|
||||
|
||||
model["serverMessageHtml"] = serverMessageHtml
|
||||
model["page_script"] = "/assets/js/home.js"
|
||||
|
|
@ -60,12 +61,13 @@ fun Route.homePage(
|
|||
description = call.msg("home_meta_description"),
|
||||
)
|
||||
|
||||
val showAllowedUsersNotice = contactEmail.isNotBlank() && !patreonActive
|
||||
val showAllowedUsersNotice = contactEmail.isNotBlank() && bannerNotice == null
|
||||
if (showAllowedUsersNotice) {
|
||||
model["allowedUsersNotice"] = true
|
||||
call.msg(model, "home_servermessage_allowedusers", contactEmail)
|
||||
}
|
||||
model["hasInstanceNotice"] = serverMessageHtml.isNotBlank() || showAllowedUsersNotice
|
||||
model["hasInstanceNotice"] =
|
||||
serverMessageHtml.isNotBlank() || showAllowedUsersNotice || !bannerNotice.isNullOrBlank()
|
||||
|
||||
call.respond(MustacheContent("home.mustache", model))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package com.darkrockstudios.apps.hammer.frontend
|
||||
|
||||
import com.darkrockstudios.apps.hammer.ServerConfig
|
||||
import com.darkrockstudios.apps.hammer.account.AccountsRepository
|
||||
import com.darkrockstudios.apps.hammer.admin.AdminServerConfig
|
||||
import com.darkrockstudios.apps.hammer.admin.ConfigRepository
|
||||
|
|
@ -9,6 +8,9 @@ import com.darkrockstudios.apps.hammer.frontend.data.UserSession
|
|||
import com.darkrockstudios.apps.hammer.monitoring.SecurityRepository
|
||||
import com.darkrockstudios.apps.hammer.plugins.LOGIN_RATE_LIMIT
|
||||
import com.darkrockstudios.apps.hammer.utilities.isSuccess
|
||||
import com.darkrockstudios.apps.hammer.plugin.NoticeSlot
|
||||
import com.darkrockstudios.apps.hammer.plugin.activeAllowedUsersSource
|
||||
import com.darkrockstudios.apps.hammer.plugin.putAllowedUsersNotice
|
||||
import com.github.aymanizz.ktori18n.R
|
||||
import com.github.aymanizz.ktori18n.t
|
||||
import io.ktor.server.mustache.*
|
||||
|
|
@ -23,10 +25,9 @@ fun Route.authRoutes(
|
|||
accountsRepository: AccountsRepository,
|
||||
whiteListRepository: WhiteListRepository,
|
||||
configRepository: ConfigRepository,
|
||||
serverConfig: ServerConfig,
|
||||
securityRepository: SecurityRepository,
|
||||
) {
|
||||
loginPage(accountsRepository, whiteListRepository, configRepository, serverConfig, securityRepository)
|
||||
loginPage(accountsRepository, whiteListRepository, configRepository, securityRepository)
|
||||
logout()
|
||||
unauthorized()
|
||||
}
|
||||
|
|
@ -35,7 +36,6 @@ private fun Route.loginPage(
|
|||
accountsRepository: AccountsRepository,
|
||||
whiteListRepository: WhiteListRepository,
|
||||
configRepository: ConfigRepository,
|
||||
serverConfig: ServerConfig,
|
||||
securityRepository: SecurityRepository,
|
||||
) {
|
||||
route("/login") {
|
||||
|
|
@ -47,7 +47,7 @@ private fun Route.loginPage(
|
|||
// Drop a present-but-unauthorized cookie so the user isn't bounced
|
||||
// straight back to /dashboard and into a redirect loop.
|
||||
if (session != null) call.sessions.clear<UserSession>()
|
||||
val model = buildLoginModel(call, configRepository, serverConfig)
|
||||
val model = buildLoginModel(call, configRepository)
|
||||
call.respond(MustacheContent("login.mustache", call.withDefaults(model)))
|
||||
}
|
||||
}
|
||||
|
|
@ -93,11 +93,13 @@ private fun Route.loginPage(
|
|||
call.sessions.set(session)
|
||||
call.respondRedirect("/dashboard")
|
||||
} else {
|
||||
val model = buildLoginModel(call, configRepository, serverConfig)
|
||||
val model = buildLoginModel(call, configRepository)
|
||||
.toMutableMap()
|
||||
// Valid credentials without a whitelist entry get no session, because
|
||||
// /dashboard would reject it and loop back here.
|
||||
model["message"] = credentialsError ?: call.t(R("api_allowedusers_rejected"))
|
||||
model["message"] = credentialsError
|
||||
?: call.activeAllowedUsersSource()?.rejectionMessage()?.text(call)
|
||||
?: call.t(R("api_allowedusers_rejected"))
|
||||
call.respond(MustacheContent("login.mustache", call.withDefaults(model)))
|
||||
}
|
||||
}
|
||||
|
|
@ -108,24 +110,15 @@ private fun Route.loginPage(
|
|||
private suspend fun buildLoginModel(
|
||||
call: RoutingCall,
|
||||
configRepository: ConfigRepository,
|
||||
serverConfig: ServerConfig
|
||||
): Map<String, Any> {
|
||||
val contactEmail = configRepository.get(AdminServerConfig.CONTACT_EMAIL)
|
||||
val patreonConfig = configRepository.get(AdminServerConfig.PATREON_CONFIG)
|
||||
val patreonFeatureEnabled = serverConfig.patreonEnabled == true
|
||||
val patreonActive = patreonFeatureEnabled && patreonConfig.enabled && patreonConfig.patreonUrl.isNotBlank()
|
||||
|
||||
return buildMap {
|
||||
put("page_stylesheet", "/assets/css/login.css")
|
||||
if (contactEmail.isNotBlank()) {
|
||||
put("contactEmail", contactEmail)
|
||||
}
|
||||
if (patreonActive) {
|
||||
val amount = "%.2f".format(patreonConfig.minimumAmountCents / 100.0)
|
||||
put("patreonEnabled", true)
|
||||
put("patreonUrl", patreonConfig.patreonUrl)
|
||||
put("patreonMessage", call.t(R("login_patreon_notice_message"), amount))
|
||||
}
|
||||
call.putAllowedUsersNotice(this, NoticeSlot.LOGIN)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,8 +71,6 @@ fun Route.adminMonitoringPages(
|
|||
projectsSyncManager: SyncSessionManager<Long, ProjectsSynchronizationSession>,
|
||||
projectSyncManager: SyncSessionManager<*, ProjectSynchronizationSession>,
|
||||
clock: Clock,
|
||||
patreonFeatureEnabled: Boolean,
|
||||
emailFeatureEnabled: Boolean,
|
||||
) {
|
||||
route("/monitoring") {
|
||||
get {
|
||||
|
|
@ -108,8 +106,6 @@ fun Route.adminMonitoringPages(
|
|||
"page_stylesheet" to "/assets/css/admin.css",
|
||||
"activeMonitoring" to true,
|
||||
"activeMonOverview" to true,
|
||||
"patreonFeatureEnabled" to patreonFeatureEnabled,
|
||||
"emailFeatureEnabled" to emailFeatureEnabled,
|
||||
"monitoringEnabled" to enabled,
|
||||
"statRequests" to formatCount(totals.requestCount),
|
||||
"statErrorRate" to formatPercent(totals.errorRate),
|
||||
|
|
@ -151,8 +147,6 @@ fun Route.adminMonitoringPages(
|
|||
"page_stylesheet" to "/assets/css/admin.css",
|
||||
"activeMonitoring" to true,
|
||||
"activeMonPerformance" to true,
|
||||
"patreonFeatureEnabled" to patreonFeatureEnabled,
|
||||
"emailFeatureEnabled" to emailFeatureEnabled,
|
||||
"range24h" to (range == RANGE_24H),
|
||||
"range7d" to (range == RANGE_7D),
|
||||
"range30d" to (range == RANGE_30D),
|
||||
|
|
@ -205,8 +199,6 @@ fun Route.adminMonitoringPages(
|
|||
"page_stylesheet" to "/assets/css/admin.css",
|
||||
"activeMonitoring" to true,
|
||||
"activeMonErrors" to true,
|
||||
"patreonFeatureEnabled" to patreonFeatureEnabled,
|
||||
"emailFeatureEnabled" to emailFeatureEnabled,
|
||||
"range24h" to (range == RANGE_24H),
|
||||
"range7d" to (range == RANGE_7D),
|
||||
"range30d" to (range == RANGE_30D),
|
||||
|
|
@ -310,8 +302,6 @@ fun Route.adminMonitoringPages(
|
|||
"page_stylesheet" to "/assets/css/admin.css",
|
||||
"activeMonitoring" to true,
|
||||
"activeMonSecurity" to true,
|
||||
"patreonFeatureEnabled" to patreonFeatureEnabled,
|
||||
"emailFeatureEnabled" to emailFeatureEnabled,
|
||||
"topFailures" to topFailures,
|
||||
"hasTopFailures" to topFailures.isNotEmpty(),
|
||||
"attempts" to attempts,
|
||||
|
|
@ -327,8 +317,6 @@ fun Route.adminMonitoringPages(
|
|||
"page_stylesheet" to "/assets/css/admin.css",
|
||||
"activeMonitoring" to true,
|
||||
"activeMonJobs" to true,
|
||||
"patreonFeatureEnabled" to patreonFeatureEnabled,
|
||||
"emailFeatureEnabled" to emailFeatureEnabled,
|
||||
"jobs" to jobs,
|
||||
"hasJobs" to jobs.isNotEmpty(),
|
||||
"anyFailing" to jobs.any { it["failing"] == true },
|
||||
|
|
@ -341,8 +329,6 @@ fun Route.adminMonitoringPages(
|
|||
"page_stylesheet" to "/assets/css/admin.css",
|
||||
"activeMonitoring" to true,
|
||||
"activeMonLogs" to true,
|
||||
"patreonFeatureEnabled" to patreonFeatureEnabled,
|
||||
"emailFeatureEnabled" to emailFeatureEnabled,
|
||||
)
|
||||
call.respond(MustacheContent("admin-monitoring-logs.mustache", call.withDefaults(model)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import com.darkrockstudios.apps.hammer.frontend.data.UserSession
|
|||
import com.darkrockstudios.apps.hammer.frontend.utils.msg
|
||||
import com.darkrockstudios.apps.hammer.monitoring.SecurityRepository
|
||||
import com.darkrockstudios.apps.hammer.plugins.LOGIN_RATE_LIMIT
|
||||
import com.darkrockstudios.apps.hammer.plugin.NoticeSlot
|
||||
import com.darkrockstudios.apps.hammer.plugin.putAllowedUsersNotice
|
||||
import io.ktor.server.mustache.*
|
||||
import io.ktor.server.plugins.origin
|
||||
import io.ktor.server.plugins.ratelimit.*
|
||||
|
|
@ -132,6 +134,7 @@ private suspend fun buildSignupModel(
|
|||
val model = call.withDefaults()
|
||||
model["page_stylesheet"] = "/assets/css/login.css"
|
||||
termsOfServiceRepository.challenge()?.let { model["tosVersion"] = it.version }
|
||||
call.putAllowedUsersNotice(model, NoticeSlot.SIGNUP)
|
||||
val contactEmail = configRepository.get(AdminServerConfig.CONTACT_EMAIL)
|
||||
if (contactEmail.isNotBlank()) {
|
||||
model["contactEmail"] = contactEmail
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import com.darkrockstudios.apps.hammer.frontend.utils.formatInstant
|
|||
import com.darkrockstudios.apps.hammer.frontend.utils.formatSyncDate
|
||||
import com.darkrockstudios.apps.hammer.frontend.utils.msg
|
||||
import com.darkrockstudios.apps.hammer.frontend.utils.respondToast
|
||||
import com.darkrockstudios.apps.hammer.patreon.PatreonSyncService
|
||||
import com.darkrockstudios.apps.hammer.plugin.installPluginAdminRoutes
|
||||
import com.darkrockstudios.apps.hammer.projects.ProjectsRepository
|
||||
import com.darkrockstudios.apps.hammer.utilities.ResUtils
|
||||
import io.ktor.htmx.*
|
||||
|
|
@ -35,7 +35,6 @@ fun Route.adminPage(
|
|||
projectsRepository: ProjectsRepository,
|
||||
accountDeletionService: AccountDeletionService,
|
||||
serverConfig: ServerConfig,
|
||||
patreonSyncService: PatreonSyncService?,
|
||||
emailService: EmailService?,
|
||||
metricsRepository: com.darkrockstudios.apps.hammer.monitoring.MetricsRepository,
|
||||
errorRepository: com.darkrockstudios.apps.hammer.monitoring.ErrorRepository,
|
||||
|
|
@ -47,31 +46,26 @@ fun Route.adminPage(
|
|||
projectSyncManager: com.darkrockstudios.apps.hammer.syncsessionmanager.SyncSessionManager<*, com.darkrockstudios.apps.hammer.project.ProjectSynchronizationSession>,
|
||||
clock: kotlin.time.Clock,
|
||||
) {
|
||||
val patreonFeatureEnabled = serverConfig.patreonEnabled == true
|
||||
val emailFeatureEnabled = serverConfig.emailProviderType != null
|
||||
|
||||
adminOnly {
|
||||
route("/admin") {
|
||||
adminSettingsPage(configRepository, patreonFeatureEnabled, emailFeatureEnabled)
|
||||
adminSettingsPage(configRepository)
|
||||
adminMonitoringPages(
|
||||
metricsRepository, configRepository, errorRepository, securityRepository,
|
||||
userActivityRepository, storyReaderRepository, recurringTaskRegistry,
|
||||
projectsSyncManager, projectSyncManager, clock,
|
||||
patreonFeatureEnabled, emailFeatureEnabled,
|
||||
)
|
||||
adminAllowedUsersPage(patreonFeatureEnabled, emailFeatureEnabled)
|
||||
adminUsersPage(patreonFeatureEnabled, emailFeatureEnabled)
|
||||
adminAllowedUsersPage()
|
||||
adminUsersPage()
|
||||
whiteListRoutes(whiteListRepository, clock)
|
||||
serverSettingsRoutes(configRepository)
|
||||
usersRoutes(accountsRepository, projectsRepository, accountDeletionService)
|
||||
if (patreonFeatureEnabled && patreonSyncService != null) {
|
||||
adminPatreonPage(configRepository, patreonSyncService, emailFeatureEnabled)
|
||||
patreonSettingsRoutes(configRepository, patreonSyncService, serverConfig)
|
||||
}
|
||||
if (emailFeatureEnabled && emailService != null) {
|
||||
adminEmailPage(configRepository, emailService, patreonFeatureEnabled, serverConfig)
|
||||
adminEmailPage(configRepository, emailService, serverConfig)
|
||||
emailSettingsRoutes(configRepository, emailService)
|
||||
}
|
||||
installPluginAdminRoutes()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -79,8 +73,6 @@ fun Route.adminPage(
|
|||
// GET /admin - Server Settings page (default)
|
||||
private fun Route.adminSettingsPage(
|
||||
configRepository: ConfigRepository,
|
||||
patreonFeatureEnabled: Boolean,
|
||||
emailFeatureEnabled: Boolean
|
||||
) {
|
||||
get {
|
||||
val configuredDefaultLocale = configRepository.get(AdminServerConfig.DEFAULT_LOCALE)
|
||||
|
|
@ -98,10 +90,7 @@ private fun Route.adminSettingsPage(
|
|||
"activeSettings" to true,
|
||||
"activeAllowedUsers" to false,
|
||||
"activeUsers" to false,
|
||||
"activePatreon" to false,
|
||||
"activeEmail" to false,
|
||||
"patreonFeatureEnabled" to patreonFeatureEnabled,
|
||||
"emailFeatureEnabled" to emailFeatureEnabled,
|
||||
"contactEmail" to configRepository.get(AdminServerConfig.CONTACT_EMAIL),
|
||||
"serverMessage" to configRepository.get(AdminServerConfig.SERVER_MESSAGE),
|
||||
"aboutServer" to configRepository.get(AdminServerConfig.ABOUT_SERVER),
|
||||
|
|
@ -124,20 +113,14 @@ private fun Route.adminSettingsPage(
|
|||
}
|
||||
|
||||
// GET /admin/allowed-users - Allowed Users management page
|
||||
private fun Route.adminAllowedUsersPage(
|
||||
patreonFeatureEnabled: Boolean,
|
||||
emailFeatureEnabled: Boolean
|
||||
) {
|
||||
private fun Route.adminAllowedUsersPage() {
|
||||
get("/allowed-users") {
|
||||
val model = mapOf(
|
||||
"page_stylesheet" to "/assets/css/admin.css",
|
||||
"activeSettings" to false,
|
||||
"activeAllowedUsers" to true,
|
||||
"activeUsers" to false,
|
||||
"activePatreon" to false,
|
||||
"activeEmail" to false,
|
||||
"patreonFeatureEnabled" to patreonFeatureEnabled,
|
||||
"emailFeatureEnabled" to emailFeatureEnabled,
|
||||
)
|
||||
call.respond(MustacheContent("admin-allowed-users.mustache", call.withDefaults(model)))
|
||||
}
|
||||
|
|
@ -149,17 +132,14 @@ private fun Route.adminAllowedUsersPage(
|
|||
}
|
||||
|
||||
// GET /admin/users - User Management page
|
||||
private fun Route.adminUsersPage(patreonFeatureEnabled: Boolean, emailFeatureEnabled: Boolean) {
|
||||
private fun Route.adminUsersPage() {
|
||||
get("/users") {
|
||||
val model = mapOf(
|
||||
"page_stylesheet" to "/assets/css/admin.css",
|
||||
"activeSettings" to false,
|
||||
"activeAllowedUsers" to false,
|
||||
"activeUsers" to true,
|
||||
"activePatreon" to false,
|
||||
"activeEmail" to false,
|
||||
"patreonFeatureEnabled" to patreonFeatureEnabled,
|
||||
"emailFeatureEnabled" to emailFeatureEnabled,
|
||||
)
|
||||
call.respond(MustacheContent("admin-users.mustache", call.withDefaults(model)))
|
||||
}
|
||||
|
|
@ -226,7 +206,7 @@ private suspend fun getUsersModel(
|
|||
"id" to account.id,
|
||||
"email" to account.email,
|
||||
"created" to formatDate(account.created),
|
||||
"lastSync" to (formatLastSync(account.most_recent_sync) ?: call.msg("admin_patreon_last_sync_never")),
|
||||
"lastSync" to (formatLastSync(account.most_recent_sync) ?: call.msg("admin_users_last_sync_never")),
|
||||
"penName" to account.pen_name,
|
||||
"hasPenName" to (account.pen_name != null),
|
||||
"projectCount" to account.project_count,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import kotlinx.html.div
|
|||
internal fun Route.adminEmailPage(
|
||||
configRepository: ConfigRepository,
|
||||
emailService: EmailService,
|
||||
patreonFeatureEnabled: Boolean,
|
||||
serverConfig: ServerConfig
|
||||
) {
|
||||
get("/email") {
|
||||
|
|
@ -38,10 +37,7 @@ internal fun Route.adminEmailPage(
|
|||
"activeSettings" to false,
|
||||
"activeAllowedUsers" to false,
|
||||
"activeUsers" to false,
|
||||
"activePatreon" to false,
|
||||
"activeEmail" to true,
|
||||
"patreonFeatureEnabled" to patreonFeatureEnabled,
|
||||
"emailFeatureEnabled" to true,
|
||||
"emailConfigured" to isConfigured,
|
||||
"activeProvider" to activeProvider.name,
|
||||
"isSmtpProvider" to (activeProvider == EmailProvider.SMTP),
|
||||
|
|
|
|||
|
|
@ -1,206 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.frontend.admin
|
||||
|
||||
import com.darkrockstudios.apps.hammer.ServerConfig
|
||||
import com.darkrockstudios.apps.hammer.admin.AdminServerConfig
|
||||
import com.darkrockstudios.apps.hammer.admin.ConfigRepository
|
||||
import com.darkrockstudios.apps.hammer.frontend.utils.formatPatreonDate
|
||||
import com.darkrockstudios.apps.hammer.frontend.utils.msg
|
||||
import com.darkrockstudios.apps.hammer.frontend.utils.respondHtmlFragment
|
||||
import com.darkrockstudios.apps.hammer.frontend.withDefaults
|
||||
import com.darkrockstudios.apps.hammer.patreon.PatreonApiClient
|
||||
import com.darkrockstudios.apps.hammer.patreon.PatreonSyncService
|
||||
import io.ktor.htmx.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.htmx.*
|
||||
import io.ktor.server.mustache.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import kotlinx.html.*
|
||||
import org.koin.ktor.ext.inject
|
||||
|
||||
internal fun Route.patreonSettingsRoutes(
|
||||
configRepository: ConfigRepository,
|
||||
patreonSyncService: PatreonSyncService,
|
||||
serverConfig: ServerConfig
|
||||
) {
|
||||
val patreonApiClient: PatreonApiClient by inject()
|
||||
|
||||
route("/patreon") {
|
||||
// POST /admin/patreon/fetch-campaign - Fetch Campaign ID from Patreon API
|
||||
hx.post("/fetch-campaign") {
|
||||
val params = call.receiveParameters()
|
||||
val accessToken = params["accessToken"]?.trim().orEmpty()
|
||||
|
||||
val currentConfig = configRepository.get(AdminServerConfig.PATREON_CONFIG)
|
||||
val effectiveToken = accessToken.ifEmpty { currentConfig.creatorAccessToken }
|
||||
|
||||
if (effectiveToken.isEmpty()) {
|
||||
val errorMsg = call.msg("admin_patreon_error_token_required")
|
||||
call.respondHtmlFragment {
|
||||
input {
|
||||
id = "campaignId"
|
||||
name = "campaignId"
|
||||
type = InputType.text
|
||||
value = ""
|
||||
classes = setOf("form-input", "form-input--error")
|
||||
placeholder = errorMsg
|
||||
}
|
||||
}
|
||||
return@post
|
||||
}
|
||||
|
||||
val result = patreonApiClient.fetchCampaignId(effectiveToken)
|
||||
|
||||
if (result.isSuccess) {
|
||||
val campaignId = result.getOrThrow()
|
||||
call.respondHtmlFragment {
|
||||
input {
|
||||
id = "campaignId"
|
||||
name = "campaignId"
|
||||
type = InputType.text
|
||||
value = campaignId
|
||||
classes = setOf("form-input")
|
||||
placeholder = "123456"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val errorMsg = call.msg("admin_patreon_error_fetch_failed")
|
||||
call.respondHtmlFragment {
|
||||
input {
|
||||
id = "campaignId"
|
||||
name = "campaignId"
|
||||
type = InputType.text
|
||||
value = ""
|
||||
classes = setOf("form-input", "form-input--error")
|
||||
placeholder = errorMsg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/patreon/settings - Save Patreon settings
|
||||
hx.post("/settings") {
|
||||
val params = call.receiveParameters()
|
||||
val enabled = params["enabled"] == "true"
|
||||
val campaignId = params["campaignId"]?.trim().orEmpty()
|
||||
val accessToken = params["accessToken"]?.trim().orEmpty()
|
||||
val webhookSecret = params["webhookSecret"]?.trim().orEmpty()
|
||||
val patreonUrl = params["patreonUrl"]?.trim().orEmpty()
|
||||
val minimumAmountStr = params["minimumAmount"]?.trim().orEmpty()
|
||||
val pollIntervalStr = params["pollInterval"]?.trim().orEmpty()
|
||||
|
||||
val currentConfig = configRepository.get(AdminServerConfig.PATREON_CONFIG)
|
||||
|
||||
// Validation: if trying to enable, required fields must be filled
|
||||
if (enabled) {
|
||||
val effectiveAccessToken = accessToken.ifEmpty { currentConfig.creatorAccessToken }
|
||||
|
||||
if (campaignId.isEmpty()) {
|
||||
call.response.header(HxResponseHeaders.Retarget, "#patreon-error")
|
||||
call.response.header(HxResponseHeaders.Reswap, "innerHTML")
|
||||
val errorMsg = call.msg("admin_patreon_error_campaign_required")
|
||||
call.respondHtmlFragment {
|
||||
div("error-message") {
|
||||
+errorMsg
|
||||
}
|
||||
}
|
||||
return@post
|
||||
}
|
||||
|
||||
if (effectiveAccessToken.isEmpty()) {
|
||||
call.response.header(HxResponseHeaders.Retarget, "#patreon-error")
|
||||
call.response.header(HxResponseHeaders.Reswap, "innerHTML")
|
||||
val errorMsg = call.msg("admin_patreon_error_token_required")
|
||||
call.respondHtmlFragment {
|
||||
div("error-message") {
|
||||
+errorMsg
|
||||
}
|
||||
}
|
||||
return@post
|
||||
}
|
||||
}
|
||||
|
||||
val minimumAmountCents = try {
|
||||
(minimumAmountStr.toDoubleOrNull()?.times(100))?.toInt() ?: 500
|
||||
} catch (_: Exception) {
|
||||
500
|
||||
}
|
||||
|
||||
val pollIntervalMinutes = pollIntervalStr.toIntOrNull()?.coerceAtLeast(1) ?: 60
|
||||
|
||||
val newConfig = currentConfig.copy(
|
||||
enabled = enabled,
|
||||
campaignId = campaignId,
|
||||
creatorAccessToken = accessToken.ifEmpty { currentConfig.creatorAccessToken },
|
||||
webhookSecret = webhookSecret.ifEmpty { currentConfig.webhookSecret },
|
||||
patreonUrl = patreonUrl.ifEmpty { currentConfig.patreonUrl },
|
||||
minimumAmountCents = minimumAmountCents,
|
||||
pollIntervalMinutes = pollIntervalMinutes
|
||||
)
|
||||
|
||||
configRepository.set(AdminServerConfig.PATREON_CONFIG, newConfig)
|
||||
|
||||
call.response.header(HxResponseHeaders.Refresh, "true")
|
||||
call.respond(HttpStatusCode.OK, "")
|
||||
}
|
||||
|
||||
// POST /admin/patreon/sync - Trigger manual sync
|
||||
hx.post("/sync") {
|
||||
val config = configRepository.get(AdminServerConfig.PATREON_CONFIG)
|
||||
|
||||
// Don't allow sync if not enabled
|
||||
if (!config.enabled) {
|
||||
call.response.header(HxResponseHeaders.Retarget, "#patreon-error")
|
||||
call.response.header(HxResponseHeaders.Reswap, "innerHTML")
|
||||
val errorMsg = call.msg("admin_patreon_error_not_enabled")
|
||||
call.respondHtmlFragment {
|
||||
div("error-message") {
|
||||
+errorMsg
|
||||
}
|
||||
}
|
||||
return@post
|
||||
}
|
||||
|
||||
val result = patreonSyncService.performFullSync()
|
||||
call.response.header(HxResponseHeaders.Refresh, "true")
|
||||
call.respond(HttpStatusCode.OK, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/patreon - Patreon Integration page
|
||||
internal fun Route.adminPatreonPage(
|
||||
configRepository: ConfigRepository,
|
||||
patreonSyncService: PatreonSyncService,
|
||||
emailFeatureEnabled: Boolean
|
||||
) {
|
||||
get("/patreon") {
|
||||
val patreonConfig = configRepository.get(AdminServerConfig.PATREON_CONFIG)
|
||||
val patreonMemberCount = patreonSyncService.getPatreonWhitelistCount()
|
||||
val patreonMembersWithAccounts = patreonSyncService.getPatreonMembersWithAccountsCount()
|
||||
|
||||
val model = mapOf(
|
||||
"page_stylesheet" to "/assets/css/admin.css",
|
||||
"activeSettings" to false,
|
||||
"activeAllowedUsers" to false,
|
||||
"activeUsers" to false,
|
||||
"activePatreon" to true,
|
||||
"activeEmail" to false,
|
||||
"patreonFeatureEnabled" to true,
|
||||
"emailFeatureEnabled" to emailFeatureEnabled,
|
||||
"patreonEnabled" to patreonConfig.enabled,
|
||||
"campaignId" to patreonConfig.campaignId,
|
||||
"accessToken" to patreonConfig.creatorAccessToken,
|
||||
"webhookSecret" to patreonConfig.webhookSecret,
|
||||
"patreonUrl" to patreonConfig.patreonUrl,
|
||||
"minimumAmountDollars" to "%.2f".format(patreonConfig.minimumAmountCents / 100.0),
|
||||
"pollIntervalMinutes" to patreonConfig.pollIntervalMinutes,
|
||||
"lastSync" to formatPatreonDate(patreonConfig.lastSync).ifEmpty { "Never" },
|
||||
"patreonMemberCount" to patreonMemberCount,
|
||||
"patreonMembersWithAccounts" to patreonMembersWithAccounts,
|
||||
"webhookUrl" to "/patreon/webhook"
|
||||
)
|
||||
call.respond(MustacheContent("admin-patreon.mustache", call.withDefaults(model)))
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ package com.darkrockstudios.apps.hammer.frontend.admin
|
|||
import com.darkrockstudios.apps.hammer.admin.WhiteListRepository
|
||||
import com.darkrockstudios.apps.hammer.frontend.utils.msg
|
||||
import com.darkrockstudios.apps.hammer.frontend.withDefaults
|
||||
import com.darkrockstudios.apps.hammer.patreon.PatreonSyncService
|
||||
import com.darkrockstudios.apps.hammer.plugin.PluginRegistry
|
||||
import io.ktor.htmx.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
|
|
@ -12,6 +12,7 @@ import io.ktor.server.mustache.*
|
|||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import org.koin.ktor.ext.get
|
||||
import io.ktor.utils.io.*
|
||||
import java.net.URLEncoder
|
||||
import java.time.LocalDate
|
||||
|
|
@ -260,6 +261,7 @@ internal suspend fun getWhitelistModel(
|
|||
sortOldestFirst: Boolean? = null,
|
||||
search: String? = null,
|
||||
): MutableMap<String, Any> {
|
||||
val sourceIds = call.application.get<PluginRegistry>().allowedUsersSourceIds
|
||||
val queryPage = call.request.queryParameters["page"]?.toIntOrNull()
|
||||
val actualPage = page ?: queryPage ?: 0
|
||||
|
||||
|
|
@ -290,8 +292,8 @@ internal suspend fun getWhitelistModel(
|
|||
?: call.msg("admin_allowedusers_expiry_never")),
|
||||
// Prefills the edit dialog's <input type="date">, which only accepts yyyy-MM-dd.
|
||||
"expiresRaw" to (entry.expires?.let { formatDateInputValue(it) } ?: ""),
|
||||
// Patreon sync owns its entries' lifecycle, so expiry isn't the admin's to set.
|
||||
"isPatreon" to (entry.reason == PatreonSyncService.WHITELIST_REASON),
|
||||
// A syncing source owns its entries' lifecycle, so expiry isn't the admin's to set.
|
||||
"sourceManaged" to (entry.reason in sourceIds),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,16 +11,6 @@ const val SYNC_DATE_PATTERN = "MMM dd, yyyy 'at' HH:mm"
|
|||
fun formatSyncDate(instant: kotlin.time.Instant): String =
|
||||
formatInstant(instant, SYNC_DATE_PATTERN)
|
||||
|
||||
/** Format a Patreon ISO datetime string for display. Patreon hands us strings, not Instants. */
|
||||
fun formatPatreonDate(dateTimeStr: String): String {
|
||||
if (dateTimeStr.isEmpty()) return ""
|
||||
return try {
|
||||
formatInstant(kotlin.time.Instant.parse(dateTimeStr), SYNC_DATE_PATTERN)
|
||||
} catch (_: Exception) {
|
||||
dateTimeStr
|
||||
}
|
||||
}
|
||||
|
||||
/** Format a kotlin.time.Instant with the given pattern, in [zone] (system zone by default). */
|
||||
fun formatInstant(
|
||||
instant: kotlin.time.Instant,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.darkrockstudios.apps.hammer.frontend.utils
|
|||
import com.darkrockstudios.apps.hammer.admin.AdminServerConfig
|
||||
import com.darkrockstudios.apps.hammer.admin.ConfigRepository
|
||||
import com.darkrockstudios.apps.hammer.frontend.LOCALE_COOKIE_NAME
|
||||
import com.darkrockstudios.apps.hammer.plugin.PluginRegistry
|
||||
import com.darkrockstudios.apps.hammer.utilities.ResUtils
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
|
|
@ -87,6 +88,19 @@ suspend fun ApplicationCall.withMessages(data: Map<String, Any> = emptyMap()): M
|
|||
messages[key] = bundle.getString(key)
|
||||
}
|
||||
|
||||
// Plugin bundles layer on top, same English-baseline rule per bundle.
|
||||
val pluginRegistry = runCatching { application.get<PluginRegistry>() }.getOrNull()
|
||||
pluginRegistry?.plugins?.mapNotNull { it.messageBundle() }?.forEach { bundleName ->
|
||||
val pluginEnglish = ResourceBundle.getBundle(bundleName, Locale.ENGLISH)
|
||||
pluginEnglish.keys.asSequence().forEach { key ->
|
||||
messages[key] = pluginEnglish.getString(key)
|
||||
}
|
||||
val pluginLocalized = ResourceBundle.getBundle(bundleName, locale)
|
||||
pluginLocalized.keys.asSequence().forEach { key ->
|
||||
messages[key] = pluginLocalized.getString(key)
|
||||
}
|
||||
}
|
||||
|
||||
val availableLocales = ResUtils.getTranslatedLocales()
|
||||
val localesForTemplate = availableLocales.map { lc ->
|
||||
mapOf(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import io.ktor.server.application.*
|
|||
import org.koin.ktor.ext.inject
|
||||
|
||||
/**
|
||||
* Starts the monitoring maintenance job. Unlike the Patreon job, this always
|
||||
* Starts the monitoring maintenance job. Unlike plugin-contributed jobs, this always
|
||||
* starts: the master enable lives in the runtime [MonitoringConfig] and is
|
||||
* checked inside the loop, so toggling it does not require a restart. The loop
|
||||
* no-ops cheaply when monitoring is disabled.
|
||||
|
|
|
|||
|
|
@ -1,191 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.patreon
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.java.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.plugins.logging.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.slf4j.Logger
|
||||
import java.security.MessageDigest
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
@Serializable
|
||||
data class PatreonMemberAttributes(
|
||||
val email: String? = null,
|
||||
val patron_status: String? = null,
|
||||
val currently_entitled_amount_cents: Int = 0
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PatreonMemberData(
|
||||
val id: String,
|
||||
val attributes: PatreonMemberAttributes? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PatreonPaginationCursors(
|
||||
val next: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PatreonPagination(
|
||||
val cursors: PatreonPaginationCursors? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PatreonMeta(
|
||||
val pagination: PatreonPagination? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PatreonMembersResponse(
|
||||
val data: List<PatreonMemberData> = emptyList(),
|
||||
val meta: PatreonMeta? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PatreonCampaignData(
|
||||
val id: String,
|
||||
val type: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PatreonCampaignsResponse(
|
||||
val data: List<PatreonCampaignData> = emptyList()
|
||||
)
|
||||
|
||||
class PatreonApiClient(
|
||||
private val logger: Logger
|
||||
) {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
private val httpClient = HttpClient(Java) {
|
||||
install(ContentNegotiation) {
|
||||
json(json)
|
||||
}
|
||||
install(Logging) {
|
||||
level = LogLevel.INFO
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchAllQualifyingMembers(
|
||||
campaignId: String,
|
||||
accessToken: String,
|
||||
minimumAmountCents: Int
|
||||
): Result<List<String>> {
|
||||
val allEmails = mutableListOf<String>()
|
||||
var cursor: String? = null
|
||||
|
||||
try {
|
||||
do {
|
||||
val response = fetchMembersPage(campaignId, accessToken, cursor)
|
||||
|
||||
response.data.forEach { member ->
|
||||
val attributes = member.attributes
|
||||
if (attributes != null &&
|
||||
attributes.patron_status == "active_patron" &&
|
||||
attributes.currently_entitled_amount_cents >= minimumAmountCents &&
|
||||
!attributes.email.isNullOrBlank()
|
||||
) {
|
||||
allEmails.add(attributes.email)
|
||||
}
|
||||
}
|
||||
|
||||
cursor = response.meta?.pagination?.cursors?.next
|
||||
} while (cursor != null)
|
||||
|
||||
return Result.success(allEmails)
|
||||
// API boundary: any failure becomes a failed Result.
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
logger.error("Failed to fetch Patreon members", e)
|
||||
return Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchMembersPage(
|
||||
campaignId: String,
|
||||
accessToken: String,
|
||||
cursor: String? = null
|
||||
): PatreonMembersResponse {
|
||||
val url = buildString {
|
||||
append("https://www.patreon.com/api/oauth2/v2/campaigns/$campaignId/members")
|
||||
append("?fields[member]=email,patron_status,currently_entitled_amount_cents")
|
||||
append("&page[count]=1000")
|
||||
if (cursor != null) {
|
||||
append("&page[cursor]=$cursor")
|
||||
}
|
||||
}
|
||||
|
||||
val response = httpClient.get(url) {
|
||||
header(HttpHeaders.Authorization, "Bearer $accessToken")
|
||||
header(HttpHeaders.UserAgent, "HammerServer/1.0")
|
||||
}
|
||||
|
||||
if (!response.status.isSuccess()) {
|
||||
throw PatreonApiException("Patreon API returned ${response.status}")
|
||||
}
|
||||
|
||||
return response.body()
|
||||
}
|
||||
|
||||
suspend fun fetchCampaignId(accessToken: String): Result<String> {
|
||||
return try {
|
||||
val url = "https://www.patreon.com/api/oauth2/v2/campaigns"
|
||||
|
||||
val response = httpClient.get(url) {
|
||||
header(HttpHeaders.Authorization, "Bearer $accessToken")
|
||||
header(HttpHeaders.UserAgent, "HammerServer/1.0")
|
||||
}
|
||||
|
||||
if (!response.status.isSuccess()) {
|
||||
return Result.failure(PatreonApiException("Patreon API returned ${response.status}"))
|
||||
}
|
||||
|
||||
val campaignsResponse: PatreonCampaignsResponse = response.body()
|
||||
|
||||
if (campaignsResponse.data.isEmpty()) {
|
||||
return Result.failure(PatreonApiException("No campaigns found for this account"))
|
||||
}
|
||||
|
||||
// Return the first campaign ID
|
||||
Result.success(campaignsResponse.data.first().id)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Failed to fetch campaign ID", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun verifyWebhookSignature(payload: String, signature: String, secret: String): Boolean {
|
||||
return try {
|
||||
val mac = Mac.getInstance("HmacMD5")
|
||||
val secretKey = SecretKeySpec(secret.toByteArray(), "HmacMD5")
|
||||
mac.init(secretKey)
|
||||
val hash = mac.doFinal(payload.toByteArray())
|
||||
val computedSignature = hash.joinToString("") { "%02x".format(it) }
|
||||
// Constant-time compare: a byte-by-byte String.equals leaks, via timing,
|
||||
// how long a prefix of the expected signature an attacker guessed right.
|
||||
MessageDigest.isEqual(
|
||||
computedSignature.toByteArray(Charsets.UTF_8),
|
||||
signature.lowercase().toByteArray(Charsets.UTF_8),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Failed to verify webhook signature", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
httpClient.close()
|
||||
}
|
||||
}
|
||||
|
||||
class PatreonApiException(message: String) : Exception(message)
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.patreon
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PatreonConfig(
|
||||
val enabled: Boolean = false,
|
||||
val campaignId: String = "",
|
||||
val creatorAccessToken: String = "",
|
||||
val webhookSecret: String = "",
|
||||
val patreonUrl: String = "",
|
||||
val minimumAmountCents: Int = 500,
|
||||
val pollIntervalMinutes: Int = 60,
|
||||
val lastSync: String = ""
|
||||
)
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.patreon
|
||||
|
||||
import com.darkrockstudios.apps.hammer.admin.AdminServerConfig
|
||||
import com.darkrockstudios.apps.hammer.admin.ConfigRepository
|
||||
import com.darkrockstudios.apps.hammer.scheduling.RecurringTask
|
||||
import org.slf4j.Logger
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
class PatreonPollingJob(
|
||||
private val patreonSyncService: PatreonSyncService,
|
||||
private val configRepository: ConfigRepository,
|
||||
logger: Logger,
|
||||
) : RecurringTask("Patreon polling job", logger) {
|
||||
|
||||
override suspend fun tick() {
|
||||
val config = configRepository.get(AdminServerConfig.PATREON_CONFIG)
|
||||
if (config.enabled) {
|
||||
logger.info("Running scheduled Patreon sync")
|
||||
val result = patreonSyncService.performFullSync()
|
||||
if (result.isFailure) {
|
||||
logger.error("Scheduled Patreon sync failed", result.exceptionOrNull())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun nextDelay() =
|
||||
configRepository.get(AdminServerConfig.PATREON_CONFIG).pollIntervalMinutes.coerceAtLeast(1).minutes
|
||||
|
||||
/** Back off longer after a failure (e.g. a sync error or an unreadable config) before retrying. */
|
||||
override suspend fun errorBackoff() = 5.minutes
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.patreon
|
||||
|
||||
import com.darkrockstudios.apps.hammer.ServerConfig
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import org.koin.ktor.ext.inject
|
||||
|
||||
fun Route.patreonRoutes(serverConfig: ServerConfig) {
|
||||
// Only set up routes if Patreon is enabled at server level
|
||||
if (serverConfig.patreonEnabled != true) {
|
||||
return
|
||||
}
|
||||
|
||||
val webhookHandler: PatreonWebhookHandler by inject()
|
||||
|
||||
route("/patreon") {
|
||||
webhookEndpoint(serverConfig, webhookHandler)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Route.webhookEndpoint(
|
||||
serverConfig: ServerConfig,
|
||||
webhookHandler: PatreonWebhookHandler
|
||||
) {
|
||||
post("/webhook") {
|
||||
// Check if Patreon is enabled at server level
|
||||
if (serverConfig.patreonEnabled != true) {
|
||||
call.respond(HttpStatusCode.NotFound)
|
||||
return@post
|
||||
}
|
||||
|
||||
val signature = call.request.headers["X-Patreon-Signature"]
|
||||
val eventType = call.request.headers["X-Patreon-Event"]
|
||||
val payload = call.receiveText()
|
||||
|
||||
when (val result = webhookHandler.handleWebhook(payload, signature, eventType)) {
|
||||
is WebhookResult.Disabled -> {
|
||||
call.respond(HttpStatusCode.NotFound)
|
||||
}
|
||||
|
||||
is WebhookResult.MissingSignature -> {
|
||||
call.respond(HttpStatusCode.BadRequest, "Missing signature")
|
||||
}
|
||||
|
||||
is WebhookResult.NotConfigured -> {
|
||||
call.respond(HttpStatusCode.InternalServerError, "Webhook not configured")
|
||||
}
|
||||
|
||||
is WebhookResult.InvalidSignature -> {
|
||||
call.respond(HttpStatusCode.Unauthorized, "Invalid signature")
|
||||
}
|
||||
|
||||
is WebhookResult.Success -> {
|
||||
call.respond(HttpStatusCode.OK, "Sync completed")
|
||||
}
|
||||
|
||||
is WebhookResult.SyncFailed -> {
|
||||
call.respond(HttpStatusCode.InternalServerError, result.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.patreon
|
||||
|
||||
import com.darkrockstudios.apps.hammer.account.AccountsRepository
|
||||
import com.darkrockstudios.apps.hammer.admin.AdminServerConfig
|
||||
import com.darkrockstudios.apps.hammer.admin.ConfigRepository
|
||||
import com.darkrockstudios.apps.hammer.admin.WhiteListRepository
|
||||
import io.ktor.util.*
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.slf4j.Logger
|
||||
import kotlin.time.Clock
|
||||
|
||||
@Serializable
|
||||
data class SyncResult(
|
||||
val added: Int,
|
||||
val removed: Int,
|
||||
val skipped: Int,
|
||||
val errors: List<String> = emptyList()
|
||||
)
|
||||
|
||||
class PatreonSyncService(
|
||||
private val patreonApiClient: PatreonApiClient,
|
||||
private val whiteListRepository: WhiteListRepository,
|
||||
private val accountsRepository: AccountsRepository,
|
||||
private val configRepository: ConfigRepository,
|
||||
private val clock: Clock,
|
||||
private val logger: Logger
|
||||
) {
|
||||
companion object {
|
||||
const val WHITELIST_REASON = "Patreon"
|
||||
}
|
||||
|
||||
suspend fun performFullSync(): Result<SyncResult> {
|
||||
val config = configRepository.get(AdminServerConfig.PATREON_CONFIG)
|
||||
|
||||
if (!config.enabled) {
|
||||
logger.info("Patreon sync skipped - disabled in config")
|
||||
return Result.success(SyncResult(added = 0, removed = 0, skipped = 0))
|
||||
}
|
||||
|
||||
if (config.campaignId.isBlank() || config.creatorAccessToken.isBlank()) {
|
||||
logger.warn("Patreon sync skipped - missing campaign ID or access token")
|
||||
return Result.failure(PatreonSyncException("Missing campaign ID or access token"))
|
||||
}
|
||||
|
||||
logger.info("Starting Patreon sync for campaign ${config.campaignId}")
|
||||
|
||||
// 1. Get qualifying patrons from Patreon API
|
||||
val patreonResult = patreonApiClient.fetchAllQualifyingMembers(
|
||||
campaignId = config.campaignId,
|
||||
accessToken = config.creatorAccessToken,
|
||||
minimumAmountCents = config.minimumAmountCents
|
||||
)
|
||||
|
||||
if (patreonResult.isFailure) {
|
||||
return Result.failure(patreonResult.exceptionOrNull()!!)
|
||||
}
|
||||
|
||||
val patreonEmails = patreonResult.getOrThrow()
|
||||
.map { it.trim().toLowerCasePreservingASCIIRules() }
|
||||
.toSet()
|
||||
|
||||
logger.info("Found ${patreonEmails.size} qualifying Patreon members")
|
||||
|
||||
// 2. Get current Patreon-managed whitelist entries ONLY
|
||||
val currentPatreonEntries = whiteListRepository.getWhiteListByReason(WHITELIST_REASON)
|
||||
.map { it.email }
|
||||
.toSet()
|
||||
|
||||
logger.info("Found ${currentPatreonEntries.size} existing Patreon whitelist entries")
|
||||
|
||||
// 3. Add new patrons (only if not already whitelisted with ANY reason)
|
||||
var addedCount = 0
|
||||
var skippedCount = 0
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
for (email in patreonEmails) {
|
||||
try {
|
||||
if (!whiteListRepository.isOnWhiteList(email)) {
|
||||
whiteListRepository.addToWhiteList(email, WHITELIST_REASON)
|
||||
logger.info("Added Patreon member to whitelist: $email")
|
||||
addedCount++
|
||||
} else if (email !in currentPatreonEntries) {
|
||||
// Already whitelisted with different reason - skip
|
||||
logger.debug("Skipping Patreon member (already whitelisted): $email")
|
||||
skippedCount++
|
||||
}
|
||||
// Per-member: one failure must not abort the whole sync.
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
logger.error("Failed to add Patreon member to whitelist: $email", e)
|
||||
errors.add("Failed to add $email: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Remove lapsed patrons + force logout (only Patreon-managed entries)
|
||||
var removedCount = 0
|
||||
val toRemove = currentPatreonEntries - patreonEmails
|
||||
|
||||
for (email in toRemove) {
|
||||
try {
|
||||
whiteListRepository.removeFromWhiteList(email)
|
||||
forceLogout(email)
|
||||
logger.info("Removed lapsed Patreon member from whitelist: $email")
|
||||
removedCount++
|
||||
} catch (e: Exception) {
|
||||
logger.error("Failed to remove Patreon member from whitelist: $email", e)
|
||||
errors.add("Failed to remove $email: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Update last sync time
|
||||
val updatedConfig = config.copy(lastSync = clock.now().toString())
|
||||
configRepository.set(AdminServerConfig.PATREON_CONFIG, updatedConfig)
|
||||
|
||||
val result = SyncResult(
|
||||
added = addedCount,
|
||||
removed = removedCount,
|
||||
skipped = skippedCount,
|
||||
errors = errors
|
||||
)
|
||||
|
||||
logger.info("Patreon sync completed: added=$addedCount, removed=$removedCount, skipped=$skippedCount, errors=${errors.size}")
|
||||
|
||||
return Result.success(result)
|
||||
}
|
||||
|
||||
suspend fun forceLogout(email: String) {
|
||||
if (accountsRepository.forceLogout(email)) {
|
||||
logger.info("Force logged out user: $email")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getPatreonWhitelistCount(): Long {
|
||||
return whiteListRepository.getWhiteListByReason(WHITELIST_REASON).size.toLong()
|
||||
}
|
||||
|
||||
suspend fun getPatreonMembersWithAccountsCount(): Long {
|
||||
return whiteListRepository.countByReasonWithAccounts(WHITELIST_REASON)
|
||||
}
|
||||
}
|
||||
|
||||
class PatreonSyncException(message: String) : Exception(message)
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.patreon
|
||||
|
||||
import com.darkrockstudios.apps.hammer.admin.AdminServerConfig
|
||||
import com.darkrockstudios.apps.hammer.admin.ConfigRepository
|
||||
import org.slf4j.Logger
|
||||
|
||||
class PatreonWebhookHandler(
|
||||
private val patreonApiClient: PatreonApiClient,
|
||||
private val patreonSyncService: PatreonSyncService,
|
||||
private val configRepository: ConfigRepository,
|
||||
private val logger: Logger
|
||||
) {
|
||||
suspend fun handleWebhook(
|
||||
payload: String,
|
||||
signature: String?,
|
||||
eventType: String?
|
||||
): WebhookResult {
|
||||
val config = configRepository.get(AdminServerConfig.PATREON_CONFIG)
|
||||
|
||||
if (!config.enabled) {
|
||||
logger.info("Patreon webhook received but integration is disabled")
|
||||
return WebhookResult.Disabled
|
||||
}
|
||||
|
||||
if (signature.isNullOrBlank()) {
|
||||
logger.warn("Patreon webhook missing signature header")
|
||||
return WebhookResult.MissingSignature
|
||||
}
|
||||
|
||||
if (config.webhookSecret.isBlank()) {
|
||||
logger.warn("Patreon webhook secret not configured")
|
||||
return WebhookResult.NotConfigured
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
if (!patreonApiClient.verifyWebhookSignature(payload, signature, config.webhookSecret)) {
|
||||
logger.warn("Patreon webhook signature verification failed")
|
||||
return WebhookResult.InvalidSignature
|
||||
}
|
||||
|
||||
logger.info("Received valid Patreon webhook: $eventType")
|
||||
|
||||
// On any member event, trigger a full sync
|
||||
// This is the simplest and most reliable approach
|
||||
val syncResult = patreonSyncService.performFullSync()
|
||||
|
||||
return if (syncResult.isSuccess) {
|
||||
WebhookResult.Success(syncResult.getOrThrow())
|
||||
} else {
|
||||
WebhookResult.SyncFailed(syncResult.exceptionOrNull()?.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class WebhookResult {
|
||||
data object Disabled : WebhookResult()
|
||||
data object MissingSignature : WebhookResult()
|
||||
data object NotConfigured : WebhookResult()
|
||||
data object InvalidSignature : WebhookResult()
|
||||
data class Success(val syncResult: SyncResult) : WebhookResult()
|
||||
data class SyncFailed(val error: String) : WebhookResult()
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.patreon
|
||||
|
||||
import com.darkrockstudios.apps.hammer.ServerConfig
|
||||
import com.darkrockstudios.apps.hammer.scheduling.launchRecurringTask
|
||||
import io.ktor.server.application.*
|
||||
import org.koin.ktor.ext.inject
|
||||
|
||||
fun Application.configurePatreonPolling(config: ServerConfig) {
|
||||
if (config.patreonEnabled == true) {
|
||||
val pollingJob: PatreonPollingJob by inject()
|
||||
launchRecurringTask(pollingJob)
|
||||
log.info("Patreon polling job started")
|
||||
} else {
|
||||
log.info("Patreon integration disabled at server level")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package com.darkrockstudios.apps.hammer.plugin
|
||||
|
||||
import com.darkrockstudios.apps.hammer.utilities.Msg
|
||||
import io.ktor.server.application.*
|
||||
|
||||
/**
|
||||
* Where an [AllowedUsersSource] notice is rendered. Each slot is inserted unescaped, so
|
||||
* sources return trusted HTML, typically from their own mustache template.
|
||||
*/
|
||||
enum class NoticeSlot {
|
||||
/** Info panel on the web login page. */
|
||||
LOGIN,
|
||||
|
||||
/** Info panel on the web signup page. */
|
||||
SIGNUP,
|
||||
|
||||
/** Instance notice band on the home page. */
|
||||
HOME_BANNER,
|
||||
|
||||
/** Social/links row in the site footer. */
|
||||
FOOTER,
|
||||
}
|
||||
|
||||
/**
|
||||
* A system that decides who belongs on the allowed-users list, e.g. a membership service
|
||||
* that syncs subscribers onto it.
|
||||
*
|
||||
* The [id] doubles as the `WhiteListRepository` reason tag: a source must only add and
|
||||
* remove entries carrying its own tag, so manually-added entries and other sources'
|
||||
* entries survive its syncs.
|
||||
*/
|
||||
interface AllowedUsersSource {
|
||||
val id: String
|
||||
|
||||
/** Fully configured and turned on. Inactive sources contribute no notices. */
|
||||
suspend fun isActive(): Boolean
|
||||
|
||||
/**
|
||||
* HTML for [slot]. Null falls back to the stock template content for that slot; an
|
||||
* empty string replaces the stock content with nothing.
|
||||
*/
|
||||
suspend fun notice(call: ApplicationCall, slot: NoticeSlot): String?
|
||||
|
||||
/**
|
||||
* Shown by the native client when signup/login is rejected because the user is not
|
||||
* on the allowed list. Null falls back to the stock rejection message.
|
||||
*/
|
||||
suspend fun rejectionMessage(): Msg?
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.darkrockstudios.apps.hammer.plugin
|
||||
|
||||
/**
|
||||
* The plugins compiled into this build. Downstream distributions register theirs here;
|
||||
* keep this file otherwise untouched so their patch applies cleanly.
|
||||
*/
|
||||
fun installedPlugins(): List<ServerPlugin> = listOf()
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.darkrockstudios.apps.hammer.plugin
|
||||
|
||||
import org.slf4j.Logger
|
||||
|
||||
/**
|
||||
* The [installedPlugins] active in this build. Everything that consumes plugin
|
||||
* contributions (routing, DI, admin UI, notices) goes through this.
|
||||
*/
|
||||
class PluginRegistry(val plugins: List<ServerPlugin>) {
|
||||
|
||||
// Lazy: the registry is built before Koin starts, and a source may inject dependencies
|
||||
// when constructed. First access happens at request time, when the graph is up.
|
||||
private val sources: List<AllowedUsersSource> by lazy { plugins.mapNotNull { it.allowedUsersSource() } }
|
||||
|
||||
/** Reason tags owned by a source, whose whitelist entries the admin must not hand-edit. */
|
||||
val allowedUsersSourceIds: Set<String> by lazy { sources.map { it.id }.toSet() }
|
||||
|
||||
/**
|
||||
* The allow-list source currently in effect, or null if none is active. With multiple
|
||||
* active sources the first by registration order wins for notices; syncing is
|
||||
* unaffected since each source only touches entries tagged with its own id.
|
||||
*/
|
||||
suspend fun activeAllowedUsersSource(): AllowedUsersSource? =
|
||||
sources.firstOrNull { it.isActive() }
|
||||
|
||||
companion object {
|
||||
fun create(plugins: List<ServerPlugin>, logger: Logger): PluginRegistry {
|
||||
plugins.forEach { logger.info("Server plugin '${it.id}' installed") }
|
||||
return PluginRegistry(plugins)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package com.darkrockstudios.apps.hammer.plugin
|
||||
|
||||
import com.darkrockstudios.apps.hammer.scheduling.launchRecurringTask
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.util.*
|
||||
import org.koin.ktor.ext.get
|
||||
import org.koin.ktor.ext.inject
|
||||
|
||||
/** Mounts every enabled plugin's public routes at the receiver. */
|
||||
fun Route.installPluginRoutes() {
|
||||
val pluginRegistry: PluginRegistry by application.inject()
|
||||
pluginRegistry.plugins.forEach { it.installRoutes(this) }
|
||||
}
|
||||
|
||||
/** Mounts every enabled plugin's admin routes at the receiver, inside the admin gate. */
|
||||
fun Route.installPluginAdminRoutes() {
|
||||
val pluginRegistry: PluginRegistry by application.inject()
|
||||
pluginRegistry.plugins.forEach { it.installAdminRoutes(this) }
|
||||
}
|
||||
|
||||
/** Starts every enabled plugin's recurring tasks. */
|
||||
fun Application.launchPluginTasks() {
|
||||
val pluginRegistry: PluginRegistry by inject()
|
||||
pluginRegistry.plugins
|
||||
.flatMap { it.recurringTasks(this) }
|
||||
.forEach { launchRecurringTask(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every enabled plugin's admin nav entries as mustache model maps, marking the entry
|
||||
* whose href the current request is under as active. Admin page models put this under
|
||||
* `pluginNavEntries` for the admin-nav partial.
|
||||
*/
|
||||
suspend fun ApplicationCall.pluginAdminNav(): List<Map<String, Any>> {
|
||||
val pluginRegistry: PluginRegistry = application.get()
|
||||
val path = request.path()
|
||||
return pluginRegistry.plugins
|
||||
.flatMap { it.adminNavEntries() }
|
||||
.map { entry ->
|
||||
mapOf(
|
||||
"href" to entry.href,
|
||||
"icon" to entry.icon,
|
||||
"label" to entry.label(this),
|
||||
"active" to (path == entry.href || path.startsWith("${entry.href}/")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The allow-list source currently in effect, or null if none is active. Resolved once per
|
||||
* request: isActive() may cost a config read, and several model builders ask per render.
|
||||
*/
|
||||
suspend fun ApplicationCall.activeAllowedUsersSource(): AllowedUsersSource? {
|
||||
attributes.getOrNull(activeSourceKey)?.let { return it.source }
|
||||
val source = application.get<PluginRegistry>().activeAllowedUsersSource()
|
||||
attributes.put(activeSourceKey, CachedActiveSource(source))
|
||||
return source
|
||||
}
|
||||
|
||||
private val activeSourceKey = AttributeKey<CachedActiveSource>("ActiveAllowedUsersSource")
|
||||
|
||||
private class CachedActiveSource(val source: AllowedUsersSource?)
|
||||
|
||||
/**
|
||||
* Applies [AllowedUsersSource.notice] for [slot] to a template model: [providedKey] is set
|
||||
* when an active source answered (suppressing the stock content), [htmlKey] only when there
|
||||
* is markup to render. Returns the raw notice, null when no active source provided one.
|
||||
*/
|
||||
suspend fun ApplicationCall.putAllowedUsersNotice(
|
||||
model: MutableMap<String, Any>,
|
||||
slot: NoticeSlot,
|
||||
htmlKey: String = "allowedUsersNoticeHtml",
|
||||
providedKey: String = "allowedUsersNoticeProvided",
|
||||
): String? {
|
||||
val html = activeAllowedUsersSource()?.notice(this, slot) ?: return null
|
||||
model[providedKey] = true
|
||||
if (html.isNotBlank()) model[htmlKey] = html
|
||||
return html
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package com.darkrockstudios.apps.hammer.plugin
|
||||
|
||||
import com.darkrockstudios.apps.hammer.scheduling.RecurringTask
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.routing.*
|
||||
import org.koin.core.module.Module
|
||||
|
||||
/**
|
||||
* A server extension. Registering it in [installedPlugins] is what activates it; there is
|
||||
* no separate enablement switch.
|
||||
*
|
||||
* Plugins compile as part of `:server`, so they may use anything in it, including Koin
|
||||
* injection inside route handlers and tasks.
|
||||
*/
|
||||
interface ServerPlugin {
|
||||
/** Stable identifier, also used as this plugin's allow-list reason tag. */
|
||||
val id: String
|
||||
|
||||
/** Koin definitions for this plugin, installed alongside the main module. */
|
||||
fun koinModule(): Module? = null
|
||||
|
||||
/** Public routes, mounted at the routing root. */
|
||||
fun installRoutes(route: Route) {}
|
||||
|
||||
/** Admin routes, mounted under `/admin` inside the admin-only gate. */
|
||||
fun installAdminRoutes(route: Route) {}
|
||||
|
||||
/** Entries appended to the admin navigation. */
|
||||
fun adminNavEntries(): List<AdminNavEntry> = emptyList()
|
||||
|
||||
/**
|
||||
* Base name of this plugin's resource bundle (e.g. `i18n.MyPluginMessages`), merged into
|
||||
* every page's template msg map. Must resolve for Locale.ENGLISH, so ship a base or `_en`
|
||||
* variant. Null contributes nothing.
|
||||
*/
|
||||
fun messageBundle(): String? = null
|
||||
|
||||
/** Background jobs to launch at startup. */
|
||||
fun recurringTasks(application: Application): List<RecurringTask> = emptyList()
|
||||
|
||||
/** The allow-list source this plugin provides, if any. */
|
||||
fun allowedUsersSource(): AllowedUsersSource? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param href absolute path, e.g. `/admin/patreon`. Also used to mark the entry active.
|
||||
* @param icon Font Awesome classes, e.g. `fa-brands fa-patreon`.
|
||||
* @param label localized label for the current request.
|
||||
*/
|
||||
data class AdminNavEntry(
|
||||
val href: String,
|
||||
val icon: String,
|
||||
val label: suspend (ApplicationCall) -> String,
|
||||
)
|
||||
|
|
@ -3,6 +3,9 @@ package com.darkrockstudios.apps.hammer.plugins
|
|||
import com.darkrockstudios.apps.hammer.ServerConfig
|
||||
import com.darkrockstudios.apps.hammer.database.Database
|
||||
import com.darkrockstudios.apps.hammer.dependencyinjection.mainModule
|
||||
import com.darkrockstudios.apps.hammer.plugin.PluginRegistry
|
||||
import com.darkrockstudios.apps.hammer.plugin.ServerPlugin
|
||||
import com.darkrockstudios.apps.hammer.plugin.installedPlugins
|
||||
import io.ktor.server.application.*
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.dsl.module
|
||||
|
|
@ -10,17 +13,25 @@ import org.koin.ktor.ext.get
|
|||
import org.koin.ktor.plugin.Koin
|
||||
import org.koin.logger.slf4jLogger
|
||||
|
||||
fun Application.configureDependencyInjection(serverConfig: ServerConfig = ServerConfig(), addInModule: Module? = null) {
|
||||
fun Application.configureDependencyInjection(
|
||||
serverConfig: ServerConfig = ServerConfig(),
|
||||
addInModule: Module? = null,
|
||||
plugins: List<ServerPlugin> = installedPlugins(),
|
||||
) {
|
||||
val logger = log
|
||||
|
||||
val pluginRegistry = PluginRegistry.create(plugins, logger)
|
||||
|
||||
val serverConfigModule = module {
|
||||
single { serverConfig }
|
||||
single { pluginRegistry }
|
||||
}
|
||||
|
||||
install(Koin) {
|
||||
slf4jLogger()
|
||||
|
||||
val modules = mutableListOf(mainModule(logger), serverConfigModule)
|
||||
pluginRegistry.plugins.mapNotNullTo(modules) { it.koinModule() }
|
||||
if (addInModule != null) {
|
||||
modules.add(addInModule)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import com.darkrockstudios.apps.hammer.account.accountRoutes
|
|||
import com.darkrockstudios.apps.hammer.admin.adminRoutes
|
||||
import com.darkrockstudios.apps.hammer.base.http.API_ROUTE_PREFIX
|
||||
import com.darkrockstudios.apps.hammer.frontend.frontend
|
||||
import com.darkrockstudios.apps.hammer.patreon.patreonRoutes
|
||||
import com.darkrockstudios.apps.hammer.plugin.installPluginRoutes
|
||||
import com.darkrockstudios.apps.hammer.project.projectRoutes
|
||||
import com.darkrockstudios.apps.hammer.projects.projectsRoutes
|
||||
import com.darkrockstudios.apps.hammer.storyideas.ideasRoutes
|
||||
|
|
@ -20,7 +20,7 @@ fun Application.configureRouting(serverConfig: ServerConfig = ServerConfig()) {
|
|||
val logger = log
|
||||
routing {
|
||||
frontend()
|
||||
patreonRoutes(serverConfig)
|
||||
installPluginRoutes()
|
||||
route(API_ROUTE_PREFIX) {
|
||||
accountRoutes()
|
||||
projectsRoutes()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import kotlin.time.Instant
|
|||
|
||||
/**
|
||||
* Shared scaffolding for the server's recurring background jobs (token cleanup,
|
||||
* monitoring maintenance, Patreon polling, ...). A subclass supplies the work
|
||||
* monitoring maintenance, plugin sync jobs, ...). A subclass supplies the work
|
||||
* ([tick]) and the cadence ([nextDelay]); this base owns the loop, the
|
||||
* already-running guard, exception handling, graceful shutdown, and the
|
||||
* liveness/timing bookkeeping surfaced as a [RecurringTaskStatus].
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@ import java.util.concurrent.CopyOnWriteArrayList
|
|||
/**
|
||||
* Holds the set of [RecurringTask]s that have been started, so the admin
|
||||
* dashboard can report each one's status. Tasks register themselves when
|
||||
* launched via [launchRecurringTask]; a task that never starts (e.g. Patreon
|
||||
* polling when the integration is disabled at the server level) does not appear.
|
||||
* launched via [launchRecurringTask]; a task that never starts does not appear.
|
||||
*/
|
||||
class RecurringTaskRegistry {
|
||||
private val tasks = CopyOnWriteArrayList<RecurringTask>()
|
||||
|
|
|
|||
|
|
@ -26,11 +26,11 @@ sealed class ServerResult<out T> {
|
|||
) : ServerResult<T>() {
|
||||
override val isSuccess = false
|
||||
|
||||
fun displayMessageText(call: ApplicationCall, default: R): String {
|
||||
suspend fun displayMessageText(call: ApplicationCall, default: R): String {
|
||||
return displayMessage?.text(call) ?: call.t(default)
|
||||
}
|
||||
|
||||
fun displayMessageText(call: ApplicationCall): String? {
|
||||
suspend fun displayMessageText(call: ApplicationCall): String? {
|
||||
return displayMessage?.text(call)
|
||||
}
|
||||
}
|
||||
|
|
@ -55,17 +55,33 @@ sealed class ServerResult<out T> {
|
|||
|
||||
typealias Msg = ServerMessage
|
||||
|
||||
class ServerMessage private constructor(
|
||||
@PropertyKey(resourceBundle = DEFAULT_RESOURCE_BUNDLE) res: R,
|
||||
inArgs: List<Any>
|
||||
) {
|
||||
val r: R = res
|
||||
val args: List<Any> = inArgs
|
||||
sealed class ServerMessage {
|
||||
abstract suspend fun text(call: ApplicationCall): String
|
||||
|
||||
fun text(call: ApplicationCall): String = call.t(r, args)
|
||||
class Resource internal constructor(
|
||||
@PropertyKey(resourceBundle = DEFAULT_RESOURCE_BUNDLE) res: R,
|
||||
inArgs: List<Any>
|
||||
) : ServerMessage() {
|
||||
val r: R = res
|
||||
val args: List<Any> = inArgs
|
||||
|
||||
override suspend fun text(call: ApplicationCall): String = call.t(r, args)
|
||||
}
|
||||
|
||||
/** Text already resolved by the caller, e.g. by a plugin from its own resource bundle. */
|
||||
class Literal(private val value: String) : ServerMessage() {
|
||||
override suspend fun text(call: ApplicationCall): String = value
|
||||
}
|
||||
|
||||
/** Resolved per request, for messages living outside the default bundle. */
|
||||
class Dynamic(private val resolve: suspend (ApplicationCall) -> String) : ServerMessage() {
|
||||
override suspend fun text(call: ApplicationCall): String = resolve(call)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun r(key: String, vararg args: Any): ServerMessage = ServerMessage(R(key), args.toList())
|
||||
fun r(key: String, vararg args: Any): ServerMessage = Resource(R(key), args.toList())
|
||||
fun literal(text: String): ServerMessage = Literal(text)
|
||||
fun dynamic(resolve: suspend (ApplicationCall) -> String): ServerMessage = Dynamic(resolve)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -147,7 +147,6 @@ home_footer_github=GitHub
|
|||
home_footer_discord=Discord
|
||||
home_footer_reddit=Reddit
|
||||
home_footer_youtube=YouTube
|
||||
home_footer_patreon=Patreon
|
||||
home_footer_mastodon=Mastodon
|
||||
home_footer_bluesky=Bluesky
|
||||
footer_about=Info
|
||||
|
|
@ -276,52 +275,7 @@ admin_pagination_of=von
|
|||
admin_nav_settings=Einstellungen
|
||||
admin_nav_allowedusers=Allowed Users
|
||||
admin_nav_users=Benutzer
|
||||
admin_nav_patreon=Patreon
|
||||
# Admin - Patreon Integration
|
||||
admin_patreon_title=Patreon-Integration
|
||||
admin_patreon_subtitle=Automatically allow Patreon supporters
|
||||
admin_patreon_status_title=Synchronisationsstatus
|
||||
admin_patreon_enabled_label=Integrationsstatus
|
||||
admin_patreon_enabled=Aktiviert
|
||||
admin_patreon_disabled=Deaktiviert
|
||||
admin_patreon_last_sync=Letzte Synchronisation
|
||||
admin_patreon_members_count=Patreon Members Allowed
|
||||
admin_patreon_members_with_accounts=Mitglieder mit Accounts
|
||||
admin_patreon_sync_now=Jetzt synchronisieren
|
||||
admin_patreon_settings_title=Einstellungen
|
||||
admin_patreon_settings_description=Ändern Sie Ihre Patreon-Integrationseinstellungen. Sie benötigen ein Creator-Zugriffstoken aus Ihren Patreon-Entwicklereinstellungen.
|
||||
admin_patreon_enable_integration=Patreon-Integration aktivieren
|
||||
admin_patreon_campaign_id=Kampagnen-ID
|
||||
admin_patreon_campaign_id_help=Ihre Patreon-Kampagnen-ID – Benutzen Sie die Schaltfläche Abrufen, um diese automatisch zu erkennen
|
||||
admin_patreon_fetch_campaign=Abrufen
|
||||
admin_patreon_error_fetch_failed=Fehler beim Abrufen – Überprüfen Sie das Token
|
||||
admin_patreon_access_token=Creator-Zugriffstoken
|
||||
admin_patreon_access_token_help=Generieren Sie dies in Ihren Patreon Entwicklereinstellungen
|
||||
admin_patreon_access_token_placeholder=Token eingeben (leer lassen, um bestehendes zu belassen)
|
||||
admin_patreon_webhook_secret=Webhook-Secret
|
||||
admin_patreon_webhook_secret_help=Secret zur Überprüfung von Webhook-Anfragen von Patreon
|
||||
admin_patreon_webhook_secret_placeholder=Secret eingeben (leer lassen, um das bestehende zu behalten)
|
||||
admin_patreon_minimum_amount=Mindestbetrag ($)
|
||||
admin_patreon_minimum_amount_help=Minimum pledge amount in dollars to qualify for access
|
||||
admin_patreon_poll_interval=Abrufintervall (Minuten)
|
||||
admin_patreon_poll_interval_help=Wie oft mit Patreon synchronisiert wird (zusätzlich zu Webhooks)
|
||||
admin_patreon_save_button=Einstellungen speichern
|
||||
admin_patreon_webhook_title=Webhook-Konfiguration
|
||||
admin_patreon_webhook_description=Konfigurieren Sie einen Webhook in Ihren Patreon-Entwicklereinstellungen, um Echtzeit-Updates zu erhalten.
|
||||
admin_patreon_webhook_url=Webhook-URL
|
||||
admin_patreon_sync_disabled_hint=Aktivieren Sie zuerst die Patreon-Integration
|
||||
admin_patreon_error_campaign_required=Für die Aktivierung der Patreon-Integration ist eine Kampagnen-ID erforderlich
|
||||
admin_patreon_error_token_required=Für die Aktivierung der Patreon-Integration ist ein Access Token erforderlich
|
||||
admin_patreon_error_not_enabled=Die Patreon-Integration muss vor der Synchronisierung aktiviert werden
|
||||
admin_patreon_url=URL der Patreon-Seite
|
||||
admin_patreon_url_help=Link zu Ihrer Patreon-Seite (Nutzern auf der Anmeldeseite angezeigt)
|
||||
admin_patreon_url_placeholder=https\://www.patreon.com/YourPage
|
||||
admin_patreon_last_sync_never=Nie
|
||||
# Login - Patreon Notice
|
||||
login_patreon_notice_title=Patreon-Abo erforderlich
|
||||
login_patreon_notice_message=Dieser Server erfordert ein aktives Patreon-Abo von ${0}/Monat oder mehr, um ein Konto zu erstellen oder sich anzumelden.
|
||||
login_failure_patreon_notice_message=Dieser Server erfordert ein aktives Patreon-Abo von ${0}/Monat oder mehr, um ein Konto zu erstellen oder sich anzumelden. ${1}
|
||||
login_patreon_notice_link=Auf Patreon abonnieren
|
||||
admin_users_last_sync_never=Nie
|
||||
# Admin - Email Settings
|
||||
admin_nav_email=E-Mail
|
||||
admin_email_title=E-Mail-Einstellungen
|
||||
|
|
|
|||
|
|
@ -147,7 +147,6 @@ home_footer_github=GitHub
|
|||
home_footer_discord=Discord
|
||||
home_footer_reddit=Reddit
|
||||
home_footer_youtube=YouTube
|
||||
home_footer_patreon=Patreon
|
||||
home_footer_mastodon=Mastodon
|
||||
home_footer_bluesky=Bluesky
|
||||
footer_about=About
|
||||
|
|
@ -276,52 +275,7 @@ admin_pagination_of=of
|
|||
admin_nav_settings=Settings
|
||||
admin_nav_allowedusers=Allowed Users
|
||||
admin_nav_users=Users
|
||||
admin_nav_patreon=Patreon
|
||||
# Admin - Patreon Integration
|
||||
admin_patreon_title=Patreon Integration
|
||||
admin_patreon_subtitle=Automatically allow Patreon supporters
|
||||
admin_patreon_status_title=Sync Status
|
||||
admin_patreon_enabled_label=Integration Status
|
||||
admin_patreon_enabled=Enabled
|
||||
admin_patreon_disabled=Disabled
|
||||
admin_patreon_last_sync=Last Sync
|
||||
admin_patreon_members_count=Patreon Members Allowed
|
||||
admin_patreon_members_with_accounts=Members with Accounts
|
||||
admin_patreon_sync_now=Sync Now
|
||||
admin_patreon_settings_title=Configuration
|
||||
admin_patreon_settings_description=Configure your Patreon integration settings. You'll need a Creator Access Token from your Patreon developer settings.
|
||||
admin_patreon_enable_integration=Enable Patreon Integration
|
||||
admin_patreon_campaign_id=Campaign ID
|
||||
admin_patreon_campaign_id_help=Your Patreon campaign ID - use the Fetch button to auto-detect it
|
||||
admin_patreon_fetch_campaign=Fetch
|
||||
admin_patreon_error_fetch_failed=Failed to fetch - check token
|
||||
admin_patreon_access_token=Creator Access Token
|
||||
admin_patreon_access_token_help=Generate this from your Patreon developer settings
|
||||
admin_patreon_access_token_placeholder=Enter token (leave blank to keep existing)
|
||||
admin_patreon_webhook_secret=Webhook Secret
|
||||
admin_patreon_webhook_secret_help=Secret for verifying webhook requests from Patreon
|
||||
admin_patreon_webhook_secret_placeholder=Enter secret (leave blank to keep existing)
|
||||
admin_patreon_minimum_amount=Minimum Amount ($)
|
||||
admin_patreon_minimum_amount_help=Minimum pledge amount in dollars to qualify for access
|
||||
admin_patreon_poll_interval=Poll Interval (minutes)
|
||||
admin_patreon_poll_interval_help=How often to sync with Patreon (in addition to webhooks)
|
||||
admin_patreon_save_button=Save Settings
|
||||
admin_patreon_webhook_title=Webhook Configuration
|
||||
admin_patreon_webhook_description=Configure a webhook in your Patreon developer settings to receive real-time updates.
|
||||
admin_patreon_webhook_url=Webhook URL
|
||||
admin_patreon_sync_disabled_hint=Enable Patreon integration first
|
||||
admin_patreon_error_campaign_required=Campaign ID is required to enable Patreon integration
|
||||
admin_patreon_error_token_required=Access Token is required to enable Patreon integration
|
||||
admin_patreon_error_not_enabled=Patreon integration must be enabled before syncing
|
||||
admin_patreon_url=Patreon Page URL
|
||||
admin_patreon_url_help=Link to your Patreon page (shown to users on the login page)
|
||||
admin_patreon_url_placeholder=https://www.patreon.com/YourPage
|
||||
admin_patreon_last_sync_never=Never
|
||||
# Login - Patreon Notice
|
||||
login_patreon_notice_title=Patreon Subscription Required
|
||||
login_patreon_notice_message=This server requires an active Patreon subscription of ${0}/month or more to create an account or log in.
|
||||
login_failure_patreon_notice_message=This server requires an active Patreon subscription of ${0}/month or more to create an account or log in. ${1}
|
||||
login_patreon_notice_link=Subscribe on Patreon
|
||||
admin_users_last_sync_never=Never
|
||||
# Admin - Email Settings
|
||||
admin_nav_email=Email
|
||||
admin_email_title=Email Settings
|
||||
|
|
|
|||
|
|
@ -147,7 +147,6 @@ home_footer_github=GitHub
|
|||
home_footer_discord=Discord
|
||||
home_footer_reddit=Reddit
|
||||
home_footer_youtube=YouTube
|
||||
home_footer_patreon=Patreon
|
||||
home_footer_mastodon=Mastodon
|
||||
home_footer_bluesky=Bluesky
|
||||
footer_about=Acerca de
|
||||
|
|
@ -276,52 +275,7 @@ admin_pagination_of=de
|
|||
admin_nav_settings=Ajustes
|
||||
admin_nav_allowedusers=Allowed Users
|
||||
admin_nav_users=Usuarios
|
||||
admin_nav_patreon=Patreon
|
||||
# Admin - Patreon Integration
|
||||
admin_patreon_title=Integración con Patreon
|
||||
admin_patreon_subtitle=Automatically allow Patreon supporters
|
||||
admin_patreon_status_title=Estado de la sincronización
|
||||
admin_patreon_enabled_label=Estado de la integración
|
||||
admin_patreon_enabled=Activada
|
||||
admin_patreon_disabled=Desactivada
|
||||
admin_patreon_last_sync=Última sincronización
|
||||
admin_patreon_members_count=Patreon Members Allowed
|
||||
admin_patreon_members_with_accounts=Miembros con cuenta
|
||||
admin_patreon_sync_now=Sincronizar ahora
|
||||
admin_patreon_settings_title=Configuración
|
||||
admin_patreon_settings_description=Configura los ajustes de tu integración con Patreon. Necesitarás un token de acceso de creador de tus ajustes de desarrollador de Patreon.
|
||||
admin_patreon_enable_integration=Activar la integración con Patreon
|
||||
admin_patreon_campaign_id=ID de la campaña
|
||||
admin_patreon_campaign_id_help=El ID de tu campaña de Patreon; usa el botón Obtener para detectarlo automáticamente
|
||||
admin_patreon_fetch_campaign=Obtener
|
||||
admin_patreon_error_fetch_failed=No se pudo obtener; comprueba el token
|
||||
admin_patreon_access_token=Token de acceso de creador
|
||||
admin_patreon_access_token_help=Genéralo desde tus ajustes de desarrollador de Patreon
|
||||
admin_patreon_access_token_placeholder=Introduce el token (déjalo en blanco para conservar el existente)
|
||||
admin_patreon_webhook_secret=Secreto del webhook
|
||||
admin_patreon_webhook_secret_help=Secreto para verificar las solicitudes de webhook de Patreon
|
||||
admin_patreon_webhook_secret_placeholder=Introduce el secreto (déjalo en blanco para conservar el existente)
|
||||
admin_patreon_minimum_amount=Importe mínimo ($)
|
||||
admin_patreon_minimum_amount_help=Minimum pledge amount in dollars to qualify for access
|
||||
admin_patreon_poll_interval=Intervalo de sondeo (minutos)
|
||||
admin_patreon_poll_interval_help=Con qué frecuencia sincronizar con Patreon (además de los webhooks)
|
||||
admin_patreon_save_button=Guardar ajustes
|
||||
admin_patreon_webhook_title=Configuración del webhook
|
||||
admin_patreon_webhook_description=Configura un webhook en tus ajustes de desarrollador de Patreon para recibir actualizaciones en tiempo real.
|
||||
admin_patreon_webhook_url=URL del webhook
|
||||
admin_patreon_sync_disabled_hint=Activa primero la integración con Patreon
|
||||
admin_patreon_error_campaign_required=Se requiere el ID de la campaña para activar la integración con Patreon
|
||||
admin_patreon_error_token_required=Se requiere el token de acceso para activar la integración con Patreon
|
||||
admin_patreon_error_not_enabled=La integración con Patreon debe estar activada antes de sincronizar
|
||||
admin_patreon_url=URL de la página de Patreon
|
||||
admin_patreon_url_help=Enlace a tu página de Patreon (se muestra a los usuarios en la página de inicio de sesión)
|
||||
admin_patreon_url_placeholder=https\://www.patreon.com/YourPage
|
||||
admin_patreon_last_sync_never=Nunca
|
||||
# Login - Patreon Notice
|
||||
login_patreon_notice_title=Se requiere suscripción a Patreon
|
||||
login_patreon_notice_message=Este servidor requiere una suscripción activa a Patreon de ${0}/mes o más para crear una cuenta o iniciar sesión.
|
||||
login_failure_patreon_notice_message=Este servidor requiere una suscripción activa a Patreon de ${0}/mes o más para crear una cuenta o iniciar sesión. ${1}
|
||||
login_patreon_notice_link=Suscribirse en Patreon
|
||||
admin_users_last_sync_never=Nunca
|
||||
# Admin - Email Settings
|
||||
admin_nav_email=Correo
|
||||
admin_email_title=Ajustes de correo
|
||||
|
|
|
|||
|
|
@ -147,7 +147,6 @@ home_footer_github=GitHub
|
|||
home_footer_discord=Discord
|
||||
home_footer_reddit=Reddit
|
||||
home_footer_youtube=YouTube
|
||||
home_footer_patreon=Patreon
|
||||
home_footer_mastodon=Mastodon
|
||||
home_footer_bluesky=Bluesky
|
||||
footer_about=À propos
|
||||
|
|
@ -276,52 +275,7 @@ admin_pagination_of=sur
|
|||
admin_nav_settings=Paramètres
|
||||
admin_nav_allowedusers=Allowed Users
|
||||
admin_nav_users=Utilisateurs
|
||||
admin_nav_patreon=Patreon
|
||||
# Admin - Patreon Integration
|
||||
admin_patreon_title=Intégration Patreon
|
||||
admin_patreon_subtitle=Automatically allow Patreon supporters
|
||||
admin_patreon_status_title=État de la synchronisation
|
||||
admin_patreon_enabled_label=État de l'intégration
|
||||
admin_patreon_enabled=Activée
|
||||
admin_patreon_disabled=Désactivée
|
||||
admin_patreon_last_sync=Dernière synchronisation
|
||||
admin_patreon_members_count=Patreon Members Allowed
|
||||
admin_patreon_members_with_accounts=Membres avec un compte
|
||||
admin_patreon_sync_now=Synchroniser maintenant
|
||||
admin_patreon_settings_title=Configuration
|
||||
admin_patreon_settings_description=Configurez les paramètres de votre intégration Patreon. Vous aurez besoin d'un jeton d'accès créateur depuis vos paramètres de développeur Patreon.
|
||||
admin_patreon_enable_integration=Activer l'intégration Patreon
|
||||
admin_patreon_campaign_id=ID de campagne
|
||||
admin_patreon_campaign_id_help=Votre ID de campagne Patreon — utilisez le bouton Récupérer pour le détecter automatiquement
|
||||
admin_patreon_fetch_campaign=Récupérer
|
||||
admin_patreon_error_fetch_failed=Échec de la récupération — vérifiez le jeton
|
||||
admin_patreon_access_token=Jeton d'accès créateur
|
||||
admin_patreon_access_token_help=Générez-le depuis vos paramètres de développeur Patreon
|
||||
admin_patreon_access_token_placeholder=Saisissez le jeton (laissez vide pour conserver l'existant)
|
||||
admin_patreon_webhook_secret=Secret du webhook
|
||||
admin_patreon_webhook_secret_help=Secret pour vérifier les requêtes webhook de Patreon
|
||||
admin_patreon_webhook_secret_placeholder=Saisissez le secret (laissez vide pour conserver l'existant)
|
||||
admin_patreon_minimum_amount=Montant minimum ($)
|
||||
admin_patreon_minimum_amount_help=Minimum pledge amount in dollars to qualify for access
|
||||
admin_patreon_poll_interval=Intervalle d'interrogation (minutes)
|
||||
admin_patreon_poll_interval_help=Fréquence de synchronisation avec Patreon (en plus des webhooks)
|
||||
admin_patreon_save_button=Enregistrer les paramètres
|
||||
admin_patreon_webhook_title=Configuration du webhook
|
||||
admin_patreon_webhook_description=Configurez un webhook dans vos paramètres de développeur Patreon pour recevoir des mises à jour en temps réel.
|
||||
admin_patreon_webhook_url=URL du webhook
|
||||
admin_patreon_sync_disabled_hint=Activez d'abord l'intégration Patreon
|
||||
admin_patreon_error_campaign_required=L'ID de campagne est requis pour activer l'intégration Patreon
|
||||
admin_patreon_error_token_required=Le jeton d'accès est requis pour activer l'intégration Patreon
|
||||
admin_patreon_error_not_enabled=L'intégration Patreon doit être activée avant la synchronisation
|
||||
admin_patreon_url=URL de la page Patreon
|
||||
admin_patreon_url_help=Lien vers votre page Patreon (affiché aux utilisateurs sur la page de connexion)
|
||||
admin_patreon_url_placeholder=https\://www.patreon.com/VotrePage
|
||||
admin_patreon_last_sync_never=Jamais
|
||||
# Login - Patreon Notice
|
||||
login_patreon_notice_title=Abonnement Patreon requis
|
||||
login_patreon_notice_message=Ce serveur nécessite un abonnement Patreon actif de ${0}/mois ou plus pour créer un compte ou se connecter.
|
||||
login_failure_patreon_notice_message=Ce serveur nécessite un abonnement Patreon actif de ${0}/mois ou plus pour créer un compte ou se connecter. ${1}
|
||||
login_patreon_notice_link=S'abonner sur Patreon
|
||||
admin_users_last_sync_never=Jamais
|
||||
# Admin - Email Settings
|
||||
admin_nav_email=E-mail
|
||||
admin_email_title=Paramètres de messagerie
|
||||
|
|
|
|||
|
|
@ -147,7 +147,6 @@ home_footer_github=GitHub
|
|||
home_footer_discord=Discord
|
||||
home_footer_reddit=Reddit
|
||||
home_footer_youtube=YouTube
|
||||
home_footer_patreon=Patreon
|
||||
home_footer_mastodon=Mastodon
|
||||
home_footer_bluesky=Bluesky
|
||||
footer_about=Informazioni
|
||||
|
|
@ -276,52 +275,7 @@ admin_pagination_of=di
|
|||
admin_nav_settings=Impostazioni
|
||||
admin_nav_allowedusers=Allowed Users
|
||||
admin_nav_users=Utenti
|
||||
admin_nav_patreon=Patreon
|
||||
# Admin - Patreon Integration
|
||||
admin_patreon_title=Integrazione con Patreon
|
||||
admin_patreon_subtitle=Automatically allow Patreon supporters
|
||||
admin_patreon_status_title=Stato della sincronizzazione
|
||||
admin_patreon_enabled_label=Stato dell’integrazione
|
||||
admin_patreon_enabled=Attivata
|
||||
admin_patreon_disabled=Disattivata
|
||||
admin_patreon_last_sync=Ultima sincronizzazione
|
||||
admin_patreon_members_count=Patreon Members Allowed
|
||||
admin_patreon_members_with_accounts=Membri con un account
|
||||
admin_patreon_sync_now=Sincronizza ora
|
||||
admin_patreon_settings_title=Configurazione
|
||||
admin_patreon_settings_description=Configura le impostazioni della tua integrazione con Patreon. Ti servirà un token di accesso creatore dalle tue impostazioni per sviluppatori di Patreon.
|
||||
admin_patreon_enable_integration=Attiva l’integrazione con Patreon
|
||||
admin_patreon_campaign_id=ID della campagna
|
||||
admin_patreon_campaign_id_help=L’ID della tua campagna Patreon\: usa il pulsante Recupera per rilevarlo automaticamente
|
||||
admin_patreon_fetch_campaign=Recupera
|
||||
admin_patreon_error_fetch_failed=Recupero non riuscito\: controlla il token
|
||||
admin_patreon_access_token=Token di accesso creatore
|
||||
admin_patreon_access_token_help=Generalo dalle tue impostazioni per sviluppatori di Patreon
|
||||
admin_patreon_access_token_placeholder=Inserisci il token (lascia vuoto per mantenere quello esistente)
|
||||
admin_patreon_webhook_secret=Secret del webhook
|
||||
admin_patreon_webhook_secret_help=Secret per verificare le richieste webhook da Patreon
|
||||
admin_patreon_webhook_secret_placeholder=Inserisci il secret (lascia vuoto per mantenere quello esistente)
|
||||
admin_patreon_minimum_amount=Importo minimo ($)
|
||||
admin_patreon_minimum_amount_help=Minimum pledge amount in dollars to qualify for access
|
||||
admin_patreon_poll_interval=Intervallo di polling (minuti)
|
||||
admin_patreon_poll_interval_help=Con quale frequenza sincronizzare con Patreon (oltre ai webhook)
|
||||
admin_patreon_save_button=Salva le impostazioni
|
||||
admin_patreon_webhook_title=Configurazione del webhook
|
||||
admin_patreon_webhook_description=Configura un webhook nelle tue impostazioni per sviluppatori di Patreon per ricevere aggiornamenti in tempo reale.
|
||||
admin_patreon_webhook_url=URL del webhook
|
||||
admin_patreon_sync_disabled_hint=Attiva prima l’integrazione con Patreon
|
||||
admin_patreon_error_campaign_required=L’ID della campagna è obbligatorio per attivare l’integrazione con Patreon
|
||||
admin_patreon_error_token_required=Il token di accesso è obbligatorio per attivare l’integrazione con Patreon
|
||||
admin_patreon_error_not_enabled=L’integrazione con Patreon deve essere attivata prima di sincronizzare
|
||||
admin_patreon_url=URL della pagina Patreon
|
||||
admin_patreon_url_help=Link alla tua pagina Patreon (mostrato agli utenti nella pagina di accesso)
|
||||
admin_patreon_url_placeholder=https\://www.patreon.com/YourPage
|
||||
admin_patreon_last_sync_never=Mai
|
||||
# Login - Patreon Notice
|
||||
login_patreon_notice_title=Abbonamento a Patreon richiesto
|
||||
login_patreon_notice_message=Questo server richiede un abbonamento a Patreon attivo di ${0}/mese o più per creare un account o accedere.
|
||||
login_failure_patreon_notice_message=Questo server richiede un abbonamento a Patreon attivo di ${0}/mese o più per creare un account o accedere. ${1}
|
||||
login_patreon_notice_link=Abbonati su Patreon
|
||||
admin_users_last_sync_never=Mai
|
||||
# Admin - Email Settings
|
||||
admin_nav_email=E-mail
|
||||
admin_email_title=Impostazioni e-mail
|
||||
|
|
|
|||
|
|
@ -147,7 +147,6 @@ home_footer_github=GitHub
|
|||
home_footer_discord=Discord
|
||||
home_footer_reddit=Reddit
|
||||
home_footer_youtube=YouTube
|
||||
home_footer_patreon=Patreon
|
||||
home_footer_mastodon=Mastodon
|
||||
home_footer_bluesky=Bluesky
|
||||
footer_about=Sobre
|
||||
|
|
@ -276,52 +275,7 @@ admin_pagination_of=de
|
|||
admin_nav_settings=Configurações
|
||||
admin_nav_allowedusers=Allowed Users
|
||||
admin_nav_users=Usuários
|
||||
admin_nav_patreon=Patreon
|
||||
# Admin - Patreon Integration
|
||||
admin_patreon_title=Integração com o Patreon
|
||||
admin_patreon_subtitle=Automatically allow Patreon supporters
|
||||
admin_patreon_status_title=Status da Sincronização
|
||||
admin_patreon_enabled_label=Status da Integração
|
||||
admin_patreon_enabled=Habilitado
|
||||
admin_patreon_disabled=Desabilitado
|
||||
admin_patreon_last_sync=Última Sincronização
|
||||
admin_patreon_members_count=Patreon Members Allowed
|
||||
admin_patreon_members_with_accounts=Membros com Contas
|
||||
admin_patreon_sync_now=Sincronizar Agora
|
||||
admin_patreon_settings_title=Configuração
|
||||
admin_patreon_settings_description=Gerencie as suas configurações de integração com o Patreon. Você precisará de um Token de Acesso de Criador, que pode ser obtido nas suas configurações de desenvolvedor do Patreon.
|
||||
admin_patreon_enable_integration=Habilitar Integração com o Patreon
|
||||
admin_patreon_campaign_id=Campaign ID
|
||||
admin_patreon_campaign_id_help=O ID da sua campanha do Patreon - use o botão Buscar para detectá-lo automaticamente
|
||||
admin_patreon_fetch_campaign=Buscar
|
||||
admin_patreon_error_fetch_failed=Falha ao buscar - token de verificação
|
||||
admin_patreon_access_token=Token de Acesso do Criador
|
||||
admin_patreon_access_token_help=Gere isto nas suas configurações de desenvolvedor do Patreon
|
||||
admin_patreon_access_token_placeholder=Insira o token (deixe em branco para manter o atual)
|
||||
admin_patreon_webhook_secret=Segredo do Webhook
|
||||
admin_patreon_webhook_secret_help=Segredo para verificar as requisições de webhook do Patreon
|
||||
admin_patreon_webhook_secret_placeholder=Insira o segredo (deixe em branco para manter o atual)
|
||||
admin_patreon_minimum_amount=Valor Mínimo ($)
|
||||
admin_patreon_minimum_amount_help=Minimum pledge amount in dollars to qualify for access
|
||||
admin_patreon_poll_interval=Intervalo de Verificação (minutos)
|
||||
admin_patreon_poll_interval_help=Com que frequência sincronizar com o Patreon (além dos webhooks)
|
||||
admin_patreon_save_button=Salvar Configurações
|
||||
admin_patreon_webhook_title=Configuração de Webhook
|
||||
admin_patreon_webhook_description=Configure um webhook nas suas configurações de desenvolvedor do Patreon para receber atualizações em tempo real.
|
||||
admin_patreon_webhook_url=URL do Webhook
|
||||
admin_patreon_sync_disabled_hint=Habilite a integração com o Patreon primeiro
|
||||
admin_patreon_error_campaign_required=O Campaign ID é obrigatório para habilitar a integração com o Patreon
|
||||
admin_patreon_error_token_required=O Token de Acesso é obrigatório para habilitar a integração com o Patreon
|
||||
admin_patreon_error_not_enabled=A integração com o Patreon deve estar habilitada antes de sincronizar
|
||||
admin_patreon_url=URL da Página do Patreon
|
||||
admin_patreon_url_help=Link para a sua página do Patreon (mostrado aos usuários na página de login)
|
||||
admin_patreon_url_placeholder=https\://www.patreon.com/YourPage
|
||||
admin_patreon_last_sync_never=Nunca
|
||||
# Login - Patreon Notice
|
||||
login_patreon_notice_title=Assinatura do Patreon Necessária
|
||||
login_patreon_notice_message=Este servidor requer uma assinatura ativa do Patreon de ${0}/mês ou mais para criar uma conta ou fazer login.
|
||||
login_failure_patreon_notice_message=Este servidor requer uma assinatura ativa do Patreon de ${0}/mês ou mais para criar uma conta ou fazer login. ${1}
|
||||
login_patreon_notice_link=Assinar no Patreon
|
||||
admin_users_last_sync_never=Nunca
|
||||
# Admin - Email Settings
|
||||
admin_nav_email=E-mail
|
||||
admin_email_title=Configurações de E-mail
|
||||
|
|
|
|||
|
|
@ -147,7 +147,6 @@ home_footer_github=GitHub
|
|||
home_footer_discord=Discord
|
||||
home_footer_reddit=Reddit
|
||||
home_footer_youtube=YouTube
|
||||
home_footer_patreon=Patreon
|
||||
home_footer_mastodon=Mastodon
|
||||
home_footer_bluesky=Bluesky
|
||||
footer_about=Про застосунок
|
||||
|
|
@ -276,52 +275,7 @@ admin_pagination_of=з
|
|||
admin_nav_settings=Налаштування
|
||||
admin_nav_allowedusers=Allowed Users
|
||||
admin_nav_users=Користувачі
|
||||
admin_nav_patreon=Patreon
|
||||
# Admin - Patreon Integration
|
||||
admin_patreon_title=Інтеграція з Patreon
|
||||
admin_patreon_subtitle=Automatically allow Patreon supporters
|
||||
admin_patreon_status_title=Стан синхронізації
|
||||
admin_patreon_enabled_label=Стан інтеграції
|
||||
admin_patreon_enabled=Увімкнено
|
||||
admin_patreon_disabled=Вимкнено
|
||||
admin_patreon_last_sync=Остання синхронізація
|
||||
admin_patreon_members_count=Patreon Members Allowed
|
||||
admin_patreon_members_with_accounts=Учасники з обліковими записами
|
||||
admin_patreon_sync_now=Синхронізувати зараз
|
||||
admin_patreon_settings_title=Конфігурація
|
||||
admin_patreon_settings_description=Налаштуйте параметри інтеграції з Patreon. Вам знадобиться токен доступу автора з налаштувань розробника Patreon.
|
||||
admin_patreon_enable_integration=Увімкнути інтеграцію з Patreon
|
||||
admin_patreon_campaign_id=ID кампанії
|
||||
admin_patreon_campaign_id_help=Ваш ID кампанії Patreon — скористайтеся кнопкою «Отримати», щоб визначити його автоматично
|
||||
admin_patreon_fetch_campaign=Отримати
|
||||
admin_patreon_error_fetch_failed=Не вдалося отримати — перевірте токен
|
||||
admin_patreon_access_token=Токен доступу автора
|
||||
admin_patreon_access_token_help=Згенеруйте його в налаштуваннях розробника Patreon
|
||||
admin_patreon_access_token_placeholder=Введіть токен (залиште порожнім, щоб зберегти наявний)
|
||||
admin_patreon_webhook_secret=Секрет вебхука
|
||||
admin_patreon_webhook_secret_help=Секрет для перевірки запитів вебхука від Patreon
|
||||
admin_patreon_webhook_secret_placeholder=Введіть секрет (залиште порожнім, щоб зберегти наявний)
|
||||
admin_patreon_minimum_amount=Мінімальна сума ($)
|
||||
admin_patreon_minimum_amount_help=Minimum pledge amount in dollars to qualify for access
|
||||
admin_patreon_poll_interval=Інтервал опитування (хвилини)
|
||||
admin_patreon_poll_interval_help=Як часто синхронізуватися з Patreon (на додачу до вебхуків)
|
||||
admin_patreon_save_button=Зберегти налаштування
|
||||
admin_patreon_webhook_title=Конфігурація вебхука
|
||||
admin_patreon_webhook_description=Налаштуйте вебхук у налаштуваннях розробника Patreon, щоб отримувати оновлення в реальному часі.
|
||||
admin_patreon_webhook_url=URL вебхука
|
||||
admin_patreon_sync_disabled_hint=Спершу увімкніть інтеграцію з Patreon
|
||||
admin_patreon_error_campaign_required=Для ввімкнення інтеграції з Patreon потрібен ID кампанії
|
||||
admin_patreon_error_token_required=Для ввімкнення інтеграції з Patreon потрібен токен доступу
|
||||
admin_patreon_error_not_enabled=Перед синхронізацією потрібно ввімкнути інтеграцію з Patreon
|
||||
admin_patreon_url=URL сторінки Patreon
|
||||
admin_patreon_url_help=Посилання на вашу сторінку Patreon (показується користувачам на сторінці входу)
|
||||
admin_patreon_url_placeholder=https\://www.patreon.com/YourPage
|
||||
admin_patreon_last_sync_never=Ніколи
|
||||
# Login - Patreon Notice
|
||||
login_patreon_notice_title=Потрібна підписка Patreon
|
||||
login_patreon_notice_message=Цей сервер вимагає активної підписки Patreon на ${0}/місяць або більше для створення облікового запису чи входу.
|
||||
login_failure_patreon_notice_message=Цей сервер вимагає активної підписки Patreon на ${0}/місяць або більше для створення облікового запису чи входу. ${1}
|
||||
login_patreon_notice_link=Підписатися на Patreon
|
||||
admin_users_last_sync_never=Ніколи
|
||||
# Admin - Email Settings
|
||||
admin_nav_email=Ел. пошта
|
||||
admin_email_title=Налаштування ел. пошти
|
||||
|
|
|
|||
|
|
@ -1,180 +0,0 @@
|
|||
{{> header}}
|
||||
|
||||
<main class="page">
|
||||
<div class="admin-layout">
|
||||
{{> partials/admin-nav}}
|
||||
|
||||
<div class="admin-content">
|
||||
<!-- Admin Header -->
|
||||
<div class="page-header">
|
||||
<div class="page-header__content">
|
||||
<h1 class="page-header__title">{{msg.admin_patreon_title}}</h1>
|
||||
<p class="page-header__subtitle">{{msg.admin_patreon_subtitle}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Patreon Status Section -->
|
||||
<section class="paper-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-header__title">{{msg.admin_patreon_status_title}}</h2>
|
||||
</div>
|
||||
|
||||
<div class="status-info">
|
||||
<div class="status-item">
|
||||
<span class="status-label">{{msg.admin_patreon_enabled_label}}:</span>
|
||||
{{#patreonEnabled}}
|
||||
<span class="status-badge status-badge--active">{{msg.admin_patreon_enabled}}</span>
|
||||
{{/patreonEnabled}}
|
||||
{{^patreonEnabled}}
|
||||
<span class="status-badge status-badge--inactive">{{msg.admin_patreon_disabled}}</span>
|
||||
{{/patreonEnabled}}
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">{{msg.admin_patreon_last_sync}}:</span>
|
||||
<span>{{lastSync}}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">{{msg.admin_patreon_members_count}}:</span>
|
||||
<span>{{patreonMemberCount}}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">{{msg.admin_patreon_members_with_accounts}}:</span>
|
||||
<span>{{patreonMembersWithAccounts}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
{{#patreonEnabled}}
|
||||
<button type="button" class="btn btn--primary"
|
||||
hx-post="/admin/patreon/sync"
|
||||
hx-swap="none">
|
||||
{{msg.admin_patreon_sync_now}}
|
||||
</button>
|
||||
{{/patreonEnabled}}
|
||||
{{^patreonEnabled}}
|
||||
<button type="button" class="btn btn--primary" disabled
|
||||
title="{{msg.admin_patreon_sync_disabled_hint}}">
|
||||
{{msg.admin_patreon_sync_now}}
|
||||
</button>
|
||||
{{/patreonEnabled}}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Patreon Settings Section -->
|
||||
<section class="paper-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-header__title">{{msg.admin_patreon_settings_title}}</h2>
|
||||
</div>
|
||||
<p class="section-description">{{msg.admin_patreon_settings_description}}</p>
|
||||
|
||||
<form action="/admin/patreon/settings" method="post" class="settings-form"
|
||||
hx-post="/admin/patreon/settings"
|
||||
hx-swap="none">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-field__label">
|
||||
<input type="checkbox" name="enabled" value="true"
|
||||
{{#patreonEnabled}}checked{{/patreonEnabled}} class="form-checkbox"/>
|
||||
{{msg.admin_patreon_enable_integration}}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="accessToken" class="form-field__label">{{msg.admin_patreon_access_token}}</label>
|
||||
<p class="form-field__help">{{msg.admin_patreon_access_token_help}}</p>
|
||||
<div class="form-input-group">
|
||||
<input id="accessToken" name="accessToken" type="password" value="{{accessToken}}"
|
||||
class="form-input"
|
||||
placeholder="{{msg.admin_patreon_access_token_placeholder}}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="campaignId" class="form-field__label">{{msg.admin_patreon_campaign_id}}</label>
|
||||
<p class="form-field__help">{{msg.admin_patreon_campaign_id_help}}</p>
|
||||
<div class="form-input-group">
|
||||
<div id="campaignId-container">
|
||||
<input id="campaignId" name="campaignId" type="text" value="{{campaignId}}"
|
||||
class="form-input"
|
||||
placeholder="123456"/>
|
||||
</div>
|
||||
<button type="button" class="btn btn--ghost"
|
||||
hx-post="/admin/patreon/fetch-campaign"
|
||||
hx-include="#accessToken"
|
||||
hx-target="#campaignId-container"
|
||||
hx-swap="innerHTML">
|
||||
{{msg.admin_patreon_fetch_campaign}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="webhookSecret"
|
||||
class="form-field__label">{{msg.admin_patreon_webhook_secret}}</label>
|
||||
<p class="form-field__help">{{msg.admin_patreon_webhook_secret_help}}</p>
|
||||
<div class="form-input-group">
|
||||
<input id="webhookSecret" name="webhookSecret" type="password" value="{{webhookSecret}}"
|
||||
class="form-input"
|
||||
placeholder="{{msg.admin_patreon_webhook_secret_placeholder}}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="patreonUrl"
|
||||
class="form-field__label">{{msg.admin_patreon_url}}</label>
|
||||
<p class="form-field__help">{{msg.admin_patreon_url_help}}</p>
|
||||
<div class="form-input-group">
|
||||
<input id="patreonUrl" name="patreonUrl" type="url" value="{{patreonUrl}}"
|
||||
class="form-input"
|
||||
placeholder="{{msg.admin_patreon_url_placeholder}}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="minimumAmount"
|
||||
class="form-field__label">{{msg.admin_patreon_minimum_amount}}</label>
|
||||
<p class="form-field__help">{{msg.admin_patreon_minimum_amount_help}}</p>
|
||||
<div class="form-input-group">
|
||||
<input id="minimumAmount" name="minimumAmount" type="number" step="0.01" min="0"
|
||||
value="{{minimumAmountDollars}}" class="form-input"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="pollInterval" class="form-field__label">{{msg.admin_patreon_poll_interval}}</label>
|
||||
<p class="form-field__help">{{msg.admin_patreon_poll_interval_help}}</p>
|
||||
<div class="form-input-group">
|
||||
<input id="pollInterval" name="pollInterval" type="number" min="1"
|
||||
value="{{pollIntervalMinutes}}" class="form-input"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn--accent">{{msg.admin_patreon_save_button}}</button>
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
<div id="patreon-error"></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Webhook Info Section -->
|
||||
<section class="paper-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-header__title">{{msg.admin_patreon_webhook_title}}</h2>
|
||||
</div>
|
||||
<p class="section-description">{{msg.admin_patreon_webhook_description}}</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-field__label">{{msg.admin_patreon_webhook_url}}</label>
|
||||
<div class="form-input-group">
|
||||
<input type="text" readonly value="{{webhookUrl}}" class="form-input"/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="result"></div>
|
||||
{{> footer}}
|
||||
|
|
@ -48,11 +48,9 @@
|
|||
<a href="https://bsky.app/profile/darkrockstudios.bsky.social" target="_blank" rel="noopener noreferrer" aria-label="{{msg.home_footer_bluesky}}">
|
||||
<i class="fa-brands fa-bluesky"></i>
|
||||
</a>
|
||||
{{#patreonUrl}}
|
||||
<a href="{{patreonUrl}}" target="_blank" rel="noopener noreferrer" aria-label="{{msg.home_footer_patreon}}">
|
||||
<i class="fa-brands fa-patreon"></i>
|
||||
</a>
|
||||
{{/patreonUrl}}
|
||||
{{#footerNoticeHtml}}
|
||||
{{{footerNoticeHtml}}}
|
||||
{{/footerNoticeHtml}}
|
||||
</div>
|
||||
<form action="/locale" method="post" class="language-selector">
|
||||
<label for="lang-select">Language:</label>
|
||||
|
|
|
|||
|
|
@ -37,17 +37,8 @@
|
|||
<a href="/forgot-password">{{msg.password_reset_forgot_link}}</a>
|
||||
</p>
|
||||
<p class="login-info-text">{{msg.login_signup_prompt}} <a href="/signup">{{msg.login_signup_link}}</a></p>
|
||||
{{#patreonEnabled}}
|
||||
<div class="login-info-patreon">
|
||||
<h3 class="login-info-patreon__title">{{msg.login_patreon_notice_title}}</h3>
|
||||
<p>{{patreonMessage}}</p>
|
||||
<a href="{{patreonUrl}}" target="_blank" rel="noopener noreferrer"
|
||||
class="login-info-patreon__link">
|
||||
{{msg.login_patreon_notice_link}} →
|
||||
</a>
|
||||
</div>
|
||||
{{/patreonEnabled}}
|
||||
{{^patreonEnabled}}
|
||||
{{{allowedUsersNoticeHtml}}}
|
||||
{{^allowedUsersNoticeProvided}}
|
||||
<div class="login-info-whitelist">
|
||||
<p>{{msg.login_info_allowedusers_notice}}</p>
|
||||
{{#contactEmail}}
|
||||
|
|
@ -55,7 +46,7 @@
|
|||
href="mailto:{{contactEmail}}">{{contactEmail}}</a></p>
|
||||
{{/contactEmail}}
|
||||
</div>
|
||||
{{/patreonEnabled}}
|
||||
{{/allowedUsersNoticeProvided}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,14 +24,14 @@
|
|||
<span>{{msg.admin_nav_monitoring}}</span>
|
||||
</a>
|
||||
</li>
|
||||
{{#patreonFeatureEnabled}}
|
||||
{{#pluginNavEntries}}
|
||||
<li class="admin-nav-item">
|
||||
<a href="/admin/patreon" class="admin-nav-link {{#activePatreon}}active{{/activePatreon}}">
|
||||
<i class="fa-brands fa-patreon"></i>
|
||||
<span>{{msg.admin_nav_patreon}}</span>
|
||||
<a href="{{href}}" class="admin-nav-link {{#active}}active{{/active}}">
|
||||
<i class="{{icon}}"></i>
|
||||
<span>{{label}}</span>
|
||||
</a>
|
||||
</li>
|
||||
{{/patreonFeatureEnabled}}
|
||||
{{/pluginNavEntries}}
|
||||
{{#emailFeatureEnabled}}
|
||||
<li class="admin-nav-item">
|
||||
<a href="/admin/email" class="admin-nav-link {{#activeEmail}}active{{/activeEmail}}">
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@
|
|||
<i class="fa-solid fa-pen"></i>
|
||||
{{msg.admin_allowedusers_edit_button}}
|
||||
</button>
|
||||
{{^isPatreon}}
|
||||
{{^sourceManaged}}
|
||||
<button type="button" class="btn btn--ghost edit-expiry-btn"
|
||||
data-email="{{email}}"
|
||||
data-expires="{{expires}}"
|
||||
|
|
@ -92,7 +92,7 @@
|
|||
<i class="fa-solid fa-hourglass-half"></i>
|
||||
{{msg.admin_allowedusers_edit_expiry_button}}
|
||||
</button>
|
||||
{{/isPatreon}}
|
||||
{{/sourceManaged}}
|
||||
<form action="/admin/allowed-users/remove" method="post"
|
||||
hx-post="/admin/allowed-users/remove"
|
||||
hx-target="#whitelist"
|
||||
|
|
|
|||
|
|
@ -11,5 +11,10 @@
|
|||
<p>{{msg.home_servermessage_allowedusers}}</p>
|
||||
</div>
|
||||
{{/allowedUsersNotice}}
|
||||
{{#allowedUsersBannerHtml}}
|
||||
<div class="instance-band__inner instance-band__inner--whitelist">
|
||||
{{{allowedUsersBannerHtml}}}
|
||||
</div>
|
||||
{{/allowedUsersBannerHtml}}
|
||||
</div>
|
||||
{{/hasInstanceNotice}}
|
||||
|
|
|
|||
|
|
@ -52,13 +52,16 @@
|
|||
|
||||
<div class="login-info">
|
||||
<p class="login-info-text">{{msg.signup_login_prompt}} <a href="/login">{{msg.signup_login_link}}</a></p>
|
||||
<div class="login-info-whitelist">
|
||||
<p>{{msg.login_info_allowedusers_notice}}</p>
|
||||
{{#contactEmail}}
|
||||
<p>{{msg.login_info_allowedusers_contact}} <a
|
||||
href="mailto:{{contactEmail}}">{{contactEmail}}</a></p>
|
||||
{{/contactEmail}}
|
||||
</div>
|
||||
{{{allowedUsersNoticeHtml}}}
|
||||
{{^allowedUsersNoticeProvided}}
|
||||
<div class="login-info-whitelist">
|
||||
<p>{{msg.login_info_allowedusers_notice}}</p>
|
||||
{{#contactEmail}}
|
||||
<p>{{msg.login_info_allowedusers_contact}} <a
|
||||
href="mailto:{{contactEmail}}">{{contactEmail}}</a></p>
|
||||
{{/contactEmail}}
|
||||
</div>
|
||||
{{/allowedUsersNoticeProvided}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.darkrockstudios.apps.hammer.base.http.TermsOfServiceChallenge
|
|||
import com.darkrockstudios.apps.hammer.base.http.Token
|
||||
import com.darkrockstudios.apps.hammer.projects.ProjectsRepository
|
||||
import com.darkrockstudios.apps.hammer.utilities.SResult
|
||||
import com.darkrockstudios.apps.hammer.plugin.PluginRegistry
|
||||
import io.mockk.MockKAnnotations
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
|
|
@ -51,9 +52,8 @@ class AccountsComponentCreateAccountTest {
|
|||
accountsRepository,
|
||||
whiteListRepository,
|
||||
projectsRepository,
|
||||
configRepository,
|
||||
termsOfServiceRepository,
|
||||
serverConfig
|
||||
PluginRegistry(emptyList())
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import com.darkrockstudios.apps.hammer.projects.ProjectsRepository
|
|||
import com.darkrockstudios.apps.hammer.utilities.SResult
|
||||
import com.darkrockstudios.apps.hammer.utilities.isFailure
|
||||
import com.darkrockstudios.apps.hammer.utilities.isSuccess
|
||||
import com.darkrockstudios.apps.hammer.plugin.PluginRegistry
|
||||
import io.mockk.MockKAnnotations
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
|
|
@ -83,9 +84,8 @@ class AccountsComponentLoginTest {
|
|||
accountsRepository,
|
||||
whiteListRepository,
|
||||
projectsRepository,
|
||||
configRepository,
|
||||
termsOfServiceRepository,
|
||||
serverConfig
|
||||
PluginRegistry(emptyList())
|
||||
)
|
||||
val result = comp.login(validEmail, validPassword, installId)
|
||||
|
||||
|
|
@ -109,9 +109,8 @@ class AccountsComponentLoginTest {
|
|||
accountsRepository,
|
||||
whiteListRepository,
|
||||
projectsRepository,
|
||||
configRepository,
|
||||
termsOfServiceRepository,
|
||||
serverConfig
|
||||
PluginRegistry(emptyList())
|
||||
)
|
||||
val result = comp.login(validEmail, validPassword, installId)
|
||||
|
||||
|
|
@ -136,9 +135,8 @@ class AccountsComponentLoginTest {
|
|||
accountsRepository,
|
||||
whiteListRepository,
|
||||
projectsRepository,
|
||||
configRepository,
|
||||
termsOfServiceRepository,
|
||||
serverConfig
|
||||
PluginRegistry(emptyList())
|
||||
)
|
||||
val result = comp.login(validEmail, validPassword, installId)
|
||||
|
||||
|
|
@ -156,9 +154,8 @@ class AccountsComponentLoginTest {
|
|||
accountsRepository,
|
||||
whiteListRepository,
|
||||
projectsRepository,
|
||||
configRepository,
|
||||
termsOfServiceRepository,
|
||||
serverConfig
|
||||
PluginRegistry(emptyList())
|
||||
)
|
||||
|
||||
val result = comp.refreshToken(unknownUserId, installId, "bad-refresh")
|
||||
|
|
@ -175,9 +172,8 @@ class AccountsComponentLoginTest {
|
|||
accountsRepository,
|
||||
whiteListRepository,
|
||||
projectsRepository,
|
||||
configRepository,
|
||||
termsOfServiceRepository,
|
||||
serverConfig
|
||||
PluginRegistry(emptyList())
|
||||
)
|
||||
|
||||
val result = comp.refreshToken(token.userId, installId, "some-refresh")
|
||||
|
|
@ -204,9 +200,8 @@ class AccountsComponentLoginTest {
|
|||
accountsRepository,
|
||||
whiteListRepository,
|
||||
projectsRepository,
|
||||
configRepository,
|
||||
termsOfServiceRepository,
|
||||
serverConfig
|
||||
PluginRegistry(emptyList())
|
||||
)
|
||||
val result = comp.login(validEmail, validPassword, installId)
|
||||
|
||||
|
|
|
|||
|
|
@ -204,8 +204,8 @@ class AccountsRepositoryTest : BaseTest() {
|
|||
assertTrue(isFailure(wrongPassword))
|
||||
assertTrue(isFailure(unknownAccount))
|
||||
assertEquals(
|
||||
wrongPassword.displayMessage?.r?.joinToString("|"),
|
||||
unknownAccount.displayMessage?.r?.joinToString("|"),
|
||||
(wrongPassword.displayMessage as? ServerMessage.Resource)?.r?.joinToString("|"),
|
||||
(unknownAccount.displayMessage as? ServerMessage.Resource)?.r?.joinToString("|"),
|
||||
"Login must not reveal whether the account exists",
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@ package com.darkrockstudios.apps.hammer.e2e
|
|||
import com.darkrockstudios.apps.hammer.e2e.util.E2eTestData
|
||||
import com.darkrockstudios.apps.hammer.e2e.util.EndToEndTest
|
||||
import com.darkrockstudios.apps.hammer.e2e.util.TestAccount
|
||||
import com.darkrockstudios.apps.hammer.patreon.PatreonSyncService
|
||||
import com.darkrockstudios.apps.hammer.plugin.AllowedUsersSource
|
||||
import com.darkrockstudios.apps.hammer.plugin.NoticeSlot
|
||||
import com.darkrockstudios.apps.hammer.plugin.ServerPlugin
|
||||
import com.darkrockstudios.apps.hammer.utilities.Msg
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.cookies.HttpCookies
|
||||
import io.ktor.client.request.get
|
||||
|
|
@ -32,6 +36,19 @@ import kotlin.time.Clock
|
|||
*/
|
||||
class AdminWhitelistExpiryPageTest : EndToEndTest() {
|
||||
|
||||
// A minimal syncing source, standing in for integrations that own their whitelist entries.
|
||||
private class TestSource : AllowedUsersSource {
|
||||
override val id = SOURCE_REASON
|
||||
override suspend fun isActive() = true
|
||||
override suspend fun notice(call: ApplicationCall, slot: NoticeSlot): String? = null
|
||||
override suspend fun rejectionMessage(): Msg? = null
|
||||
}
|
||||
|
||||
override val serverPlugins = super.serverPlugins + object : ServerPlugin {
|
||||
override val id = SOURCE_REASON
|
||||
override fun allowedUsersSource(): AllowedUsersSource = TestSource()
|
||||
}
|
||||
|
||||
private val email = "admin@test.com"
|
||||
private val password = "password123!@#"
|
||||
|
||||
|
|
@ -247,14 +264,14 @@ class AdminWhitelistExpiryPageTest : EndToEndTest() {
|
|||
.single { it.contains(target) }
|
||||
|
||||
@Test
|
||||
fun `patreon entries render without an expiry control but others keep one`(): Unit = runBlocking {
|
||||
fun `source-managed entries render without an expiry control but others keep one`(): Unit = runBlocking {
|
||||
doStartServer()
|
||||
seed()
|
||||
|
||||
database().serverDatabase.whiteListQueries.addToWhiteList(
|
||||
"patron@example.com",
|
||||
Clock.System.now(),
|
||||
PatreonSyncService.WHITELIST_REASON,
|
||||
SOURCE_REASON,
|
||||
null,
|
||||
)
|
||||
|
||||
|
|
@ -266,7 +283,7 @@ class AdminWhitelistExpiryPageTest : EndToEndTest() {
|
|||
assertContains(body, "patron@example.com")
|
||||
assertFalse(
|
||||
rowFor(body, "patron@example.com").contains("edit-expiry-btn"),
|
||||
"Patreon-owned entries must not offer an expiry control",
|
||||
"Source-owned entries must not offer an expiry control",
|
||||
)
|
||||
assertContains(
|
||||
rowFor(body, email),
|
||||
|
|
@ -416,4 +433,8 @@ class AdminWhitelistExpiryPageTest : EndToEndTest() {
|
|||
assertContains(page.bodyAsText(), "Allowed Users")
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SOURCE_REASON = "TestSync"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package com.darkrockstudios.apps.hammer.frontend
|
|||
|
||||
import com.darkrockstudios.apps.hammer.frontend.utils.SYNC_DATE_PATTERN
|
||||
import com.darkrockstudios.apps.hammer.frontend.utils.formatInstant
|
||||
import com.darkrockstudios.apps.hammer.frontend.utils.formatPatreonDate
|
||||
import com.darkrockstudios.apps.hammer.frontend.utils.formatSyncDate
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Month
|
||||
|
|
@ -111,11 +110,4 @@ class FormatSyncDateTest {
|
|||
assertEquals("", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formatPatreonDate handles ISO 8601 format`() {
|
||||
val result = formatPatreonDate("2024-03-15T14:30:00Z")
|
||||
|
||||
assertTrue(result.matches(syncDateFormat), "Result was: $result")
|
||||
assertTrue(result.startsWith(monthShort(Month.MARCH)), "Result was: $result")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import com.darkrockstudios.apps.hammer.ServerConfig
|
|||
import com.darkrockstudios.apps.hammer.admin.AdminServerConfig
|
||||
import com.darkrockstudios.apps.hammer.admin.ConfigRepository
|
||||
import com.darkrockstudios.apps.hammer.frontend.data.UserSession
|
||||
import com.darkrockstudios.apps.hammer.patreon.PatreonConfig
|
||||
import com.darkrockstudios.apps.hammer.plugins.configureLocalization
|
||||
import com.darkrockstudios.apps.hammer.plugins.configureTemplating
|
||||
import com.darkrockstudios.apps.hammer.utilities.MarkdownService
|
||||
|
|
@ -45,7 +44,7 @@ class HomePageTest : BaseTest() {
|
|||
cookie<UserSession>(COOKIE_USER_SESSION)
|
||||
}
|
||||
routing {
|
||||
homePage(configRepository, ServerConfig(), MarkdownService())
|
||||
homePage(configRepository, MarkdownService())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -53,7 +52,6 @@ class HomePageTest : BaseTest() {
|
|||
private fun mockPageModelDependencies(serverMessage: String) {
|
||||
coEvery { configRepository.get(AdminServerConfig.SERVER_MESSAGE) } returns serverMessage
|
||||
coEvery { configRepository.get(AdminServerConfig.CONTACT_EMAIL) } returns ""
|
||||
coEvery { configRepository.get(AdminServerConfig.PATREON_CONFIG) } returns PatreonConfig()
|
||||
coEvery { configRepository.get(AdminServerConfig.ABOUT_SERVER) } returns ""
|
||||
coEvery { configRepository.get(AdminServerConfig.DEFAULT_LOCALE) } returns "en"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
package com.darkrockstudios.apps.hammer.frontend.utils
|
||||
|
||||
import com.darkrockstudios.apps.hammer.utilities.ResUtils
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import java.util.Properties
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.test.fail
|
||||
|
||||
/**
|
||||
* English is the fallback bundle every other locale resolves through, so a key
|
||||
* that is looked up but absent from Messages_en.properties throws
|
||||
* MissingResourceException at request time instead of degrading to untranslated
|
||||
* text.
|
||||
* English is the fallback every other locale resolves through, so a key that is
|
||||
* looked up but absent from its English bundle throws MissingResourceException at
|
||||
* request time instead of degrading to untranslated text. Kotlin call sites resolve
|
||||
* only the default Messages bundle; templates read the msg map, which also merges
|
||||
* plugin-declared bundles.
|
||||
*
|
||||
* Parity is asserted against the keys the server actually references rather than
|
||||
* against the translation files, which Crowdin owns and which carry strings that
|
||||
|
|
@ -31,20 +30,30 @@ class MessageBundleParityTest {
|
|||
)
|
||||
private val templatePattern = Regex("""\{\{[{&]?\s*msg\.([A-Za-z0-9_]+)""")
|
||||
|
||||
private fun keysOf(locale: Locale): Set<String> {
|
||||
private val i18nDir = File("src/main/resources/i18n")
|
||||
|
||||
private fun loadProperties(file: File): Properties {
|
||||
val properties = Properties()
|
||||
ResUtils.getResourceAsStream("i18n/Messages_$locale.properties")
|
||||
.reader(Charsets.UTF_8)
|
||||
.use { properties.load(it) }
|
||||
return properties.stringPropertyNames()
|
||||
file.reader(Charsets.UTF_8).use { properties.load(it) }
|
||||
return properties
|
||||
}
|
||||
|
||||
private fun englishBundleFiles(): List<File> =
|
||||
i18nDir.listFiles { f -> f.name.endsWith("_en.properties") }.orEmpty().toList()
|
||||
|
||||
/** Kotlin call sites resolve only the default bundle, so they get no plugin-bundle credit. */
|
||||
private fun coreEnglishKeys(): Set<String> =
|
||||
loadProperties(File(i18nDir, "Messages_en.properties")).stringPropertyNames()
|
||||
|
||||
/** Templates read the msg map, which merges plugin bundles, so the union applies. */
|
||||
private fun allEnglishKeys(): Set<String> =
|
||||
englishBundleFiles().flatMapTo(mutableSetOf()) { loadProperties(it).stringPropertyNames() }
|
||||
|
||||
private fun filesUnder(root: File, extension: String): List<File> =
|
||||
root.walkTopDown().filter { it.isFile && it.extension == extension }.toList()
|
||||
|
||||
private fun referencedKeys(): Map<String, String> {
|
||||
private fun sourceReferences(): Map<String, String> {
|
||||
val references = mutableMapOf<String, String>()
|
||||
|
||||
filesUnder(kotlinSources, "kt").forEach { file ->
|
||||
val text = file.readText()
|
||||
callSitePatterns.forEach { pattern ->
|
||||
|
|
@ -55,14 +64,17 @@ class MessageBundleParityTest {
|
|||
}
|
||||
}
|
||||
}
|
||||
return references
|
||||
}
|
||||
|
||||
private fun templateReferences(): Map<String, String> {
|
||||
val references = mutableMapOf<String, String>()
|
||||
filesUnder(templates, "mustache").forEach { file ->
|
||||
val text = file.readText()
|
||||
templatePattern.findAll(text).forEach { match ->
|
||||
references.putIfAbsent(match.groupValues[1], file.name)
|
||||
}
|
||||
}
|
||||
|
||||
return references
|
||||
}
|
||||
|
||||
|
|
@ -71,17 +83,21 @@ class MessageBundleParityTest {
|
|||
assertTrue(kotlinSources.isDirectory, "Kotlin sources not found at ${kotlinSources.absolutePath}")
|
||||
assertTrue(templates.isDirectory, "Templates not found at ${templates.absolutePath}")
|
||||
|
||||
val references = referencedKeys()
|
||||
val sourceRefs = sourceReferences()
|
||||
val templateRefs = templateReferences()
|
||||
// A regex that quietly stops matching would turn this into a vacuous pass.
|
||||
assertTrue(references.size > 200, "Only found ${references.size} message references; the scan is broken")
|
||||
val total = sourceRefs.size + templateRefs.size
|
||||
assertTrue(total > 200, "Only found $total message references; the scan is broken")
|
||||
|
||||
val englishKeys = keysOf(Locale.ENGLISH)
|
||||
val missing = references.filterKeys { it !in englishKeys }
|
||||
val coreKeys = coreEnglishKeys()
|
||||
val allKeys = allEnglishKeys()
|
||||
val missing = sourceRefs.filterKeys { it !in coreKeys } +
|
||||
templateRefs.filterKeys { it !in allKeys }
|
||||
|
||||
if (missing.isNotEmpty()) {
|
||||
val report = missing.toSortedMap().entries.joinToString("\n") { (key, source) -> " $key (used by $source)" }
|
||||
fail(
|
||||
"Messages_en.properties is the fallback bundle and must carry every key the server looks up.\n" +
|
||||
"The English bundles are the fallback and must carry every key the server looks up.\n" +
|
||||
"$report\n" +
|
||||
"If a Crowdin sync rewrote the English source file, restore the dropped keys."
|
||||
)
|
||||
|
|
@ -90,12 +106,12 @@ class MessageBundleParityTest {
|
|||
|
||||
@Test
|
||||
fun `every English key resolves to a non-blank value`() {
|
||||
val properties = Properties()
|
||||
ResUtils.getResourceAsStream("i18n/Messages_en.properties")
|
||||
.reader(Charsets.UTF_8)
|
||||
.use { properties.load(it) }
|
||||
|
||||
val blank = properties.stringPropertyNames().filter { properties.getProperty(it).isNullOrBlank() }
|
||||
val blank = englishBundleFiles().flatMap { file ->
|
||||
val properties = loadProperties(file)
|
||||
properties.stringPropertyNames()
|
||||
.filter { properties.getProperty(it).isNullOrBlank() }
|
||||
.map { "${file.name}: $it" }
|
||||
}
|
||||
|
||||
assertTrue(blank.isEmpty(), "English keys with blank values: ${blank.sorted()}")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,8 +63,6 @@ class MonitoringExportRouteTest {
|
|||
projectsSyncManager = mockk<SyncSessionManager<Long, ProjectsSynchronizationSession>>(relaxed = true),
|
||||
projectSyncManager = mockk<SyncSessionManager<Long, ProjectSynchronizationSession>>(relaxed = true),
|
||||
clock = clock,
|
||||
patreonFeatureEnabled = false,
|
||||
emailFeatureEnabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,8 +63,6 @@ class MonitoringIgnoreRouteTest : BaseTest() {
|
|||
relaxed = true
|
||||
),
|
||||
clock = clock,
|
||||
patreonFeatureEnabled = false,
|
||||
emailFeatureEnabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.patreon
|
||||
|
||||
import com.darkrockstudios.apps.hammer.utils.BaseTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.slf4j.LoggerFactory
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PatreonApiClientTest : BaseTest() {
|
||||
|
||||
private lateinit var client: PatreonApiClient
|
||||
private val logger = LoggerFactory.getLogger(PatreonApiClientTest::class.java)
|
||||
|
||||
@BeforeEach
|
||||
override fun setup() {
|
||||
super.setup()
|
||||
client = PatreonApiClient(logger)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verify valid webhook signature`() {
|
||||
val payload = """{"data":{"id":"123"}}"""
|
||||
val secret = "test-webhook-secret"
|
||||
|
||||
// Compute expected signature using HmacMD5
|
||||
val mac = javax.crypto.Mac.getInstance("HmacMD5")
|
||||
val secretKey = javax.crypto.spec.SecretKeySpec(secret.toByteArray(), "HmacMD5")
|
||||
mac.init(secretKey)
|
||||
val hash = mac.doFinal(payload.toByteArray())
|
||||
val expectedSignature = hash.joinToString("") { "%02x".format(it) }
|
||||
|
||||
val result = client.verifyWebhookSignature(payload, expectedSignature, secret)
|
||||
|
||||
assertTrue(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reject invalid webhook signature`() {
|
||||
val payload = """{"data":{"id":"123"}}"""
|
||||
val secret = "test-webhook-secret"
|
||||
val invalidSignature = "invalid-signature-12345"
|
||||
|
||||
val result = client.verifyWebhookSignature(payload, invalidSignature, secret)
|
||||
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reject tampered payload`() {
|
||||
val originalPayload = """{"data":{"id":"123"}}"""
|
||||
val tamperedPayload = """{"data":{"id":"456"}}"""
|
||||
val secret = "test-webhook-secret"
|
||||
|
||||
// Compute signature for original payload
|
||||
val mac = javax.crypto.Mac.getInstance("HmacMD5")
|
||||
val secretKey = javax.crypto.spec.SecretKeySpec(secret.toByteArray(), "HmacMD5")
|
||||
mac.init(secretKey)
|
||||
val hash = mac.doFinal(originalPayload.toByteArray())
|
||||
val originalSignature = hash.joinToString("") { "%02x".format(it) }
|
||||
|
||||
// Verify with tampered payload should fail
|
||||
val result = client.verifyWebhookSignature(tamperedPayload, originalSignature, secret)
|
||||
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reject wrong secret`() {
|
||||
val payload = """{"data":{"id":"123"}}"""
|
||||
val correctSecret = "correct-secret"
|
||||
val wrongSecret = "wrong-secret"
|
||||
|
||||
// Compute signature with correct secret
|
||||
val mac = javax.crypto.Mac.getInstance("HmacMD5")
|
||||
val secretKey = javax.crypto.spec.SecretKeySpec(correctSecret.toByteArray(), "HmacMD5")
|
||||
mac.init(secretKey)
|
||||
val hash = mac.doFinal(payload.toByteArray())
|
||||
val signature = hash.joinToString("") { "%02x".format(it) }
|
||||
|
||||
// Verify with wrong secret should fail
|
||||
val result = client.verifyWebhookSignature(payload, signature, wrongSecret)
|
||||
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signature verification is case insensitive`() {
|
||||
val payload = """{"data":{"id":"123"}}"""
|
||||
val secret = "test-webhook-secret"
|
||||
|
||||
// Compute expected signature
|
||||
val mac = javax.crypto.Mac.getInstance("HmacMD5")
|
||||
val secretKey = javax.crypto.spec.SecretKeySpec(secret.toByteArray(), "HmacMD5")
|
||||
mac.init(secretKey)
|
||||
val hash = mac.doFinal(payload.toByteArray())
|
||||
val lowerCaseSignature = hash.joinToString("") { "%02x".format(it) }
|
||||
val upperCaseSignature = lowerCaseSignature.uppercase()
|
||||
|
||||
// Both should verify successfully
|
||||
assertTrue(client.verifyWebhookSignature(payload, lowerCaseSignature, secret))
|
||||
assertTrue(client.verifyWebhookSignature(payload, upperCaseSignature, secret))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty payload signature verification`() {
|
||||
val payload = ""
|
||||
val secret = "test-webhook-secret"
|
||||
|
||||
// Compute expected signature for empty payload
|
||||
val mac = javax.crypto.Mac.getInstance("HmacMD5")
|
||||
val secretKey = javax.crypto.spec.SecretKeySpec(secret.toByteArray(), "HmacMD5")
|
||||
mac.init(secretKey)
|
||||
val hash = mac.doFinal(payload.toByteArray())
|
||||
val expectedSignature = hash.joinToString("") { "%02x".format(it) }
|
||||
|
||||
val result = client.verifyWebhookSignature(payload, expectedSignature, secret)
|
||||
|
||||
assertTrue(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty signature returns false`() {
|
||||
val payload = """{"data":{"id":"123"}}"""
|
||||
val secret = "test-webhook-secret"
|
||||
|
||||
val result = client.verifyWebhookSignature(payload, "", secret)
|
||||
|
||||
assertFalse(result)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,314 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.patreon
|
||||
|
||||
import com.darkrockstudios.apps.hammer.Account
|
||||
import com.darkrockstudios.apps.hammer.account.AccountsRepository
|
||||
import com.darkrockstudios.apps.hammer.admin.AdminServerConfig
|
||||
import com.darkrockstudios.apps.hammer.admin.ConfigRepository
|
||||
import com.darkrockstudios.apps.hammer.admin.WhiteListRepository
|
||||
import com.darkrockstudios.apps.hammer.database.ServerConfigDao
|
||||
import com.darkrockstudios.apps.hammer.database.WhiteListDao
|
||||
import com.darkrockstudios.apps.hammer.e2e.util.SharedPostgresTestDatabase
|
||||
import com.darkrockstudios.apps.hammer.utils.BaseTest
|
||||
import com.darkrockstudios.apps.hammer.utils.TestClock
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.slf4j.LoggerFactory
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
class PatreonSyncServiceTest : BaseTest() {
|
||||
|
||||
private lateinit var db: SharedPostgresTestDatabase
|
||||
private lateinit var whiteListDao: WhiteListDao
|
||||
private lateinit var configDao: ServerConfigDao
|
||||
private lateinit var configRepository: ConfigRepository
|
||||
private lateinit var whiteListRepository: WhiteListRepository
|
||||
private lateinit var clock: TestClock
|
||||
private lateinit var patreonApiClient: PatreonApiClient
|
||||
private lateinit var accountsRepository: AccountsRepository
|
||||
private val logger = LoggerFactory.getLogger(PatreonSyncServiceTest::class.java)
|
||||
|
||||
@BeforeEach
|
||||
override fun setup() {
|
||||
super.setup()
|
||||
|
||||
db = SharedPostgresTestDatabase()
|
||||
db.initialize()
|
||||
whiteListDao = WhiteListDao(db)
|
||||
configDao = ServerConfigDao(db)
|
||||
configRepository = ConfigRepository(configDao)
|
||||
clock = TestClock(Clock.System)
|
||||
whiteListRepository = WhiteListRepository(whiteListDao, clock)
|
||||
patreonApiClient = mockk()
|
||||
accountsRepository = mockk()
|
||||
|
||||
setupKoin()
|
||||
}
|
||||
|
||||
private fun createSyncService() = PatreonSyncService(
|
||||
patreonApiClient = patreonApiClient,
|
||||
whiteListRepository = whiteListRepository,
|
||||
accountsRepository = accountsRepository,
|
||||
configRepository = configRepository,
|
||||
clock = clock,
|
||||
logger = logger
|
||||
)
|
||||
|
||||
private suspend fun enablePatreon(
|
||||
campaignId: String = "test-campaign",
|
||||
accessToken: String = "test-token",
|
||||
minimumAmountCents: Int = 500
|
||||
) {
|
||||
val config = PatreonConfig(
|
||||
enabled = true,
|
||||
campaignId = campaignId,
|
||||
creatorAccessToken = accessToken,
|
||||
minimumAmountCents = minimumAmountCents
|
||||
)
|
||||
configRepository.set(AdminServerConfig.PATREON_CONFIG, config)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync skipped when disabled`() = runTest {
|
||||
// Patreon disabled by default
|
||||
val service = createSyncService()
|
||||
|
||||
val result = service.performFullSync()
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(0, result.getOrThrow().added)
|
||||
assertEquals(0, result.getOrThrow().removed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync fails when missing campaign ID`() = runTest {
|
||||
val config = PatreonConfig(enabled = true, campaignId = "", creatorAccessToken = "token")
|
||||
configRepository.set(AdminServerConfig.PATREON_CONFIG, config)
|
||||
|
||||
val service = createSyncService()
|
||||
val result = service.performFullSync()
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertIs<PatreonSyncException>(result.exceptionOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new patron joins - added to whitelist`() = runTest {
|
||||
enablePatreon()
|
||||
|
||||
coEvery {
|
||||
patreonApiClient.fetchAllQualifyingMembers("test-campaign", "test-token", 500)
|
||||
} returns Result.success(listOf("patron@example.com"))
|
||||
|
||||
val service = createSyncService()
|
||||
val result = service.performFullSync()
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(1, result.getOrThrow().added)
|
||||
assertTrue(whiteListRepository.isOnWhiteList("patron@example.com"))
|
||||
|
||||
// Verify reason is "Patreon"
|
||||
val entries = whiteListRepository.getWhiteListByReason(PatreonSyncService.WHITELIST_REASON)
|
||||
assertEquals(1, entries.size)
|
||||
assertEquals("patron@example.com", entries[0].email)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `existing patron with different reason - not modified`() = runTest {
|
||||
// Add user to whitelist with different reason
|
||||
whiteListRepository.addToWhiteList("betatester@example.com", "Beta tester")
|
||||
|
||||
enablePatreon()
|
||||
|
||||
coEvery {
|
||||
patreonApiClient.fetchAllQualifyingMembers("test-campaign", "test-token", 500)
|
||||
} returns Result.success(listOf("betatester@example.com"))
|
||||
|
||||
val service = createSyncService()
|
||||
val result = service.performFullSync()
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(0, result.getOrThrow().added)
|
||||
assertEquals(1, result.getOrThrow().skipped)
|
||||
|
||||
// Verify reason is still "Beta tester"
|
||||
val entries = whiteListRepository.getWhiteListByReason("Beta tester")
|
||||
assertEquals(1, entries.size)
|
||||
assertEquals("betatester@example.com", entries[0].email)
|
||||
|
||||
// Verify no Patreon entries
|
||||
val patreonEntries = whiteListRepository.getWhiteListByReason(PatreonSyncService.WHITELIST_REASON)
|
||||
assertEquals(0, patreonEntries.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `patron unsubscribes - removed from whitelist and logged out`() = runTest {
|
||||
// Add user as Patreon member
|
||||
whiteListRepository.addToWhiteList("lapsed@example.com", PatreonSyncService.WHITELIST_REASON)
|
||||
|
||||
enablePatreon()
|
||||
|
||||
// Patron is no longer in the API response
|
||||
coEvery {
|
||||
patreonApiClient.fetchAllQualifyingMembers("test-campaign", "test-token", 500)
|
||||
} returns Result.success(emptyList())
|
||||
|
||||
coEvery { accountsRepository.forceLogout("lapsed@example.com") } returns true
|
||||
|
||||
val service = createSyncService()
|
||||
val result = service.performFullSync()
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(1, result.getOrThrow().removed)
|
||||
assertFalse(whiteListRepository.isOnWhiteList("lapsed@example.com"))
|
||||
|
||||
// Verify force logout was called
|
||||
coVerify { accountsRepository.forceLogout("lapsed@example.com") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-Patreon user unaffected when not in API`() = runTest {
|
||||
// Add user with non-Patreon reason
|
||||
whiteListRepository.addToWhiteList("friend@example.com", "Friend")
|
||||
|
||||
enablePatreon()
|
||||
|
||||
// Friend is not in Patreon API (they're not a patron)
|
||||
coEvery {
|
||||
patreonApiClient.fetchAllQualifyingMembers("test-campaign", "test-token", 500)
|
||||
} returns Result.success(emptyList())
|
||||
|
||||
val service = createSyncService()
|
||||
val result = service.performFullSync()
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(0, result.getOrThrow().removed)
|
||||
|
||||
// Friend should still be on whitelist
|
||||
assertTrue(whiteListRepository.isOnWhiteList("friend@example.com"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync with no changes`() = runTest {
|
||||
// Add existing Patreon member
|
||||
whiteListRepository.addToWhiteList("patron@example.com", PatreonSyncService.WHITELIST_REASON)
|
||||
|
||||
enablePatreon()
|
||||
|
||||
coEvery {
|
||||
patreonApiClient.fetchAllQualifyingMembers("test-campaign", "test-token", 500)
|
||||
} returns Result.success(listOf("patron@example.com"))
|
||||
|
||||
val service = createSyncService()
|
||||
val result = service.performFullSync()
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(0, result.getOrThrow().added)
|
||||
assertEquals(0, result.getOrThrow().removed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `force logout works`() = runTest {
|
||||
coEvery { accountsRepository.forceLogout("user@example.com") } returns true
|
||||
|
||||
val service = createSyncService()
|
||||
service.forceLogout("user@example.com")
|
||||
|
||||
coVerify { accountsRepository.forceLogout("user@example.com") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `force logout handles missing account gracefully`() = runTest {
|
||||
coEvery { accountsRepository.forceLogout("nonexistent@example.com") } returns false
|
||||
|
||||
val service = createSyncService()
|
||||
// Should not throw
|
||||
service.forceLogout("nonexistent@example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `email normalization works`() = runTest {
|
||||
enablePatreon()
|
||||
|
||||
coEvery {
|
||||
patreonApiClient.fetchAllQualifyingMembers("test-campaign", "test-token", 500)
|
||||
} returns Result.success(listOf(" PATRON@EXAMPLE.COM "))
|
||||
|
||||
val service = createSyncService()
|
||||
val result = service.performFullSync()
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(1, result.getOrThrow().added)
|
||||
|
||||
// Should be normalized to lowercase
|
||||
assertTrue(whiteListRepository.isOnWhiteList("patron@example.com"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `last sync time is updated`() = runTest {
|
||||
enablePatreon()
|
||||
|
||||
coEvery {
|
||||
patreonApiClient.fetchAllQualifyingMembers("test-campaign", "test-token", 500)
|
||||
} returns Result.success(emptyList())
|
||||
|
||||
val service = createSyncService()
|
||||
service.performFullSync()
|
||||
|
||||
val config = configRepository.get(AdminServerConfig.PATREON_CONFIG)
|
||||
assertEquals(clock.now().toString(), config.lastSync)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `patron count returns correct value`() = runTest {
|
||||
whiteListRepository.addToWhiteList("patron1@example.com", PatreonSyncService.WHITELIST_REASON)
|
||||
whiteListRepository.addToWhiteList("patron2@example.com", PatreonSyncService.WHITELIST_REASON)
|
||||
whiteListRepository.addToWhiteList("friend@example.com", "Friend")
|
||||
|
||||
val service = createSyncService()
|
||||
val count = service.getPatreonWhitelistCount()
|
||||
|
||||
assertEquals(2L, count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `patron with accounts count returns correct value`() = runTest {
|
||||
// Add patrons to whitelist
|
||||
whiteListRepository.addToWhiteList("patron1@example.com", PatreonSyncService.WHITELIST_REASON)
|
||||
whiteListRepository.addToWhiteList("patron2@example.com", PatreonSyncService.WHITELIST_REASON)
|
||||
whiteListRepository.addToWhiteList("patron3@example.com", PatreonSyncService.WHITELIST_REASON)
|
||||
whiteListRepository.addToWhiteList("friend@example.com", "Friend")
|
||||
|
||||
// Create accounts for only some of the patrons
|
||||
db.serverDatabase.accountQueries.testInsertAccount(
|
||||
email = "patron1@example.com",
|
||||
password_hash = "hash",
|
||||
cipher_secret = "secret",
|
||||
is_admin = false,
|
||||
created = Instant.parse("2024-01-01T00:00:00Z"),
|
||||
last_sync = Instant.parse("2024-01-01T00:00:00Z")
|
||||
)
|
||||
db.serverDatabase.accountQueries.testInsertAccount(
|
||||
email = "patron3@example.com",
|
||||
password_hash = "hash",
|
||||
cipher_secret = "secret",
|
||||
is_admin = false,
|
||||
created = Instant.parse("2024-01-01T00:00:00Z"),
|
||||
last_sync = Instant.parse("2024-01-01T00:00:00Z")
|
||||
)
|
||||
|
||||
val service = createSyncService()
|
||||
val count = service.getPatreonMembersWithAccountsCount()
|
||||
|
||||
// Only patron1 and patron3 have accounts (patron2 doesn't, friend is not a Patreon member)
|
||||
assertEquals(2L, count)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
package com.darkrockstudios.apps.hammer.patreon
|
||||
|
||||
import com.darkrockstudios.apps.hammer.admin.AdminServerConfig
|
||||
import com.darkrockstudios.apps.hammer.admin.ConfigRepository
|
||||
import com.darkrockstudios.apps.hammer.database.ServerConfigDao
|
||||
import com.darkrockstudios.apps.hammer.e2e.util.SharedPostgresTestDatabase
|
||||
import com.darkrockstudios.apps.hammer.utils.BaseTest
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.slf4j.LoggerFactory
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PatreonWebhookHandlerTest : BaseTest() {
|
||||
|
||||
private lateinit var db: SharedPostgresTestDatabase
|
||||
private lateinit var configDao: ServerConfigDao
|
||||
private lateinit var configRepository: ConfigRepository
|
||||
private lateinit var patreonApiClient: PatreonApiClient
|
||||
private lateinit var patreonSyncService: PatreonSyncService
|
||||
private val logger = LoggerFactory.getLogger(PatreonWebhookHandlerTest::class.java)
|
||||
|
||||
@BeforeEach
|
||||
override fun setup() {
|
||||
super.setup()
|
||||
|
||||
db = SharedPostgresTestDatabase()
|
||||
db.initialize()
|
||||
configDao = ServerConfigDao(db)
|
||||
configRepository = ConfigRepository(configDao)
|
||||
patreonApiClient = mockk()
|
||||
patreonSyncService = mockk()
|
||||
|
||||
setupKoin()
|
||||
}
|
||||
|
||||
private fun createHandler() = PatreonWebhookHandler(
|
||||
patreonApiClient = patreonApiClient,
|
||||
patreonSyncService = patreonSyncService,
|
||||
configRepository = configRepository,
|
||||
logger = logger
|
||||
)
|
||||
|
||||
private suspend fun enablePatreon(
|
||||
webhookSecret: String = "test-secret"
|
||||
) {
|
||||
val config = PatreonConfig(
|
||||
enabled = true,
|
||||
campaignId = "test-campaign",
|
||||
creatorAccessToken = "test-token",
|
||||
webhookSecret = webhookSecret,
|
||||
minimumAmountCents = 500
|
||||
)
|
||||
configRepository.set(AdminServerConfig.PATREON_CONFIG, config)
|
||||
}
|
||||
|
||||
private fun computeSignature(payload: String, secret: String): String {
|
||||
val mac = javax.crypto.Mac.getInstance("HmacMD5")
|
||||
val secretKey = javax.crypto.spec.SecretKeySpec(secret.toByteArray(), "HmacMD5")
|
||||
mac.init(secretKey)
|
||||
val hash = mac.doFinal(payload.toByteArray())
|
||||
return hash.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns Disabled when Patreon integration is disabled`() = runTest {
|
||||
// Patreon disabled by default
|
||||
val handler = createHandler()
|
||||
|
||||
val result = handler.handleWebhook(
|
||||
payload = """{"data":{}}""",
|
||||
signature = "some-signature",
|
||||
eventType = "members:pledge:create"
|
||||
)
|
||||
|
||||
assertTrue(result is WebhookResult.Disabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns MissingSignature when signature header is null`() = runTest {
|
||||
enablePatreon()
|
||||
val handler = createHandler()
|
||||
|
||||
val result = handler.handleWebhook(
|
||||
payload = """{"data":{}}""",
|
||||
signature = null,
|
||||
eventType = "members:pledge:create"
|
||||
)
|
||||
|
||||
assertTrue(result is WebhookResult.MissingSignature)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns MissingSignature when signature header is blank`() = runTest {
|
||||
enablePatreon()
|
||||
val handler = createHandler()
|
||||
|
||||
val result = handler.handleWebhook(
|
||||
payload = """{"data":{}}""",
|
||||
signature = " ",
|
||||
eventType = "members:pledge:create"
|
||||
)
|
||||
|
||||
assertTrue(result is WebhookResult.MissingSignature)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns NotConfigured when webhook secret is not set`() = runTest {
|
||||
enablePatreon(webhookSecret = "")
|
||||
val handler = createHandler()
|
||||
|
||||
val result = handler.handleWebhook(
|
||||
payload = """{"data":{}}""",
|
||||
signature = "some-signature",
|
||||
eventType = "members:pledge:create"
|
||||
)
|
||||
|
||||
assertTrue(result is WebhookResult.NotConfigured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns InvalidSignature when signature verification fails`() = runTest {
|
||||
enablePatreon()
|
||||
val handler = createHandler()
|
||||
|
||||
every {
|
||||
patreonApiClient.verifyWebhookSignature(any(), any(), any())
|
||||
} returns false
|
||||
|
||||
val result = handler.handleWebhook(
|
||||
payload = """{"data":{}}""",
|
||||
signature = "invalid-signature",
|
||||
eventType = "members:pledge:create"
|
||||
)
|
||||
|
||||
assertTrue(result is WebhookResult.InvalidSignature)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `triggers full sync on valid webhook`() = runTest {
|
||||
enablePatreon()
|
||||
val handler = createHandler()
|
||||
val payload = """{"data":{"id":"123"}}"""
|
||||
val signature = computeSignature(payload, "test-secret")
|
||||
|
||||
every {
|
||||
patreonApiClient.verifyWebhookSignature(payload, signature, "test-secret")
|
||||
} returns true
|
||||
|
||||
coEvery {
|
||||
patreonSyncService.performFullSync()
|
||||
} returns Result.success(SyncResult(added = 1, removed = 0, skipped = 0))
|
||||
|
||||
val result = handler.handleWebhook(
|
||||
payload = payload,
|
||||
signature = signature,
|
||||
eventType = "members:pledge:create"
|
||||
)
|
||||
|
||||
assertTrue(result is WebhookResult.Success)
|
||||
assertEquals(1, (result as WebhookResult.Success).syncResult.added)
|
||||
|
||||
coVerify { patreonSyncService.performFullSync() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns SyncFailed when sync fails`() = runTest {
|
||||
enablePatreon()
|
||||
val handler = createHandler()
|
||||
val payload = """{"data":{"id":"123"}}"""
|
||||
val signature = computeSignature(payload, "test-secret")
|
||||
|
||||
every {
|
||||
patreonApiClient.verifyWebhookSignature(payload, signature, "test-secret")
|
||||
} returns true
|
||||
|
||||
coEvery {
|
||||
patreonSyncService.performFullSync()
|
||||
} returns Result.failure(RuntimeException("API error"))
|
||||
|
||||
val result = handler.handleWebhook(
|
||||
payload = payload,
|
||||
signature = signature,
|
||||
eventType = "members:pledge:create"
|
||||
)
|
||||
|
||||
assertTrue(result is WebhookResult.SyncFailed)
|
||||
assertEquals("API error", (result as WebhookResult.SyncFailed).error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles members create event`() = runTest {
|
||||
enablePatreon()
|
||||
val handler = createHandler()
|
||||
val payload = """{"data":{"id":"123","type":"member"}}"""
|
||||
val signature = computeSignature(payload, "test-secret")
|
||||
|
||||
every {
|
||||
patreonApiClient.verifyWebhookSignature(payload, signature, "test-secret")
|
||||
} returns true
|
||||
|
||||
coEvery {
|
||||
patreonSyncService.performFullSync()
|
||||
} returns Result.success(SyncResult(added = 1, removed = 0, skipped = 0))
|
||||
|
||||
val result = handler.handleWebhook(
|
||||
payload = payload,
|
||||
signature = signature,
|
||||
eventType = "members:create"
|
||||
)
|
||||
|
||||
assertTrue(result is WebhookResult.Success)
|
||||
coVerify { patreonSyncService.performFullSync() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles members delete event`() = runTest {
|
||||
enablePatreon()
|
||||
val handler = createHandler()
|
||||
val payload = """{"data":{"id":"123","type":"member"}}"""
|
||||
val signature = computeSignature(payload, "test-secret")
|
||||
|
||||
every {
|
||||
patreonApiClient.verifyWebhookSignature(payload, signature, "test-secret")
|
||||
} returns true
|
||||
|
||||
coEvery {
|
||||
patreonSyncService.performFullSync()
|
||||
} returns Result.success(SyncResult(added = 0, removed = 1, skipped = 0))
|
||||
|
||||
val result = handler.handleWebhook(
|
||||
payload = payload,
|
||||
signature = signature,
|
||||
eventType = "members:delete"
|
||||
)
|
||||
|
||||
assertTrue(result is WebhookResult.Success)
|
||||
assertEquals(1, (result as WebhookResult.Success).syncResult.removed)
|
||||
coVerify { patreonSyncService.performFullSync() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles members pledge update event`() = runTest {
|
||||
enablePatreon()
|
||||
val handler = createHandler()
|
||||
val payload = """{"data":{"id":"123","type":"member"}}"""
|
||||
val signature = computeSignature(payload, "test-secret")
|
||||
|
||||
every {
|
||||
patreonApiClient.verifyWebhookSignature(payload, signature, "test-secret")
|
||||
} returns true
|
||||
|
||||
coEvery {
|
||||
patreonSyncService.performFullSync()
|
||||
} returns Result.success(SyncResult(added = 0, removed = 0, skipped = 1))
|
||||
|
||||
val result = handler.handleWebhook(
|
||||
payload = payload,
|
||||
signature = signature,
|
||||
eventType = "members:pledge:update"
|
||||
)
|
||||
|
||||
assertTrue(result is WebhookResult.Success)
|
||||
coVerify { patreonSyncService.performFullSync() }
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import com.darkrockstudios.apps.hammer.project.ProjectSynchronizationSession
|
|||
import com.darkrockstudios.apps.hammer.projects.ProjectsSynchronizationSession
|
||||
import com.darkrockstudios.apps.hammer.scheduling.RecurringTaskRegistry
|
||||
import com.darkrockstudios.apps.hammer.syncsessionmanager.SyncSessionManager
|
||||
import com.darkrockstudios.apps.hammer.plugin.PluginRegistry
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.install
|
||||
import io.mockk.mockk
|
||||
|
|
@ -116,6 +117,8 @@ fun Application.setupKtorTestKoin(baseTest: BaseTest, vararg modules: Module) {
|
|||
|
||||
modules(
|
||||
module {
|
||||
// Route helpers resolve the registry even when no plugins are in play.
|
||||
single { PluginRegistry(emptyList()) }
|
||||
single<CoroutineContext>(named(DISPATCHER_DEFAULT)) {
|
||||
StandardTestDispatcher(
|
||||
baseTest.scope.testScheduler,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import com.darkrockstudios.apps.hammer.utilities.TokenHasher
|
|||
import com.darkrockstudios.apps.hammer.utilities.TouchableFileSystem
|
||||
import com.darkrockstudios.apps.hammer.utilities.DiskCache
|
||||
import com.darkrockstudios.apps.hammer.utilities.cacheDirectory
|
||||
import com.darkrockstudios.apps.hammer.plugin.ServerPlugin
|
||||
import com.darkrockstudios.apps.hammer.plugin.installedPlugins
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
|
|
@ -57,6 +59,13 @@ abstract class EndToEndTest {
|
|||
*/
|
||||
protected open val serverConfig = ServerConfig(encryption = EncryptionConfig(EncryptionMode.AES))
|
||||
|
||||
/**
|
||||
* Plugins active in the booted server. Defaults to the build's real plugin list so e2e
|
||||
* runs match production; tests override to add fixtures or to isolate from plugins.
|
||||
* Plugin fixtures must keep their integrations unconfigured or tests become order-dependent.
|
||||
*/
|
||||
protected open val serverPlugins: List<ServerPlugin> = installedPlugins()
|
||||
|
||||
/** The OS-assigned port the server bound to; valid after [doStartServer]. */
|
||||
protected var serverPort: Int = 0
|
||||
private set
|
||||
|
|
@ -154,7 +163,7 @@ abstract class EndToEndTest {
|
|||
port = 0,
|
||||
host = "0.0.0.0",
|
||||
module = {
|
||||
appMain(serverConfig, testModule)
|
||||
appMain(serverConfig, testModule, plugins = serverPlugins)
|
||||
}
|
||||
)
|
||||
server.start()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue