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
{{msg.login_signup_prompt}} {{msg.login_signup_link}}
- {{#patreonEnabled}}
-
- {{/patreonEnabled}}
- {{^patreonEnabled}}
+ {{{allowedUsersNoticeHtml}}}
+ {{^allowedUsersNoticeProvided}}
{{msg.login_info_allowedusers_notice}}
{{#contactEmail}}
@@ -55,7 +46,7 @@
href="mailto:{{contactEmail}}">{{contactEmail}}
{{/contactEmail}}
- {{/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}}