diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/Application.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/Application.kt index 05a7712a0..dcf092205 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/Application.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/Application.kt @@ -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 = 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() diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/ServerConfig.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/ServerConfig.kt index 5a03b1d23..3ab1711ea 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/ServerConfig.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/ServerConfig.kt @@ -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. diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountsComponent.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountsComponent.kt index 1e3648369..f3daf854e 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountsComponent.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/account/AccountsComponent.kt @@ -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 { - 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") + ) } } diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/admin/AdminServerConfig.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/admin/AdminServerConfig.kt index 87861fa0c..b09291efb 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/admin/AdminServerConfig.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/admin/AdminServerConfig.kt @@ -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(it) }, - serialize = { Json.encodeToString(PatreonConfig.serializer(), it) } - ) val SMTP_CONFIG = ServerConfigKey( key = "smtp_config", default = SmtpConfig(), diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/dependencyinjection/mainModule.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/dependencyinjection/mainModule.kt index 930e08dd6..44ed8a3e0 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/dependencyinjection/mainModule.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/dependencyinjection/mainModule.kt @@ -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() single() - single() - single() - single() - single() single() single() diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/Frontend.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/Frontend.kt index fbf9655c8..168136a51 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/Frontend.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/Frontend.kt @@ -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 by inject(named(PROJECTS_SYNC_MANAGER)) val projectSyncManager: SyncSessionManager by inject(named(PROJECT_SYNC_MANAGER)) - // Only inject PatreonSyncService if Patreon is enabled at server level - val patreonSyncService: PatreonSyncService? = if (serverConfig.patreonEnabled == true) { - inject().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 = emptyMap()): M val serverConfig = get() + // 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() val aboutContent = configRepository.get(AdminServerConfig.ABOUT_SERVER) val hasAboutPage = aboutContent.isNotBlank() @@ -404,13 +406,7 @@ suspend fun ApplicationCall.withDefaults(data: Map = 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) { diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/HomePage.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/HomePage.kt index c55f21498..963e0ed9c 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/HomePage.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/HomePage.kt @@ -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)) } diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/LoginPage.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/LoginPage.kt index 4aad985ca..0274a15ee 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/LoginPage.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/LoginPage.kt @@ -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() - 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 { 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) } } diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/MonitoringPage.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/MonitoringPage.kt index 60f3b0841..1efd4b799 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/MonitoringPage.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/MonitoringPage.kt @@ -71,8 +71,6 @@ fun Route.adminMonitoringPages( projectsSyncManager: SyncSessionManager, 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))) } diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/SignupPage.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/SignupPage.kt index 3de74736d..010a82788 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/SignupPage.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/SignupPage.kt @@ -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 diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/admin/AdminPage.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/admin/AdminPage.kt index 42e32e39e..d35f086eb 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/admin/AdminPage.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/admin/AdminPage.kt @@ -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, diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/admin/EmailPage.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/admin/EmailPage.kt index 662ab4c87..036245599 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/admin/EmailPage.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/admin/EmailPage.kt @@ -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), diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/admin/PatreonPage.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/admin/PatreonPage.kt deleted file mode 100644 index 4fcdce842..000000000 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/admin/PatreonPage.kt +++ /dev/null @@ -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))) - } -} \ No newline at end of file diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/admin/WhitelistPage.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/admin/WhitelistPage.kt index b4409e282..98552dff9 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/admin/WhitelistPage.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/admin/WhitelistPage.kt @@ -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 { + val sourceIds = call.application.get().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 , 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), ) } diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/DateUtils.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/DateUtils.kt index 3aa57e387..a1659cedc 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/DateUtils.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/DateUtils.kt @@ -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, diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/Localization.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/Localization.kt index af03e389f..1e4463180 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/Localization.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/Localization.kt @@ -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 = emptyMap()): M messages[key] = bundle.getString(key) } + // Plugin bundles layer on top, same English-baseline rule per bundle. + val pluginRegistry = runCatching { application.get() }.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( diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/monitoring/ConfigureMonitoringJob.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/monitoring/ConfigureMonitoringJob.kt index 8a3a2be9e..a60fb2bcf 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/monitoring/ConfigureMonitoringJob.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/monitoring/ConfigureMonitoringJob.kt @@ -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. diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonApiClient.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonApiClient.kt deleted file mode 100644 index 0664ec882..000000000 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonApiClient.kt +++ /dev/null @@ -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 = emptyList(), - val meta: PatreonMeta? = null -) - -@Serializable -data class PatreonCampaignData( - val id: String, - val type: String -) - -@Serializable -data class PatreonCampaignsResponse( - val data: List = 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> { - val allEmails = mutableListOf() - 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 { - 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) diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonConfig.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonConfig.kt deleted file mode 100644 index cb58fa214..000000000 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonConfig.kt +++ /dev/null @@ -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 = "" -) diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonPollingJob.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonPollingJob.kt deleted file mode 100644 index fd9f2ed3d..000000000 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonPollingJob.kt +++ /dev/null @@ -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 -} diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonRoutes.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonRoutes.kt deleted file mode 100644 index 681d731a9..000000000 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonRoutes.kt +++ /dev/null @@ -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) - } - } - } -} diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonSyncService.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonSyncService.kt deleted file mode 100644 index 2a33c7d0b..000000000 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonSyncService.kt +++ /dev/null @@ -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 = 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 { - 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() - - 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) diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonWebhookHandler.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonWebhookHandler.kt deleted file mode 100644 index 023cb7d72..000000000 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonWebhookHandler.kt +++ /dev/null @@ -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() -} diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/patreon/configurePatreonPolling.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/patreon/configurePatreonPolling.kt deleted file mode 100644 index 7e31d3bed..000000000 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/patreon/configurePatreonPolling.kt +++ /dev/null @@ -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") - } -} diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugin/AllowedUsersSource.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugin/AllowedUsersSource.kt new file mode 100644 index 000000000..8cc05dc19 --- /dev/null +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugin/AllowedUsersSource.kt @@ -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? +} diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugin/InstalledPlugins.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugin/InstalledPlugins.kt new file mode 100644 index 000000000..292cec499 --- /dev/null +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugin/InstalledPlugins.kt @@ -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 = listOf() diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugin/PluginRegistry.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugin/PluginRegistry.kt new file mode 100644 index 000000000..f8c7ad00e --- /dev/null +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugin/PluginRegistry.kt @@ -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) { + + // 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 by lazy { plugins.mapNotNull { it.allowedUsersSource() } } + + /** Reason tags owned by a source, whose whitelist entries the admin must not hand-edit. */ + val allowedUsersSourceIds: Set 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, logger: Logger): PluginRegistry { + plugins.forEach { logger.info("Server plugin '${it.id}' installed") } + return PluginRegistry(plugins) + } + } +} diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugin/PluginWiring.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugin/PluginWiring.kt new file mode 100644 index 000000000..fc89846c1 --- /dev/null +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugin/PluginWiring.kt @@ -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> { + 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().activeAllowedUsersSource() + attributes.put(activeSourceKey, CachedActiveSource(source)) + return source +} + +private val activeSourceKey = AttributeKey("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, + 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 +} diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugin/ServerPlugin.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugin/ServerPlugin.kt new file mode 100644 index 000000000..8fee4f77f --- /dev/null +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugin/ServerPlugin.kt @@ -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 = 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 = 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, +) diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugins/DependencyInjection.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugins/DependencyInjection.kt index 633954d39..ecd707798 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugins/DependencyInjection.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugins/DependencyInjection.kt @@ -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 = 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) } diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugins/Routing.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugins/Routing.kt index be07c2dd0..a1fa79cf9 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugins/Routing.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/plugins/Routing.kt @@ -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() diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/scheduling/RecurringTask.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/scheduling/RecurringTask.kt index a4a938cd2..c9118a7ab 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/scheduling/RecurringTask.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/scheduling/RecurringTask.kt @@ -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]. diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/scheduling/RecurringTaskRegistry.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/scheduling/RecurringTaskRegistry.kt index 19c3db252..9a5524b51 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/scheduling/RecurringTaskRegistry.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/scheduling/RecurringTaskRegistry.kt @@ -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() diff --git a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/utilities/SResult.kt b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/utilities/SResult.kt index fd282e96a..1f44d135f 100644 --- a/server/src/main/kotlin/com/darkrockstudios/apps/hammer/utilities/SResult.kt +++ b/server/src/main/kotlin/com/darkrockstudios/apps/hammer/utilities/SResult.kt @@ -26,11 +26,11 @@ sealed class ServerResult { ) : ServerResult() { 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 { typealias Msg = ServerMessage -class ServerMessage private constructor( - @PropertyKey(resourceBundle = DEFAULT_RESOURCE_BUNDLE) res: R, - inArgs: List -) { - val r: R = res - val args: List = 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 + ) : ServerMessage() { + val r: R = res + val args: List = 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) } } diff --git a/server/src/main/resources/i18n/Messages_de.properties b/server/src/main/resources/i18n/Messages_de.properties index 668532d7e..6437ee60a 100644 --- a/server/src/main/resources/i18n/Messages_de.properties +++ b/server/src/main/resources/i18n/Messages_de.properties @@ -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 diff --git a/server/src/main/resources/i18n/Messages_en.properties b/server/src/main/resources/i18n/Messages_en.properties index bd0282994..b2442a664 100644 --- a/server/src/main/resources/i18n/Messages_en.properties +++ b/server/src/main/resources/i18n/Messages_en.properties @@ -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 diff --git a/server/src/main/resources/i18n/Messages_es.properties b/server/src/main/resources/i18n/Messages_es.properties index d3a1c8517..a395ef9b9 100644 --- a/server/src/main/resources/i18n/Messages_es.properties +++ b/server/src/main/resources/i18n/Messages_es.properties @@ -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 diff --git a/server/src/main/resources/i18n/Messages_fr.properties b/server/src/main/resources/i18n/Messages_fr.properties index bc6c87d74..b8389c719 100644 --- a/server/src/main/resources/i18n/Messages_fr.properties +++ b/server/src/main/resources/i18n/Messages_fr.properties @@ -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 diff --git a/server/src/main/resources/i18n/Messages_it.properties b/server/src/main/resources/i18n/Messages_it.properties index 44b49171a..c1bf9b3cb 100644 --- a/server/src/main/resources/i18n/Messages_it.properties +++ b/server/src/main/resources/i18n/Messages_it.properties @@ -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 diff --git a/server/src/main/resources/i18n/Messages_pt_BR.properties b/server/src/main/resources/i18n/Messages_pt_BR.properties index 8b228416a..1b48245b6 100644 --- a/server/src/main/resources/i18n/Messages_pt_BR.properties +++ b/server/src/main/resources/i18n/Messages_pt_BR.properties @@ -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 diff --git a/server/src/main/resources/i18n/Messages_uk.properties b/server/src/main/resources/i18n/Messages_uk.properties index aa03f4d0c..ab51a9e5a 100644 --- a/server/src/main/resources/i18n/Messages_uk.properties +++ b/server/src/main/resources/i18n/Messages_uk.properties @@ -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=Налаштування ел. пошти diff --git a/server/src/main/resources/templates/admin-patreon.mustache b/server/src/main/resources/templates/admin-patreon.mustache deleted file mode 100644 index 4986d151d..000000000 --- a/server/src/main/resources/templates/admin-patreon.mustache +++ /dev/null @@ -1,180 +0,0 @@ -{{> header}} - -
-
- {{> partials/admin-nav}} - -
- - - - -
-
-

{{msg.admin_patreon_status_title}}

-
- -
-
- {{msg.admin_patreon_enabled_label}}: - {{#patreonEnabled}} - {{msg.admin_patreon_enabled}} - {{/patreonEnabled}} - {{^patreonEnabled}} - {{msg.admin_patreon_disabled}} - {{/patreonEnabled}} -
-
- {{msg.admin_patreon_last_sync}}: - {{lastSync}} -
-
- {{msg.admin_patreon_members_count}}: - {{patreonMemberCount}} -
-
- {{msg.admin_patreon_members_with_accounts}}: - {{patreonMembersWithAccounts}} -
-
- -
- {{#patreonEnabled}} - - {{/patreonEnabled}} - {{^patreonEnabled}} - - {{/patreonEnabled}} -
-
- - -
-
-

{{msg.admin_patreon_settings_title}}

-
-

{{msg.admin_patreon_settings_description}}

- -
- -
- -
- -
- -

{{msg.admin_patreon_access_token_help}}

-
- -
-
- -
- -

{{msg.admin_patreon_campaign_id_help}}

-
-
- -
- -
-
- -
- -

{{msg.admin_patreon_webhook_secret_help}}

-
- -
-
- -
- -

{{msg.admin_patreon_url_help}}

-
- -
-
- -
- -

{{msg.admin_patreon_minimum_amount_help}}

-
- -
-
- -
- -

{{msg.admin_patreon_poll_interval_help}}

-
- -
-
- -
- -
- - -
-
-
- - -
-
-

{{msg.admin_patreon_webhook_title}}

-
-

{{msg.admin_patreon_webhook_description}}

- -
- -
- -
-
-
-
-
-
- -
-{{> footer}} diff --git a/server/src/main/resources/templates/footer.mustache b/server/src/main/resources/templates/footer.mustache index 888726592..aa3d21ace 100644 --- a/server/src/main/resources/templates/footer.mustache +++ b/server/src/main/resources/templates/footer.mustache @@ -48,11 +48,9 @@ - {{#patreonUrl}} - - - - {{/patreonUrl}} + {{#footerNoticeHtml}} + {{{footerNoticeHtml}}} + {{/footerNoticeHtml}}
diff --git a/server/src/main/resources/templates/login.mustache b/server/src/main/resources/templates/login.mustache index 74de98ffd..12e9ceb9e 100644 --- a/server/src/main/resources/templates/login.mustache +++ b/server/src/main/resources/templates/login.mustache @@ -37,17 +37,8 @@ {{msg.password_reset_forgot_link}}

- {{#patreonEnabled}} - - {{/patreonEnabled}} - {{^patreonEnabled}} + {{{allowedUsersNoticeHtml}}} + {{^allowedUsersNoticeProvided}} - {{/patreonEnabled}} + {{/allowedUsersNoticeProvided}} diff --git a/server/src/main/resources/templates/partials/admin-nav.mustache b/server/src/main/resources/templates/partials/admin-nav.mustache index 57a87f680..b7e45147b 100644 --- a/server/src/main/resources/templates/partials/admin-nav.mustache +++ b/server/src/main/resources/templates/partials/admin-nav.mustache @@ -24,14 +24,14 @@ {{msg.admin_nav_monitoring}} - {{#patreonFeatureEnabled}} + {{#pluginNavEntries}}
  • - - - {{msg.admin_nav_patreon}} + + + {{label}}
  • - {{/patreonFeatureEnabled}} + {{/pluginNavEntries}} {{#emailFeatureEnabled}}
  • diff --git a/server/src/main/resources/templates/partials/allowed-users.mustache b/server/src/main/resources/templates/partials/allowed-users.mustache index fc5aa3879..5e1b62b17 100644 --- a/server/src/main/resources/templates/partials/allowed-users.mustache +++ b/server/src/main/resources/templates/partials/allowed-users.mustache @@ -80,7 +80,7 @@ {{msg.admin_allowedusers_edit_button}} - {{^isPatreon}} + {{^sourceManaged}} - {{/isPatreon}} + {{/sourceManaged}} {{msg.home_servermessage_allowedusers}}

    {{/allowedUsersNotice}} + {{#allowedUsersBannerHtml}} +
    + {{{allowedUsersBannerHtml}}} +
    + {{/allowedUsersBannerHtml}} {{/hasInstanceNotice}} diff --git a/server/src/main/resources/templates/signup.mustache b/server/src/main/resources/templates/signup.mustache index 8de9da54b..fc0253859 100644 --- a/server/src/main/resources/templates/signup.mustache +++ b/server/src/main/resources/templates/signup.mustache @@ -52,13 +52,16 @@
    diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountsComponentCreateAccountTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountsComponentCreateAccountTest.kt index 732775db0..ed5ec97b1 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountsComponentCreateAccountTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountsComponentCreateAccountTest.kt @@ -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 diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountsComponentLoginTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountsComponentLoginTest.kt index e306d65f4..8edbcc0d5 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountsComponentLoginTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountsComponentLoginTest.kt @@ -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) diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountsRepositoryTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountsRepositoryTest.kt index 51e132858..2ca95af6f 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountsRepositoryTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/account/AccountsRepositoryTest.kt @@ -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", ) } diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/AdminWhitelistExpiryPageTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/AdminWhitelistExpiryPageTest.kt index 410ab7511..10ddbf525 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/AdminWhitelistExpiryPageTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/e2e/AdminWhitelistExpiryPageTest.kt @@ -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" + } } diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/FormatSyncDateTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/FormatSyncDateTest.kt index 913ef82ca..313f37b31 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/FormatSyncDateTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/FormatSyncDateTest.kt @@ -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") - } } diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/HomePageTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/HomePageTest.kt index 85880ac04..940361cd3 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/HomePageTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/HomePageTest.kt @@ -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(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" } diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/MessageBundleParityTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/MessageBundleParityTest.kt index e88f7b411..1296a3b5e 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/MessageBundleParityTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/frontend/utils/MessageBundleParityTest.kt @@ -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 { + 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 = + 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 = + loadProperties(File(i18nDir, "Messages_en.properties")).stringPropertyNames() + + /** Templates read the msg map, which merges plugin bundles, so the union applies. */ + private fun allEnglishKeys(): Set = + englishBundleFiles().flatMapTo(mutableSetOf()) { loadProperties(it).stringPropertyNames() } + private fun filesUnder(root: File, extension: String): List = root.walkTopDown().filter { it.isFile && it.extension == extension }.toList() - private fun referencedKeys(): Map { + private fun sourceReferences(): Map { val references = mutableMapOf() - filesUnder(kotlinSources, "kt").forEach { file -> val text = file.readText() callSitePatterns.forEach { pattern -> @@ -55,14 +64,17 @@ class MessageBundleParityTest { } } } + return references + } + private fun templateReferences(): Map { + val references = mutableMapOf() 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()}") } diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/monitoring/MonitoringExportRouteTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/monitoring/MonitoringExportRouteTest.kt index 8778dbe76..feda3540c 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/monitoring/MonitoringExportRouteTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/monitoring/MonitoringExportRouteTest.kt @@ -63,8 +63,6 @@ class MonitoringExportRouteTest { projectsSyncManager = mockk>(relaxed = true), projectSyncManager = mockk>(relaxed = true), clock = clock, - patreonFeatureEnabled = false, - emailFeatureEnabled = false, ) } } diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/monitoring/MonitoringIgnoreRouteTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/monitoring/MonitoringIgnoreRouteTest.kt index 89f9ab8a8..5782b0ed4 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/monitoring/MonitoringIgnoreRouteTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/monitoring/MonitoringIgnoreRouteTest.kt @@ -63,8 +63,6 @@ class MonitoringIgnoreRouteTest : BaseTest() { relaxed = true ), clock = clock, - patreonFeatureEnabled = false, - emailFeatureEnabled = false, ) } } diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonApiClientTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonApiClientTest.kt deleted file mode 100644 index ca8fbbf67..000000000 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonApiClientTest.kt +++ /dev/null @@ -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) - } -} diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonSyncServiceTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonSyncServiceTest.kt deleted file mode 100644 index 2f6d62a5a..000000000 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonSyncServiceTest.kt +++ /dev/null @@ -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(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) - } -} diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonWebhookHandlerTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonWebhookHandlerTest.kt deleted file mode 100644 index 8e7bd2e97..000000000 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/patreon/PatreonWebhookHandlerTest.kt +++ /dev/null @@ -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() } - } -} diff --git a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/utils/BaseTest.kt b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/utils/BaseTest.kt index 14e70c26d..c02fc49f6 100644 --- a/server/src/test/kotlin/com/darkrockstudios/apps/hammer/utils/BaseTest.kt +++ b/server/src/test/kotlin/com/darkrockstudios/apps/hammer/utils/BaseTest.kt @@ -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(named(DISPATCHER_DEFAULT)) { StandardTestDispatcher( baseTest.scope.testScheduler, diff --git a/server/src/testFixtures/kotlin/com/darkrockstudios/apps/hammer/e2e/util/EndToEndTest.kt b/server/src/testFixtures/kotlin/com/darkrockstudios/apps/hammer/e2e/util/EndToEndTest.kt index 59d9c6308..bee1f99bc 100644 --- a/server/src/testFixtures/kotlin/com/darkrockstudios/apps/hammer/e2e/util/EndToEndTest.kt +++ b/server/src/testFixtures/kotlin/com/darkrockstudios/apps/hammer/e2e/util/EndToEndTest.kt @@ -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 = 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()