Extract a generic interface for user gating

Simplify allowed user management.
This commit is contained in:
Wavesonics 2026-08-20 21:17:45 -07:00
parent d424a346ed
commit 9ead030ab9
No known key found for this signature in database
62 changed files with 468 additions and 2205 deletions

View file

@ -13,7 +13,9 @@ import com.darkrockstudios.apps.hammer.frontend.configureFrontEnd
import com.darkrockstudios.apps.hammer.monitoring.configureApiMetrics
import com.darkrockstudios.apps.hammer.monitoring.configureMonitoringJob
import com.darkrockstudios.apps.hammer.monitoring.configureRouteTemplateCapture
import com.darkrockstudios.apps.hammer.patreon.configurePatreonPolling
import com.darkrockstudios.apps.hammer.plugin.ServerPlugin
import com.darkrockstudios.apps.hammer.plugin.installedPlugins
import com.darkrockstudios.apps.hammer.plugin.launchPluginTasks
import com.darkrockstudios.apps.hammer.plugins.SetupModePlugin
import com.darkrockstudios.apps.hammer.plugins.configureDependencyInjection
import com.darkrockstudios.apps.hammer.plugins.configureHTTP
@ -414,8 +416,9 @@ fun Application.appMain(
config: ServerConfig,
addInModule: Module? = null,
logLevel: Level? = null,
plugins: List<ServerPlugin> = installedPlugins(),
) {
configureDependencyInjection(config, addInModule)
configureDependencyInjection(config, addInModule, plugins)
val encryptionBootstrap: EncryptionBootstrap by inject()
runBlocking { encryptionBootstrap.run() }
// Must complete before any route is installed: routes enforce the allowed
@ -432,7 +435,7 @@ fun Application.appMain(
install(SetupModePlugin)
configureRouting(config)
configureFrontEnd()
configurePatreonPolling(config)
launchPluginTasks()
configureMonitoringJob()
configureTokenMaintenanceJob()
configureWhitelistExpiryJob()

View file

@ -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.

View file

@ -1,13 +1,10 @@
package com.darkrockstudios.apps.hammer.account
import com.darkrockstudios.apps.hammer.Account
import com.darkrockstudios.apps.hammer.ServerConfig
import com.darkrockstudios.apps.hammer.admin.AdminServerConfig
import com.darkrockstudios.apps.hammer.admin.ConfigRepository
import com.darkrockstudios.apps.hammer.admin.WhiteListRepository
import com.darkrockstudios.apps.hammer.base.http.TermsOfServiceChallenge
import com.darkrockstudios.apps.hammer.base.http.Token
import com.darkrockstudios.apps.hammer.patreon.PatreonConfig
import com.darkrockstudios.apps.hammer.plugin.PluginRegistry
import com.darkrockstudios.apps.hammer.projects.ProjectsRepository
import com.darkrockstudios.apps.hammer.utilities.Msg
import com.darkrockstudios.apps.hammer.utilities.SResult
@ -18,9 +15,8 @@ class AccountsComponent(
private val accountsRepository: AccountsRepository,
private val whiteListRepository: WhiteListRepository,
private val projectsRepository: ProjectsRepository,
private val configRepository: ConfigRepository,
private val termsOfServiceRepository: TermsOfServiceRepository,
private val serverConfig: ServerConfig,
private val pluginRegistry: PluginRegistry,
) {
suspend fun createAccount(
email: String,
@ -96,26 +92,12 @@ class AccountsComponent(
whiteListRepository.isOnWhiteList(account.email).not()
}
private suspend fun getActivePatreonConfig(): PatreonConfig? {
if (serverConfig.patreonEnabled != true) return null
val config = configRepository.get(AdminServerConfig.PATREON_CONFIG)
return if (config.enabled && config.patreonUrl.isNotBlank()) config else null
}
private suspend fun whiteListRejectedFailure(): ServerResult.Failure<Token> {
val patreonConfig = getActivePatreonConfig()
return if (patreonConfig != null) {
val amount = "%.2f".format(patreonConfig.minimumAmountCents / 100.0)
SResult.failure(
error = "User not on whitelist - Patreon subscription required",
displayMessage = Msg.r("login_failure_patreon_notice_message", amount, patreonConfig.patreonUrl)
)
} else {
SResult.failure(
error = "User not on whitelist",
displayMessage = Msg.r("api_allowedusers_rejected")
)
}
val message = pluginRegistry.activeAllowedUsersSource()?.rejectionMessage()
return SResult.failure(
error = "User not on whitelist",
displayMessage = message ?: Msg.r("api_allowedusers_rejected")
)
}
}

View file

@ -6,7 +6,6 @@ import com.darkrockstudios.apps.hammer.email.SendGridConfig
import com.darkrockstudios.apps.hammer.email.SmtpConfig
import com.darkrockstudios.apps.hammer.monitoring.IgnoredErrorRule
import com.darkrockstudios.apps.hammer.monitoring.MonitoringConfig
import com.darkrockstudios.apps.hammer.patreon.PatreonConfig
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
@ -29,12 +28,6 @@ object AdminServerConfig {
val ABOUT_SERVER = ServerConfigKey.string("about_server", "")
val CONTACT_EMAIL = ServerConfigKey.string("contact_email", "")
val DEFAULT_LOCALE = ServerConfigKey.string("default_locale", "en")
val PATREON_CONFIG = ServerConfigKey(
key = "patreon_config",
default = PatreonConfig(),
parse = { Json.decodeFromString<PatreonConfig>(it) },
serialize = { Json.encodeToString(PatreonConfig.serializer(), it) }
)
val SMTP_CONFIG = ServerConfigKey(
key = "smtp_config",
default = SmtpConfig(),

View file

@ -75,10 +75,6 @@ import com.darkrockstudios.apps.hammer.monitoring.StoryReaderCollector
import com.darkrockstudios.apps.hammer.monitoring.StoryReaderRepository
import com.darkrockstudios.apps.hammer.monitoring.UserActivityCollector
import com.darkrockstudios.apps.hammer.monitoring.UserActivityRepository
import com.darkrockstudios.apps.hammer.patreon.PatreonApiClient
import com.darkrockstudios.apps.hammer.patreon.PatreonPollingJob
import com.darkrockstudios.apps.hammer.patreon.PatreonSyncService
import com.darkrockstudios.apps.hammer.patreon.PatreonWebhookHandler
import com.darkrockstudios.apps.hammer.project.ProjectEntityDatabaseDatasource
import com.darkrockstudios.apps.hammer.project.ProjectEntityDatasource
import com.darkrockstudios.apps.hammer.project.ProjectEntityRepository
@ -300,10 +296,6 @@ fun mainModule(
single<AdminComponent>()
single<AccountsComponent>()
single<PatreonApiClient>()
single<PatreonSyncService>()
single<PatreonWebhookHandler>()
single<PatreonPollingJob>()
single<ServerSceneSynchronizer>()
single<ServerNoteSynchronizer>()

View file

@ -34,7 +34,9 @@ import com.darkrockstudios.apps.hammer.monitoring.UserActivityRepository
import com.darkrockstudios.apps.hammer.monitoring.isClientAbort
import com.darkrockstudios.apps.hammer.monitoring.recordMonitoredError
import com.darkrockstudios.apps.hammer.monitoring.toMonitoredStatus
import com.darkrockstudios.apps.hammer.patreon.PatreonSyncService
import com.darkrockstudios.apps.hammer.plugin.NoticeSlot
import com.darkrockstudios.apps.hammer.plugin.pluginAdminNav
import com.darkrockstudios.apps.hammer.plugin.putAllowedUsersNotice
import com.darkrockstudios.apps.hammer.plugins.configureTemplating
import com.darkrockstudios.apps.hammer.project.ProjectSyncKey
import com.darkrockstudios.apps.hammer.project.ProjectSynchronizationSession
@ -105,12 +107,6 @@ fun Route.frontend() {
val projectsSyncManager: SyncSessionManager<Long, ProjectsSynchronizationSession> by inject(named(PROJECTS_SYNC_MANAGER))
val projectSyncManager: SyncSessionManager<ProjectSyncKey, ProjectSynchronizationSession> by inject(named(PROJECT_SYNC_MANAGER))
// Only inject PatreonSyncService if Patreon is enabled at server level
val patreonSyncService: PatreonSyncService? = if (serverConfig.patreonEnabled == true) {
inject<PatreonSyncService>().value
} else {
null
}
// Only inject EmailService if email is enabled at server level
val emailService: EmailService? = if (serverConfig.emailProviderType != null) {
@ -126,12 +122,12 @@ fun Route.frontend() {
robotsRoutes()
sitemapRoutes(serverConfig, accountsRepository, projectAccessRepository, configRepository)
setupPage(serverConfig)
homePage(configRepository, serverConfig, markdownService)
homePage(configRepository, markdownService)
aboutPage(configRepository, serverConfig, accountsRepository, projectAccessRepository, markdownService)
termsOfServicePage()
privacyPolicyPage()
localeRoutes()
authRoutes(accountsRepository, whiteListRepository, configRepository, serverConfig, securityRepository)
authRoutes(accountsRepository, whiteListRepository, configRepository, securityRepository)
signupPage(
accountsComponent,
accountsRepository,
@ -191,7 +187,6 @@ fun Route.frontend() {
projectsRepository,
accountDeletionService,
serverConfig,
patreonSyncService,
emailService,
metricsRepository,
errorRepository,
@ -386,6 +381,13 @@ suspend fun ApplicationCall.withDefaults(data: Map<String, Any> = emptyMap()): M
val serverConfig = get<ServerConfig>()
// Only admin templates render the admin-nav partial that consumes these.
if (request.path().startsWith("/admin")) {
// Not putIfAbsent: the argument would compute the nav even when the key is present.
if ("pluginNavEntries" !in model) model["pluginNavEntries"] = pluginAdminNav()
model.putIfAbsent("emailFeatureEnabled", serverConfig.emailProviderType != null)
}
val configRepository = get<ConfigRepository>()
val aboutContent = configRepository.get(AdminServerConfig.ABOUT_SERVER)
val hasAboutPage = aboutContent.isNotBlank()
@ -404,13 +406,7 @@ suspend fun ApplicationCall.withDefaults(data: Map<String, Any> = emptyMap()): M
model["footerExtraLinks"] = footerExtraLinks
model["hasFooterNav"] = hasAboutPage || hasTermsPage || hasPrivacyPage || footerExtraLinks.isNotEmpty()
// Add Patreon link for footer if configured
if (serverConfig.patreonEnabled == true) {
val patreonConfig = configRepository.get(AdminServerConfig.PATREON_CONFIG)
if (patreonConfig.enabled && patreonConfig.patreonUrl.isNotBlank()) {
model["patreonUrl"] = patreonConfig.patreonUrl
}
}
putAllowedUsersNotice(model, NoticeSlot.FOOTER, htmlKey = "footerNoticeHtml", providedKey = "footerNoticeProvided")
// Add community enabled flag for header nav
if (serverConfig.communityEnabled) {

View file

@ -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))
}

View file

@ -1,6 +1,5 @@
package com.darkrockstudios.apps.hammer.frontend
import com.darkrockstudios.apps.hammer.ServerConfig
import com.darkrockstudios.apps.hammer.account.AccountsRepository
import com.darkrockstudios.apps.hammer.admin.AdminServerConfig
import com.darkrockstudios.apps.hammer.admin.ConfigRepository
@ -9,6 +8,9 @@ import com.darkrockstudios.apps.hammer.frontend.data.UserSession
import com.darkrockstudios.apps.hammer.monitoring.SecurityRepository
import com.darkrockstudios.apps.hammer.plugins.LOGIN_RATE_LIMIT
import com.darkrockstudios.apps.hammer.utilities.isSuccess
import com.darkrockstudios.apps.hammer.plugin.NoticeSlot
import com.darkrockstudios.apps.hammer.plugin.activeAllowedUsersSource
import com.darkrockstudios.apps.hammer.plugin.putAllowedUsersNotice
import com.github.aymanizz.ktori18n.R
import com.github.aymanizz.ktori18n.t
import io.ktor.server.mustache.*
@ -23,10 +25,9 @@ fun Route.authRoutes(
accountsRepository: AccountsRepository,
whiteListRepository: WhiteListRepository,
configRepository: ConfigRepository,
serverConfig: ServerConfig,
securityRepository: SecurityRepository,
) {
loginPage(accountsRepository, whiteListRepository, configRepository, serverConfig, securityRepository)
loginPage(accountsRepository, whiteListRepository, configRepository, securityRepository)
logout()
unauthorized()
}
@ -35,7 +36,6 @@ private fun Route.loginPage(
accountsRepository: AccountsRepository,
whiteListRepository: WhiteListRepository,
configRepository: ConfigRepository,
serverConfig: ServerConfig,
securityRepository: SecurityRepository,
) {
route("/login") {
@ -47,7 +47,7 @@ private fun Route.loginPage(
// Drop a present-but-unauthorized cookie so the user isn't bounced
// straight back to /dashboard and into a redirect loop.
if (session != null) call.sessions.clear<UserSession>()
val model = buildLoginModel(call, configRepository, serverConfig)
val model = buildLoginModel(call, configRepository)
call.respond(MustacheContent("login.mustache", call.withDefaults(model)))
}
}
@ -93,11 +93,13 @@ private fun Route.loginPage(
call.sessions.set(session)
call.respondRedirect("/dashboard")
} else {
val model = buildLoginModel(call, configRepository, serverConfig)
val model = buildLoginModel(call, configRepository)
.toMutableMap()
// Valid credentials without a whitelist entry get no session, because
// /dashboard would reject it and loop back here.
model["message"] = credentialsError ?: call.t(R("api_allowedusers_rejected"))
model["message"] = credentialsError
?: call.activeAllowedUsersSource()?.rejectionMessage()?.text(call)
?: call.t(R("api_allowedusers_rejected"))
call.respond(MustacheContent("login.mustache", call.withDefaults(model)))
}
}
@ -108,24 +110,15 @@ private fun Route.loginPage(
private suspend fun buildLoginModel(
call: RoutingCall,
configRepository: ConfigRepository,
serverConfig: ServerConfig
): Map<String, Any> {
val contactEmail = configRepository.get(AdminServerConfig.CONTACT_EMAIL)
val patreonConfig = configRepository.get(AdminServerConfig.PATREON_CONFIG)
val patreonFeatureEnabled = serverConfig.patreonEnabled == true
val patreonActive = patreonFeatureEnabled && patreonConfig.enabled && patreonConfig.patreonUrl.isNotBlank()
return buildMap {
put("page_stylesheet", "/assets/css/login.css")
if (contactEmail.isNotBlank()) {
put("contactEmail", contactEmail)
}
if (patreonActive) {
val amount = "%.2f".format(patreonConfig.minimumAmountCents / 100.0)
put("patreonEnabled", true)
put("patreonUrl", patreonConfig.patreonUrl)
put("patreonMessage", call.t(R("login_patreon_notice_message"), amount))
}
call.putAllowedUsersNotice(this, NoticeSlot.LOGIN)
}
}

View file

@ -71,8 +71,6 @@ fun Route.adminMonitoringPages(
projectsSyncManager: SyncSessionManager<Long, ProjectsSynchronizationSession>,
projectSyncManager: SyncSessionManager<*, ProjectSynchronizationSession>,
clock: Clock,
patreonFeatureEnabled: Boolean,
emailFeatureEnabled: Boolean,
) {
route("/monitoring") {
get {
@ -108,8 +106,6 @@ fun Route.adminMonitoringPages(
"page_stylesheet" to "/assets/css/admin.css",
"activeMonitoring" to true,
"activeMonOverview" to true,
"patreonFeatureEnabled" to patreonFeatureEnabled,
"emailFeatureEnabled" to emailFeatureEnabled,
"monitoringEnabled" to enabled,
"statRequests" to formatCount(totals.requestCount),
"statErrorRate" to formatPercent(totals.errorRate),
@ -151,8 +147,6 @@ fun Route.adminMonitoringPages(
"page_stylesheet" to "/assets/css/admin.css",
"activeMonitoring" to true,
"activeMonPerformance" to true,
"patreonFeatureEnabled" to patreonFeatureEnabled,
"emailFeatureEnabled" to emailFeatureEnabled,
"range24h" to (range == RANGE_24H),
"range7d" to (range == RANGE_7D),
"range30d" to (range == RANGE_30D),
@ -205,8 +199,6 @@ fun Route.adminMonitoringPages(
"page_stylesheet" to "/assets/css/admin.css",
"activeMonitoring" to true,
"activeMonErrors" to true,
"patreonFeatureEnabled" to patreonFeatureEnabled,
"emailFeatureEnabled" to emailFeatureEnabled,
"range24h" to (range == RANGE_24H),
"range7d" to (range == RANGE_7D),
"range30d" to (range == RANGE_30D),
@ -310,8 +302,6 @@ fun Route.adminMonitoringPages(
"page_stylesheet" to "/assets/css/admin.css",
"activeMonitoring" to true,
"activeMonSecurity" to true,
"patreonFeatureEnabled" to patreonFeatureEnabled,
"emailFeatureEnabled" to emailFeatureEnabled,
"topFailures" to topFailures,
"hasTopFailures" to topFailures.isNotEmpty(),
"attempts" to attempts,
@ -327,8 +317,6 @@ fun Route.adminMonitoringPages(
"page_stylesheet" to "/assets/css/admin.css",
"activeMonitoring" to true,
"activeMonJobs" to true,
"patreonFeatureEnabled" to patreonFeatureEnabled,
"emailFeatureEnabled" to emailFeatureEnabled,
"jobs" to jobs,
"hasJobs" to jobs.isNotEmpty(),
"anyFailing" to jobs.any { it["failing"] == true },
@ -341,8 +329,6 @@ fun Route.adminMonitoringPages(
"page_stylesheet" to "/assets/css/admin.css",
"activeMonitoring" to true,
"activeMonLogs" to true,
"patreonFeatureEnabled" to patreonFeatureEnabled,
"emailFeatureEnabled" to emailFeatureEnabled,
)
call.respond(MustacheContent("admin-monitoring-logs.mustache", call.withDefaults(model)))
}

View file

@ -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

View file

@ -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,

View file

@ -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),

View file

@ -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)))
}
}

View file

@ -3,7 +3,7 @@ package com.darkrockstudios.apps.hammer.frontend.admin
import com.darkrockstudios.apps.hammer.admin.WhiteListRepository
import com.darkrockstudios.apps.hammer.frontend.utils.msg
import com.darkrockstudios.apps.hammer.frontend.withDefaults
import com.darkrockstudios.apps.hammer.patreon.PatreonSyncService
import com.darkrockstudios.apps.hammer.plugin.PluginRegistry
import io.ktor.htmx.*
import io.ktor.http.*
import io.ktor.server.application.*
@ -12,6 +12,7 @@ import io.ktor.server.mustache.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.koin.ktor.ext.get
import io.ktor.utils.io.*
import java.net.URLEncoder
import java.time.LocalDate
@ -260,6 +261,7 @@ internal suspend fun getWhitelistModel(
sortOldestFirst: Boolean? = null,
search: String? = null,
): MutableMap<String, Any> {
val sourceIds = call.application.get<PluginRegistry>().allowedUsersSourceIds
val queryPage = call.request.queryParameters["page"]?.toIntOrNull()
val actualPage = page ?: queryPage ?: 0
@ -290,8 +292,8 @@ internal suspend fun getWhitelistModel(
?: call.msg("admin_allowedusers_expiry_never")),
// Prefills the edit dialog's <input type="date">, which only accepts yyyy-MM-dd.
"expiresRaw" to (entry.expires?.let { formatDateInputValue(it) } ?: ""),
// Patreon sync owns its entries' lifecycle, so expiry isn't the admin's to set.
"isPatreon" to (entry.reason == PatreonSyncService.WHITELIST_REASON),
// A syncing source owns its entries' lifecycle, so expiry isn't the admin's to set.
"sourceManaged" to (entry.reason in sourceIds),
)
}

View file

@ -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,

View file

@ -3,6 +3,7 @@ package com.darkrockstudios.apps.hammer.frontend.utils
import com.darkrockstudios.apps.hammer.admin.AdminServerConfig
import com.darkrockstudios.apps.hammer.admin.ConfigRepository
import com.darkrockstudios.apps.hammer.frontend.LOCALE_COOKIE_NAME
import com.darkrockstudios.apps.hammer.plugin.PluginRegistry
import com.darkrockstudios.apps.hammer.utilities.ResUtils
import io.ktor.http.*
import io.ktor.server.application.*
@ -87,6 +88,19 @@ suspend fun ApplicationCall.withMessages(data: Map<String, Any> = emptyMap()): M
messages[key] = bundle.getString(key)
}
// Plugin bundles layer on top, same English-baseline rule per bundle.
val pluginRegistry = runCatching { application.get<PluginRegistry>() }.getOrNull()
pluginRegistry?.plugins?.mapNotNull { it.messageBundle() }?.forEach { bundleName ->
val pluginEnglish = ResourceBundle.getBundle(bundleName, Locale.ENGLISH)
pluginEnglish.keys.asSequence().forEach { key ->
messages[key] = pluginEnglish.getString(key)
}
val pluginLocalized = ResourceBundle.getBundle(bundleName, locale)
pluginLocalized.keys.asSequence().forEach { key ->
messages[key] = pluginLocalized.getString(key)
}
}
val availableLocales = ResUtils.getTranslatedLocales()
val localesForTemplate = availableLocales.map { lc ->
mapOf(

View file

@ -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.

View file

@ -1,191 +0,0 @@
package com.darkrockstudios.apps.hammer.patreon
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.java.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.slf4j.Logger
import java.security.MessageDigest
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
@Serializable
data class PatreonMemberAttributes(
val email: String? = null,
val patron_status: String? = null,
val currently_entitled_amount_cents: Int = 0
)
@Serializable
data class PatreonMemberData(
val id: String,
val attributes: PatreonMemberAttributes? = null
)
@Serializable
data class PatreonPaginationCursors(
val next: String? = null
)
@Serializable
data class PatreonPagination(
val cursors: PatreonPaginationCursors? = null
)
@Serializable
data class PatreonMeta(
val pagination: PatreonPagination? = null
)
@Serializable
data class PatreonMembersResponse(
val data: List<PatreonMemberData> = emptyList(),
val meta: PatreonMeta? = null
)
@Serializable
data class PatreonCampaignData(
val id: String,
val type: String
)
@Serializable
data class PatreonCampaignsResponse(
val data: List<PatreonCampaignData> = emptyList()
)
class PatreonApiClient(
private val logger: Logger
) {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
}
private val httpClient = HttpClient(Java) {
install(ContentNegotiation) {
json(json)
}
install(Logging) {
level = LogLevel.INFO
}
}
suspend fun fetchAllQualifyingMembers(
campaignId: String,
accessToken: String,
minimumAmountCents: Int
): Result<List<String>> {
val allEmails = mutableListOf<String>()
var cursor: String? = null
try {
do {
val response = fetchMembersPage(campaignId, accessToken, cursor)
response.data.forEach { member ->
val attributes = member.attributes
if (attributes != null &&
attributes.patron_status == "active_patron" &&
attributes.currently_entitled_amount_cents >= minimumAmountCents &&
!attributes.email.isNullOrBlank()
) {
allEmails.add(attributes.email)
}
}
cursor = response.meta?.pagination?.cursors?.next
} while (cursor != null)
return Result.success(allEmails)
// API boundary: any failure becomes a failed Result.
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
logger.error("Failed to fetch Patreon members", e)
return Result.failure(e)
}
}
private suspend fun fetchMembersPage(
campaignId: String,
accessToken: String,
cursor: String? = null
): PatreonMembersResponse {
val url = buildString {
append("https://www.patreon.com/api/oauth2/v2/campaigns/$campaignId/members")
append("?fields[member]=email,patron_status,currently_entitled_amount_cents")
append("&page[count]=1000")
if (cursor != null) {
append("&page[cursor]=$cursor")
}
}
val response = httpClient.get(url) {
header(HttpHeaders.Authorization, "Bearer $accessToken")
header(HttpHeaders.UserAgent, "HammerServer/1.0")
}
if (!response.status.isSuccess()) {
throw PatreonApiException("Patreon API returned ${response.status}")
}
return response.body()
}
suspend fun fetchCampaignId(accessToken: String): Result<String> {
return try {
val url = "https://www.patreon.com/api/oauth2/v2/campaigns"
val response = httpClient.get(url) {
header(HttpHeaders.Authorization, "Bearer $accessToken")
header(HttpHeaders.UserAgent, "HammerServer/1.0")
}
if (!response.status.isSuccess()) {
return Result.failure(PatreonApiException("Patreon API returned ${response.status}"))
}
val campaignsResponse: PatreonCampaignsResponse = response.body()
if (campaignsResponse.data.isEmpty()) {
return Result.failure(PatreonApiException("No campaigns found for this account"))
}
// Return the first campaign ID
Result.success(campaignsResponse.data.first().id)
} catch (e: Exception) {
logger.error("Failed to fetch campaign ID", e)
Result.failure(e)
}
}
fun verifyWebhookSignature(payload: String, signature: String, secret: String): Boolean {
return try {
val mac = Mac.getInstance("HmacMD5")
val secretKey = SecretKeySpec(secret.toByteArray(), "HmacMD5")
mac.init(secretKey)
val hash = mac.doFinal(payload.toByteArray())
val computedSignature = hash.joinToString("") { "%02x".format(it) }
// Constant-time compare: a byte-by-byte String.equals leaks, via timing,
// how long a prefix of the expected signature an attacker guessed right.
MessageDigest.isEqual(
computedSignature.toByteArray(Charsets.UTF_8),
signature.lowercase().toByteArray(Charsets.UTF_8),
)
} catch (e: Exception) {
logger.error("Failed to verify webhook signature", e)
false
}
}
fun close() {
httpClient.close()
}
}
class PatreonApiException(message: String) : Exception(message)

View file

@ -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 = ""
)

View file

@ -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
}

View file

@ -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)
}
}
}
}

View file

@ -1,141 +0,0 @@
package com.darkrockstudios.apps.hammer.patreon
import com.darkrockstudios.apps.hammer.account.AccountsRepository
import com.darkrockstudios.apps.hammer.admin.AdminServerConfig
import com.darkrockstudios.apps.hammer.admin.ConfigRepository
import com.darkrockstudios.apps.hammer.admin.WhiteListRepository
import io.ktor.util.*
import kotlinx.serialization.Serializable
import org.slf4j.Logger
import kotlin.time.Clock
@Serializable
data class SyncResult(
val added: Int,
val removed: Int,
val skipped: Int,
val errors: List<String> = emptyList()
)
class PatreonSyncService(
private val patreonApiClient: PatreonApiClient,
private val whiteListRepository: WhiteListRepository,
private val accountsRepository: AccountsRepository,
private val configRepository: ConfigRepository,
private val clock: Clock,
private val logger: Logger
) {
companion object {
const val WHITELIST_REASON = "Patreon"
}
suspend fun performFullSync(): Result<SyncResult> {
val config = configRepository.get(AdminServerConfig.PATREON_CONFIG)
if (!config.enabled) {
logger.info("Patreon sync skipped - disabled in config")
return Result.success(SyncResult(added = 0, removed = 0, skipped = 0))
}
if (config.campaignId.isBlank() || config.creatorAccessToken.isBlank()) {
logger.warn("Patreon sync skipped - missing campaign ID or access token")
return Result.failure(PatreonSyncException("Missing campaign ID or access token"))
}
logger.info("Starting Patreon sync for campaign ${config.campaignId}")
// 1. Get qualifying patrons from Patreon API
val patreonResult = patreonApiClient.fetchAllQualifyingMembers(
campaignId = config.campaignId,
accessToken = config.creatorAccessToken,
minimumAmountCents = config.minimumAmountCents
)
if (patreonResult.isFailure) {
return Result.failure(patreonResult.exceptionOrNull()!!)
}
val patreonEmails = patreonResult.getOrThrow()
.map { it.trim().toLowerCasePreservingASCIIRules() }
.toSet()
logger.info("Found ${patreonEmails.size} qualifying Patreon members")
// 2. Get current Patreon-managed whitelist entries ONLY
val currentPatreonEntries = whiteListRepository.getWhiteListByReason(WHITELIST_REASON)
.map { it.email }
.toSet()
logger.info("Found ${currentPatreonEntries.size} existing Patreon whitelist entries")
// 3. Add new patrons (only if not already whitelisted with ANY reason)
var addedCount = 0
var skippedCount = 0
val errors = mutableListOf<String>()
for (email in patreonEmails) {
try {
if (!whiteListRepository.isOnWhiteList(email)) {
whiteListRepository.addToWhiteList(email, WHITELIST_REASON)
logger.info("Added Patreon member to whitelist: $email")
addedCount++
} else if (email !in currentPatreonEntries) {
// Already whitelisted with different reason - skip
logger.debug("Skipping Patreon member (already whitelisted): $email")
skippedCount++
}
// Per-member: one failure must not abort the whole sync.
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
logger.error("Failed to add Patreon member to whitelist: $email", e)
errors.add("Failed to add $email: ${e.message}")
}
}
// 4. Remove lapsed patrons + force logout (only Patreon-managed entries)
var removedCount = 0
val toRemove = currentPatreonEntries - patreonEmails
for (email in toRemove) {
try {
whiteListRepository.removeFromWhiteList(email)
forceLogout(email)
logger.info("Removed lapsed Patreon member from whitelist: $email")
removedCount++
} catch (e: Exception) {
logger.error("Failed to remove Patreon member from whitelist: $email", e)
errors.add("Failed to remove $email: ${e.message}")
}
}
// Update last sync time
val updatedConfig = config.copy(lastSync = clock.now().toString())
configRepository.set(AdminServerConfig.PATREON_CONFIG, updatedConfig)
val result = SyncResult(
added = addedCount,
removed = removedCount,
skipped = skippedCount,
errors = errors
)
logger.info("Patreon sync completed: added=$addedCount, removed=$removedCount, skipped=$skippedCount, errors=${errors.size}")
return Result.success(result)
}
suspend fun forceLogout(email: String) {
if (accountsRepository.forceLogout(email)) {
logger.info("Force logged out user: $email")
}
}
suspend fun getPatreonWhitelistCount(): Long {
return whiteListRepository.getWhiteListByReason(WHITELIST_REASON).size.toLong()
}
suspend fun getPatreonMembersWithAccountsCount(): Long {
return whiteListRepository.countByReasonWithAccounts(WHITELIST_REASON)
}
}
class PatreonSyncException(message: String) : Exception(message)

View file

@ -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()
}

View file

@ -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")
}
}

View file

@ -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?
}

View file

@ -0,0 +1,7 @@
package com.darkrockstudios.apps.hammer.plugin
/**
* The plugins compiled into this build. Downstream distributions register theirs here;
* keep this file otherwise untouched so their patch applies cleanly.
*/
fun installedPlugins(): List<ServerPlugin> = listOf()

View file

@ -0,0 +1,32 @@
package com.darkrockstudios.apps.hammer.plugin
import org.slf4j.Logger
/**
* The [installedPlugins] active in this build. Everything that consumes plugin
* contributions (routing, DI, admin UI, notices) goes through this.
*/
class PluginRegistry(val plugins: List<ServerPlugin>) {
// Lazy: the registry is built before Koin starts, and a source may inject dependencies
// when constructed. First access happens at request time, when the graph is up.
private val sources: List<AllowedUsersSource> by lazy { plugins.mapNotNull { it.allowedUsersSource() } }
/** Reason tags owned by a source, whose whitelist entries the admin must not hand-edit. */
val allowedUsersSourceIds: Set<String> by lazy { sources.map { it.id }.toSet() }
/**
* The allow-list source currently in effect, or null if none is active. With multiple
* active sources the first by registration order wins for notices; syncing is
* unaffected since each source only touches entries tagged with its own id.
*/
suspend fun activeAllowedUsersSource(): AllowedUsersSource? =
sources.firstOrNull { it.isActive() }
companion object {
fun create(plugins: List<ServerPlugin>, logger: Logger): PluginRegistry {
plugins.forEach { logger.info("Server plugin '${it.id}' installed") }
return PluginRegistry(plugins)
}
}
}

View file

@ -0,0 +1,81 @@
package com.darkrockstudios.apps.hammer.plugin
import com.darkrockstudios.apps.hammer.scheduling.launchRecurringTask
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.routing.*
import io.ktor.util.*
import org.koin.ktor.ext.get
import org.koin.ktor.ext.inject
/** Mounts every enabled plugin's public routes at the receiver. */
fun Route.installPluginRoutes() {
val pluginRegistry: PluginRegistry by application.inject()
pluginRegistry.plugins.forEach { it.installRoutes(this) }
}
/** Mounts every enabled plugin's admin routes at the receiver, inside the admin gate. */
fun Route.installPluginAdminRoutes() {
val pluginRegistry: PluginRegistry by application.inject()
pluginRegistry.plugins.forEach { it.installAdminRoutes(this) }
}
/** Starts every enabled plugin's recurring tasks. */
fun Application.launchPluginTasks() {
val pluginRegistry: PluginRegistry by inject()
pluginRegistry.plugins
.flatMap { it.recurringTasks(this) }
.forEach { launchRecurringTask(it) }
}
/**
* Every enabled plugin's admin nav entries as mustache model maps, marking the entry
* whose href the current request is under as active. Admin page models put this under
* `pluginNavEntries` for the admin-nav partial.
*/
suspend fun ApplicationCall.pluginAdminNav(): List<Map<String, Any>> {
val pluginRegistry: PluginRegistry = application.get()
val path = request.path()
return pluginRegistry.plugins
.flatMap { it.adminNavEntries() }
.map { entry ->
mapOf(
"href" to entry.href,
"icon" to entry.icon,
"label" to entry.label(this),
"active" to (path == entry.href || path.startsWith("${entry.href}/")),
)
}
}
/**
* The allow-list source currently in effect, or null if none is active. Resolved once per
* request: isActive() may cost a config read, and several model builders ask per render.
*/
suspend fun ApplicationCall.activeAllowedUsersSource(): AllowedUsersSource? {
attributes.getOrNull(activeSourceKey)?.let { return it.source }
val source = application.get<PluginRegistry>().activeAllowedUsersSource()
attributes.put(activeSourceKey, CachedActiveSource(source))
return source
}
private val activeSourceKey = AttributeKey<CachedActiveSource>("ActiveAllowedUsersSource")
private class CachedActiveSource(val source: AllowedUsersSource?)
/**
* Applies [AllowedUsersSource.notice] for [slot] to a template model: [providedKey] is set
* when an active source answered (suppressing the stock content), [htmlKey] only when there
* is markup to render. Returns the raw notice, null when no active source provided one.
*/
suspend fun ApplicationCall.putAllowedUsersNotice(
model: MutableMap<String, Any>,
slot: NoticeSlot,
htmlKey: String = "allowedUsersNoticeHtml",
providedKey: String = "allowedUsersNoticeProvided",
): String? {
val html = activeAllowedUsersSource()?.notice(this, slot) ?: return null
model[providedKey] = true
if (html.isNotBlank()) model[htmlKey] = html
return html
}

View file

@ -0,0 +1,54 @@
package com.darkrockstudios.apps.hammer.plugin
import com.darkrockstudios.apps.hammer.scheduling.RecurringTask
import io.ktor.server.application.*
import io.ktor.server.routing.*
import org.koin.core.module.Module
/**
* A server extension. Registering it in [installedPlugins] is what activates it; there is
* no separate enablement switch.
*
* Plugins compile as part of `:server`, so they may use anything in it, including Koin
* injection inside route handlers and tasks.
*/
interface ServerPlugin {
/** Stable identifier, also used as this plugin's allow-list reason tag. */
val id: String
/** Koin definitions for this plugin, installed alongside the main module. */
fun koinModule(): Module? = null
/** Public routes, mounted at the routing root. */
fun installRoutes(route: Route) {}
/** Admin routes, mounted under `/admin` inside the admin-only gate. */
fun installAdminRoutes(route: Route) {}
/** Entries appended to the admin navigation. */
fun adminNavEntries(): List<AdminNavEntry> = emptyList()
/**
* Base name of this plugin's resource bundle (e.g. `i18n.MyPluginMessages`), merged into
* every page's template msg map. Must resolve for Locale.ENGLISH, so ship a base or `_en`
* variant. Null contributes nothing.
*/
fun messageBundle(): String? = null
/** Background jobs to launch at startup. */
fun recurringTasks(application: Application): List<RecurringTask> = emptyList()
/** The allow-list source this plugin provides, if any. */
fun allowedUsersSource(): AllowedUsersSource? = null
}
/**
* @param href absolute path, e.g. `/admin/patreon`. Also used to mark the entry active.
* @param icon Font Awesome classes, e.g. `fa-brands fa-patreon`.
* @param label localized label for the current request.
*/
data class AdminNavEntry(
val href: String,
val icon: String,
val label: suspend (ApplicationCall) -> String,
)

View file

@ -3,6 +3,9 @@ package com.darkrockstudios.apps.hammer.plugins
import com.darkrockstudios.apps.hammer.ServerConfig
import com.darkrockstudios.apps.hammer.database.Database
import com.darkrockstudios.apps.hammer.dependencyinjection.mainModule
import com.darkrockstudios.apps.hammer.plugin.PluginRegistry
import com.darkrockstudios.apps.hammer.plugin.ServerPlugin
import com.darkrockstudios.apps.hammer.plugin.installedPlugins
import io.ktor.server.application.*
import org.koin.core.module.Module
import org.koin.dsl.module
@ -10,17 +13,25 @@ import org.koin.ktor.ext.get
import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger
fun Application.configureDependencyInjection(serverConfig: ServerConfig = ServerConfig(), addInModule: Module? = null) {
fun Application.configureDependencyInjection(
serverConfig: ServerConfig = ServerConfig(),
addInModule: Module? = null,
plugins: List<ServerPlugin> = installedPlugins(),
) {
val logger = log
val pluginRegistry = PluginRegistry.create(plugins, logger)
val serverConfigModule = module {
single { serverConfig }
single { pluginRegistry }
}
install(Koin) {
slf4jLogger()
val modules = mutableListOf(mainModule(logger), serverConfigModule)
pluginRegistry.plugins.mapNotNullTo(modules) { it.koinModule() }
if (addInModule != null) {
modules.add(addInModule)
}

View file

@ -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()

View file

@ -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].

View file

@ -5,8 +5,7 @@ import java.util.concurrent.CopyOnWriteArrayList
/**
* Holds the set of [RecurringTask]s that have been started, so the admin
* dashboard can report each one's status. Tasks register themselves when
* launched via [launchRecurringTask]; a task that never starts (e.g. Patreon
* polling when the integration is disabled at the server level) does not appear.
* launched via [launchRecurringTask]; a task that never starts does not appear.
*/
class RecurringTaskRegistry {
private val tasks = CopyOnWriteArrayList<RecurringTask>()

View file

@ -26,11 +26,11 @@ sealed class ServerResult<out T> {
) : ServerResult<T>() {
override val isSuccess = false
fun displayMessageText(call: ApplicationCall, default: R): String {
suspend fun displayMessageText(call: ApplicationCall, default: R): String {
return displayMessage?.text(call) ?: call.t(default)
}
fun displayMessageText(call: ApplicationCall): String? {
suspend fun displayMessageText(call: ApplicationCall): String? {
return displayMessage?.text(call)
}
}
@ -55,17 +55,33 @@ sealed class ServerResult<out T> {
typealias Msg = ServerMessage
class ServerMessage private constructor(
@PropertyKey(resourceBundle = DEFAULT_RESOURCE_BUNDLE) res: R,
inArgs: List<Any>
) {
val r: R = res
val args: List<Any> = inArgs
sealed class ServerMessage {
abstract suspend fun text(call: ApplicationCall): String
fun text(call: ApplicationCall): String = call.t(r, args)
class Resource internal constructor(
@PropertyKey(resourceBundle = DEFAULT_RESOURCE_BUNDLE) res: R,
inArgs: List<Any>
) : ServerMessage() {
val r: R = res
val args: List<Any> = inArgs
override suspend fun text(call: ApplicationCall): String = call.t(r, args)
}
/** Text already resolved by the caller, e.g. by a plugin from its own resource bundle. */
class Literal(private val value: String) : ServerMessage() {
override suspend fun text(call: ApplicationCall): String = value
}
/** Resolved per request, for messages living outside the default bundle. */
class Dynamic(private val resolve: suspend (ApplicationCall) -> String) : ServerMessage() {
override suspend fun text(call: ApplicationCall): String = resolve(call)
}
companion object {
fun r(key: String, vararg args: Any): ServerMessage = ServerMessage(R(key), args.toList())
fun r(key: String, vararg args: Any): ServerMessage = Resource(R(key), args.toList())
fun literal(text: String): ServerMessage = Literal(text)
fun dynamic(resolve: suspend (ApplicationCall) -> String): ServerMessage = Dynamic(resolve)
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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=Налаштування ел. пошти

View file

@ -1,180 +0,0 @@
{{> header}}
<main class="page">
<div class="admin-layout">
{{> partials/admin-nav}}
<div class="admin-content">
<!-- Admin Header -->
<div class="page-header">
<div class="page-header__content">
<h1 class="page-header__title">{{msg.admin_patreon_title}}</h1>
<p class="page-header__subtitle">{{msg.admin_patreon_subtitle}}</p>
</div>
</div>
<!-- Patreon Status Section -->
<section class="paper-section">
<div class="section-header">
<h2 class="section-header__title">{{msg.admin_patreon_status_title}}</h2>
</div>
<div class="status-info">
<div class="status-item">
<span class="status-label">{{msg.admin_patreon_enabled_label}}:</span>
{{#patreonEnabled}}
<span class="status-badge status-badge--active">{{msg.admin_patreon_enabled}}</span>
{{/patreonEnabled}}
{{^patreonEnabled}}
<span class="status-badge status-badge--inactive">{{msg.admin_patreon_disabled}}</span>
{{/patreonEnabled}}
</div>
<div class="status-item">
<span class="status-label">{{msg.admin_patreon_last_sync}}:</span>
<span>{{lastSync}}</span>
</div>
<div class="status-item">
<span class="status-label">{{msg.admin_patreon_members_count}}:</span>
<span>{{patreonMemberCount}}</span>
</div>
<div class="status-item">
<span class="status-label">{{msg.admin_patreon_members_with_accounts}}:</span>
<span>{{patreonMembersWithAccounts}}</span>
</div>
</div>
<div class="form-actions">
{{#patreonEnabled}}
<button type="button" class="btn btn--primary"
hx-post="/admin/patreon/sync"
hx-swap="none">
{{msg.admin_patreon_sync_now}}
</button>
{{/patreonEnabled}}
{{^patreonEnabled}}
<button type="button" class="btn btn--primary" disabled
title="{{msg.admin_patreon_sync_disabled_hint}}">
{{msg.admin_patreon_sync_now}}
</button>
{{/patreonEnabled}}
</div>
</section>
<!-- Patreon Settings Section -->
<section class="paper-section">
<div class="section-header">
<h2 class="section-header__title">{{msg.admin_patreon_settings_title}}</h2>
</div>
<p class="section-description">{{msg.admin_patreon_settings_description}}</p>
<form action="/admin/patreon/settings" method="post" class="settings-form"
hx-post="/admin/patreon/settings"
hx-swap="none">
<div class="form-group">
<label class="form-field__label">
<input type="checkbox" name="enabled" value="true"
{{#patreonEnabled}}checked{{/patreonEnabled}} class="form-checkbox"/>
{{msg.admin_patreon_enable_integration}}
</label>
</div>
<div class="form-group">
<label for="accessToken" class="form-field__label">{{msg.admin_patreon_access_token}}</label>
<p class="form-field__help">{{msg.admin_patreon_access_token_help}}</p>
<div class="form-input-group">
<input id="accessToken" name="accessToken" type="password" value="{{accessToken}}"
class="form-input"
placeholder="{{msg.admin_patreon_access_token_placeholder}}"/>
</div>
</div>
<div class="form-group">
<label for="campaignId" class="form-field__label">{{msg.admin_patreon_campaign_id}}</label>
<p class="form-field__help">{{msg.admin_patreon_campaign_id_help}}</p>
<div class="form-input-group">
<div id="campaignId-container">
<input id="campaignId" name="campaignId" type="text" value="{{campaignId}}"
class="form-input"
placeholder="123456"/>
</div>
<button type="button" class="btn btn--ghost"
hx-post="/admin/patreon/fetch-campaign"
hx-include="#accessToken"
hx-target="#campaignId-container"
hx-swap="innerHTML">
{{msg.admin_patreon_fetch_campaign}}
</button>
</div>
</div>
<div class="form-group">
<label for="webhookSecret"
class="form-field__label">{{msg.admin_patreon_webhook_secret}}</label>
<p class="form-field__help">{{msg.admin_patreon_webhook_secret_help}}</p>
<div class="form-input-group">
<input id="webhookSecret" name="webhookSecret" type="password" value="{{webhookSecret}}"
class="form-input"
placeholder="{{msg.admin_patreon_webhook_secret_placeholder}}"/>
</div>
</div>
<div class="form-group">
<label for="patreonUrl"
class="form-field__label">{{msg.admin_patreon_url}}</label>
<p class="form-field__help">{{msg.admin_patreon_url_help}}</p>
<div class="form-input-group">
<input id="patreonUrl" name="patreonUrl" type="url" value="{{patreonUrl}}"
class="form-input"
placeholder="{{msg.admin_patreon_url_placeholder}}"/>
</div>
</div>
<div class="form-group">
<label for="minimumAmount"
class="form-field__label">{{msg.admin_patreon_minimum_amount}}</label>
<p class="form-field__help">{{msg.admin_patreon_minimum_amount_help}}</p>
<div class="form-input-group">
<input id="minimumAmount" name="minimumAmount" type="number" step="0.01" min="0"
value="{{minimumAmountDollars}}" class="form-input"/>
</div>
</div>
<div class="form-group">
<label for="pollInterval" class="form-field__label">{{msg.admin_patreon_poll_interval}}</label>
<p class="form-field__help">{{msg.admin_patreon_poll_interval_help}}</p>
<div class="form-input-group">
<input id="pollInterval" name="pollInterval" type="number" min="1"
value="{{pollIntervalMinutes}}" class="form-input"/>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn--accent">{{msg.admin_patreon_save_button}}</button>
</div>
<!-- Error Display -->
<div id="patreon-error"></div>
</form>
</section>
<!-- Webhook Info Section -->
<section class="paper-section">
<div class="section-header">
<h2 class="section-header__title">{{msg.admin_patreon_webhook_title}}</h2>
</div>
<p class="section-description">{{msg.admin_patreon_webhook_description}}</p>
<div class="form-group">
<label class="form-field__label">{{msg.admin_patreon_webhook_url}}</label>
<div class="form-input-group">
<input type="text" readonly value="{{webhookUrl}}" class="form-input"/>
</div>
</div>
</section>
</div>
</div>
</main>
<div id="result"></div>
{{> footer}}

View file

@ -48,11 +48,9 @@
<a href="https://bsky.app/profile/darkrockstudios.bsky.social" target="_blank" rel="noopener noreferrer" aria-label="{{msg.home_footer_bluesky}}">
<i class="fa-brands fa-bluesky"></i>
</a>
{{#patreonUrl}}
<a href="{{patreonUrl}}" target="_blank" rel="noopener noreferrer" aria-label="{{msg.home_footer_patreon}}">
<i class="fa-brands fa-patreon"></i>
</a>
{{/patreonUrl}}
{{#footerNoticeHtml}}
{{{footerNoticeHtml}}}
{{/footerNoticeHtml}}
</div>
<form action="/locale" method="post" class="language-selector">
<label for="lang-select">Language:</label>

View file

@ -37,17 +37,8 @@
<a href="/forgot-password">{{msg.password_reset_forgot_link}}</a>
</p>
<p class="login-info-text">{{msg.login_signup_prompt}} <a href="/signup">{{msg.login_signup_link}}</a></p>
{{#patreonEnabled}}
<div class="login-info-patreon">
<h3 class="login-info-patreon__title">{{msg.login_patreon_notice_title}}</h3>
<p>{{patreonMessage}}</p>
<a href="{{patreonUrl}}" target="_blank" rel="noopener noreferrer"
class="login-info-patreon__link">
{{msg.login_patreon_notice_link}} →
</a>
</div>
{{/patreonEnabled}}
{{^patreonEnabled}}
{{{allowedUsersNoticeHtml}}}
{{^allowedUsersNoticeProvided}}
<div class="login-info-whitelist">
<p>{{msg.login_info_allowedusers_notice}}</p>
{{#contactEmail}}
@ -55,7 +46,7 @@
href="mailto:{{contactEmail}}">{{contactEmail}}</a></p>
{{/contactEmail}}
</div>
{{/patreonEnabled}}
{{/allowedUsersNoticeProvided}}
</div>
</div>
</div>

View file

@ -24,14 +24,14 @@
<span>{{msg.admin_nav_monitoring}}</span>
</a>
</li>
{{#patreonFeatureEnabled}}
{{#pluginNavEntries}}
<li class="admin-nav-item">
<a href="/admin/patreon" class="admin-nav-link {{#activePatreon}}active{{/activePatreon}}">
<i class="fa-brands fa-patreon"></i>
<span>{{msg.admin_nav_patreon}}</span>
<a href="{{href}}" class="admin-nav-link {{#active}}active{{/active}}">
<i class="{{icon}}"></i>
<span>{{label}}</span>
</a>
</li>
{{/patreonFeatureEnabled}}
{{/pluginNavEntries}}
{{#emailFeatureEnabled}}
<li class="admin-nav-item">
<a href="/admin/email" class="admin-nav-link {{#activeEmail}}active{{/activeEmail}}">

View file

@ -80,7 +80,7 @@
<i class="fa-solid fa-pen"></i>
{{msg.admin_allowedusers_edit_button}}
</button>
{{^isPatreon}}
{{^sourceManaged}}
<button type="button" class="btn btn--ghost edit-expiry-btn"
data-email="{{email}}"
data-expires="{{expires}}"
@ -92,7 +92,7 @@
<i class="fa-solid fa-hourglass-half"></i>
{{msg.admin_allowedusers_edit_expiry_button}}
</button>
{{/isPatreon}}
{{/sourceManaged}}
<form action="/admin/allowed-users/remove" method="post"
hx-post="/admin/allowed-users/remove"
hx-target="#whitelist"

View file

@ -11,5 +11,10 @@
<p>{{msg.home_servermessage_allowedusers}}</p>
</div>
{{/allowedUsersNotice}}
{{#allowedUsersBannerHtml}}
<div class="instance-band__inner instance-band__inner--whitelist">
{{{allowedUsersBannerHtml}}}
</div>
{{/allowedUsersBannerHtml}}
</div>
{{/hasInstanceNotice}}

View file

@ -52,13 +52,16 @@
<div class="login-info">
<p class="login-info-text">{{msg.signup_login_prompt}} <a href="/login">{{msg.signup_login_link}}</a></p>
<div class="login-info-whitelist">
<p>{{msg.login_info_allowedusers_notice}}</p>
{{#contactEmail}}
<p>{{msg.login_info_allowedusers_contact}} <a
href="mailto:{{contactEmail}}">{{contactEmail}}</a></p>
{{/contactEmail}}
</div>
{{{allowedUsersNoticeHtml}}}
{{^allowedUsersNoticeProvided}}
<div class="login-info-whitelist">
<p>{{msg.login_info_allowedusers_notice}}</p>
{{#contactEmail}}
<p>{{msg.login_info_allowedusers_contact}} <a
href="mailto:{{contactEmail}}">{{contactEmail}}</a></p>
{{/contactEmail}}
</div>
{{/allowedUsersNoticeProvided}}
</div>
</div>
</div>

View file

@ -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

View file

@ -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)

View file

@ -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",
)
}

View file

@ -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"
}
}

View file

@ -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")
}
}

View file

@ -4,7 +4,6 @@ import com.darkrockstudios.apps.hammer.ServerConfig
import com.darkrockstudios.apps.hammer.admin.AdminServerConfig
import com.darkrockstudios.apps.hammer.admin.ConfigRepository
import com.darkrockstudios.apps.hammer.frontend.data.UserSession
import com.darkrockstudios.apps.hammer.patreon.PatreonConfig
import com.darkrockstudios.apps.hammer.plugins.configureLocalization
import com.darkrockstudios.apps.hammer.plugins.configureTemplating
import com.darkrockstudios.apps.hammer.utilities.MarkdownService
@ -45,7 +44,7 @@ class HomePageTest : BaseTest() {
cookie<UserSession>(COOKIE_USER_SESSION)
}
routing {
homePage(configRepository, ServerConfig(), MarkdownService())
homePage(configRepository, MarkdownService())
}
}
}
@ -53,7 +52,6 @@ class HomePageTest : BaseTest() {
private fun mockPageModelDependencies(serverMessage: String) {
coEvery { configRepository.get(AdminServerConfig.SERVER_MESSAGE) } returns serverMessage
coEvery { configRepository.get(AdminServerConfig.CONTACT_EMAIL) } returns ""
coEvery { configRepository.get(AdminServerConfig.PATREON_CONFIG) } returns PatreonConfig()
coEvery { configRepository.get(AdminServerConfig.ABOUT_SERVER) } returns ""
coEvery { configRepository.get(AdminServerConfig.DEFAULT_LOCALE) } returns "en"
}

View file

@ -1,18 +1,17 @@
package com.darkrockstudios.apps.hammer.frontend.utils
import com.darkrockstudios.apps.hammer.utilities.ResUtils
import org.junit.jupiter.api.Test
import java.io.File
import java.util.Locale
import java.util.Properties
import kotlin.test.assertTrue
import kotlin.test.fail
/**
* English is the fallback bundle every other locale resolves through, so a key
* that is looked up but absent from Messages_en.properties throws
* MissingResourceException at request time instead of degrading to untranslated
* text.
* English is the fallback every other locale resolves through, so a key that is
* looked up but absent from its English bundle throws MissingResourceException at
* request time instead of degrading to untranslated text. Kotlin call sites resolve
* only the default Messages bundle; templates read the msg map, which also merges
* plugin-declared bundles.
*
* Parity is asserted against the keys the server actually references rather than
* against the translation files, which Crowdin owns and which carry strings that
@ -31,20 +30,30 @@ class MessageBundleParityTest {
)
private val templatePattern = Regex("""\{\{[{&]?\s*msg\.([A-Za-z0-9_]+)""")
private fun keysOf(locale: Locale): Set<String> {
private val i18nDir = File("src/main/resources/i18n")
private fun loadProperties(file: File): Properties {
val properties = Properties()
ResUtils.getResourceAsStream("i18n/Messages_$locale.properties")
.reader(Charsets.UTF_8)
.use { properties.load(it) }
return properties.stringPropertyNames()
file.reader(Charsets.UTF_8).use { properties.load(it) }
return properties
}
private fun englishBundleFiles(): List<File> =
i18nDir.listFiles { f -> f.name.endsWith("_en.properties") }.orEmpty().toList()
/** Kotlin call sites resolve only the default bundle, so they get no plugin-bundle credit. */
private fun coreEnglishKeys(): Set<String> =
loadProperties(File(i18nDir, "Messages_en.properties")).stringPropertyNames()
/** Templates read the msg map, which merges plugin bundles, so the union applies. */
private fun allEnglishKeys(): Set<String> =
englishBundleFiles().flatMapTo(mutableSetOf()) { loadProperties(it).stringPropertyNames() }
private fun filesUnder(root: File, extension: String): List<File> =
root.walkTopDown().filter { it.isFile && it.extension == extension }.toList()
private fun referencedKeys(): Map<String, String> {
private fun sourceReferences(): Map<String, String> {
val references = mutableMapOf<String, String>()
filesUnder(kotlinSources, "kt").forEach { file ->
val text = file.readText()
callSitePatterns.forEach { pattern ->
@ -55,14 +64,17 @@ class MessageBundleParityTest {
}
}
}
return references
}
private fun templateReferences(): Map<String, String> {
val references = mutableMapOf<String, String>()
filesUnder(templates, "mustache").forEach { file ->
val text = file.readText()
templatePattern.findAll(text).forEach { match ->
references.putIfAbsent(match.groupValues[1], file.name)
}
}
return references
}
@ -71,17 +83,21 @@ class MessageBundleParityTest {
assertTrue(kotlinSources.isDirectory, "Kotlin sources not found at ${kotlinSources.absolutePath}")
assertTrue(templates.isDirectory, "Templates not found at ${templates.absolutePath}")
val references = referencedKeys()
val sourceRefs = sourceReferences()
val templateRefs = templateReferences()
// A regex that quietly stops matching would turn this into a vacuous pass.
assertTrue(references.size > 200, "Only found ${references.size} message references; the scan is broken")
val total = sourceRefs.size + templateRefs.size
assertTrue(total > 200, "Only found $total message references; the scan is broken")
val englishKeys = keysOf(Locale.ENGLISH)
val missing = references.filterKeys { it !in englishKeys }
val coreKeys = coreEnglishKeys()
val allKeys = allEnglishKeys()
val missing = sourceRefs.filterKeys { it !in coreKeys } +
templateRefs.filterKeys { it !in allKeys }
if (missing.isNotEmpty()) {
val report = missing.toSortedMap().entries.joinToString("\n") { (key, source) -> " $key (used by $source)" }
fail(
"Messages_en.properties is the fallback bundle and must carry every key the server looks up.\n" +
"The English bundles are the fallback and must carry every key the server looks up.\n" +
"$report\n" +
"If a Crowdin sync rewrote the English source file, restore the dropped keys."
)
@ -90,12 +106,12 @@ class MessageBundleParityTest {
@Test
fun `every English key resolves to a non-blank value`() {
val properties = Properties()
ResUtils.getResourceAsStream("i18n/Messages_en.properties")
.reader(Charsets.UTF_8)
.use { properties.load(it) }
val blank = properties.stringPropertyNames().filter { properties.getProperty(it).isNullOrBlank() }
val blank = englishBundleFiles().flatMap { file ->
val properties = loadProperties(file)
properties.stringPropertyNames()
.filter { properties.getProperty(it).isNullOrBlank() }
.map { "${file.name}: $it" }
}
assertTrue(blank.isEmpty(), "English keys with blank values: ${blank.sorted()}")
}

View file

@ -63,8 +63,6 @@ class MonitoringExportRouteTest {
projectsSyncManager = mockk<SyncSessionManager<Long, ProjectsSynchronizationSession>>(relaxed = true),
projectSyncManager = mockk<SyncSessionManager<Long, ProjectSynchronizationSession>>(relaxed = true),
clock = clock,
patreonFeatureEnabled = false,
emailFeatureEnabled = false,
)
}
}

View file

@ -63,8 +63,6 @@ class MonitoringIgnoreRouteTest : BaseTest() {
relaxed = true
),
clock = clock,
patreonFeatureEnabled = false,
emailFeatureEnabled = false,
)
}
}

View file

@ -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)
}
}

View file

@ -1,314 +0,0 @@
package com.darkrockstudios.apps.hammer.patreon
import com.darkrockstudios.apps.hammer.Account
import com.darkrockstudios.apps.hammer.account.AccountsRepository
import com.darkrockstudios.apps.hammer.admin.AdminServerConfig
import com.darkrockstudios.apps.hammer.admin.ConfigRepository
import com.darkrockstudios.apps.hammer.admin.WhiteListRepository
import com.darkrockstudios.apps.hammer.database.ServerConfigDao
import com.darkrockstudios.apps.hammer.database.WhiteListDao
import com.darkrockstudios.apps.hammer.e2e.util.SharedPostgresTestDatabase
import com.darkrockstudios.apps.hammer.utils.BaseTest
import com.darkrockstudios.apps.hammer.utils.TestClock
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.slf4j.LoggerFactory
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.time.Instant
class PatreonSyncServiceTest : BaseTest() {
private lateinit var db: SharedPostgresTestDatabase
private lateinit var whiteListDao: WhiteListDao
private lateinit var configDao: ServerConfigDao
private lateinit var configRepository: ConfigRepository
private lateinit var whiteListRepository: WhiteListRepository
private lateinit var clock: TestClock
private lateinit var patreonApiClient: PatreonApiClient
private lateinit var accountsRepository: AccountsRepository
private val logger = LoggerFactory.getLogger(PatreonSyncServiceTest::class.java)
@BeforeEach
override fun setup() {
super.setup()
db = SharedPostgresTestDatabase()
db.initialize()
whiteListDao = WhiteListDao(db)
configDao = ServerConfigDao(db)
configRepository = ConfigRepository(configDao)
clock = TestClock(Clock.System)
whiteListRepository = WhiteListRepository(whiteListDao, clock)
patreonApiClient = mockk()
accountsRepository = mockk()
setupKoin()
}
private fun createSyncService() = PatreonSyncService(
patreonApiClient = patreonApiClient,
whiteListRepository = whiteListRepository,
accountsRepository = accountsRepository,
configRepository = configRepository,
clock = clock,
logger = logger
)
private suspend fun enablePatreon(
campaignId: String = "test-campaign",
accessToken: String = "test-token",
minimumAmountCents: Int = 500
) {
val config = PatreonConfig(
enabled = true,
campaignId = campaignId,
creatorAccessToken = accessToken,
minimumAmountCents = minimumAmountCents
)
configRepository.set(AdminServerConfig.PATREON_CONFIG, config)
}
@Test
fun `sync skipped when disabled`() = runTest {
// Patreon disabled by default
val service = createSyncService()
val result = service.performFullSync()
assertTrue(result.isSuccess)
assertEquals(0, result.getOrThrow().added)
assertEquals(0, result.getOrThrow().removed)
}
@Test
fun `sync fails when missing campaign ID`() = runTest {
val config = PatreonConfig(enabled = true, campaignId = "", creatorAccessToken = "token")
configRepository.set(AdminServerConfig.PATREON_CONFIG, config)
val service = createSyncService()
val result = service.performFullSync()
assertTrue(result.isFailure)
assertIs<PatreonSyncException>(result.exceptionOrNull())
}
@Test
fun `new patron joins - added to whitelist`() = runTest {
enablePatreon()
coEvery {
patreonApiClient.fetchAllQualifyingMembers("test-campaign", "test-token", 500)
} returns Result.success(listOf("patron@example.com"))
val service = createSyncService()
val result = service.performFullSync()
assertTrue(result.isSuccess)
assertEquals(1, result.getOrThrow().added)
assertTrue(whiteListRepository.isOnWhiteList("patron@example.com"))
// Verify reason is "Patreon"
val entries = whiteListRepository.getWhiteListByReason(PatreonSyncService.WHITELIST_REASON)
assertEquals(1, entries.size)
assertEquals("patron@example.com", entries[0].email)
}
@Test
fun `existing patron with different reason - not modified`() = runTest {
// Add user to whitelist with different reason
whiteListRepository.addToWhiteList("betatester@example.com", "Beta tester")
enablePatreon()
coEvery {
patreonApiClient.fetchAllQualifyingMembers("test-campaign", "test-token", 500)
} returns Result.success(listOf("betatester@example.com"))
val service = createSyncService()
val result = service.performFullSync()
assertTrue(result.isSuccess)
assertEquals(0, result.getOrThrow().added)
assertEquals(1, result.getOrThrow().skipped)
// Verify reason is still "Beta tester"
val entries = whiteListRepository.getWhiteListByReason("Beta tester")
assertEquals(1, entries.size)
assertEquals("betatester@example.com", entries[0].email)
// Verify no Patreon entries
val patreonEntries = whiteListRepository.getWhiteListByReason(PatreonSyncService.WHITELIST_REASON)
assertEquals(0, patreonEntries.size)
}
@Test
fun `patron unsubscribes - removed from whitelist and logged out`() = runTest {
// Add user as Patreon member
whiteListRepository.addToWhiteList("lapsed@example.com", PatreonSyncService.WHITELIST_REASON)
enablePatreon()
// Patron is no longer in the API response
coEvery {
patreonApiClient.fetchAllQualifyingMembers("test-campaign", "test-token", 500)
} returns Result.success(emptyList())
coEvery { accountsRepository.forceLogout("lapsed@example.com") } returns true
val service = createSyncService()
val result = service.performFullSync()
assertTrue(result.isSuccess)
assertEquals(1, result.getOrThrow().removed)
assertFalse(whiteListRepository.isOnWhiteList("lapsed@example.com"))
// Verify force logout was called
coVerify { accountsRepository.forceLogout("lapsed@example.com") }
}
@Test
fun `non-Patreon user unaffected when not in API`() = runTest {
// Add user with non-Patreon reason
whiteListRepository.addToWhiteList("friend@example.com", "Friend")
enablePatreon()
// Friend is not in Patreon API (they're not a patron)
coEvery {
patreonApiClient.fetchAllQualifyingMembers("test-campaign", "test-token", 500)
} returns Result.success(emptyList())
val service = createSyncService()
val result = service.performFullSync()
assertTrue(result.isSuccess)
assertEquals(0, result.getOrThrow().removed)
// Friend should still be on whitelist
assertTrue(whiteListRepository.isOnWhiteList("friend@example.com"))
}
@Test
fun `sync with no changes`() = runTest {
// Add existing Patreon member
whiteListRepository.addToWhiteList("patron@example.com", PatreonSyncService.WHITELIST_REASON)
enablePatreon()
coEvery {
patreonApiClient.fetchAllQualifyingMembers("test-campaign", "test-token", 500)
} returns Result.success(listOf("patron@example.com"))
val service = createSyncService()
val result = service.performFullSync()
assertTrue(result.isSuccess)
assertEquals(0, result.getOrThrow().added)
assertEquals(0, result.getOrThrow().removed)
}
@Test
fun `force logout works`() = runTest {
coEvery { accountsRepository.forceLogout("user@example.com") } returns true
val service = createSyncService()
service.forceLogout("user@example.com")
coVerify { accountsRepository.forceLogout("user@example.com") }
}
@Test
fun `force logout handles missing account gracefully`() = runTest {
coEvery { accountsRepository.forceLogout("nonexistent@example.com") } returns false
val service = createSyncService()
// Should not throw
service.forceLogout("nonexistent@example.com")
}
@Test
fun `email normalization works`() = runTest {
enablePatreon()
coEvery {
patreonApiClient.fetchAllQualifyingMembers("test-campaign", "test-token", 500)
} returns Result.success(listOf(" PATRON@EXAMPLE.COM "))
val service = createSyncService()
val result = service.performFullSync()
assertTrue(result.isSuccess)
assertEquals(1, result.getOrThrow().added)
// Should be normalized to lowercase
assertTrue(whiteListRepository.isOnWhiteList("patron@example.com"))
}
@Test
fun `last sync time is updated`() = runTest {
enablePatreon()
coEvery {
patreonApiClient.fetchAllQualifyingMembers("test-campaign", "test-token", 500)
} returns Result.success(emptyList())
val service = createSyncService()
service.performFullSync()
val config = configRepository.get(AdminServerConfig.PATREON_CONFIG)
assertEquals(clock.now().toString(), config.lastSync)
}
@Test
fun `patron count returns correct value`() = runTest {
whiteListRepository.addToWhiteList("patron1@example.com", PatreonSyncService.WHITELIST_REASON)
whiteListRepository.addToWhiteList("patron2@example.com", PatreonSyncService.WHITELIST_REASON)
whiteListRepository.addToWhiteList("friend@example.com", "Friend")
val service = createSyncService()
val count = service.getPatreonWhitelistCount()
assertEquals(2L, count)
}
@Test
fun `patron with accounts count returns correct value`() = runTest {
// Add patrons to whitelist
whiteListRepository.addToWhiteList("patron1@example.com", PatreonSyncService.WHITELIST_REASON)
whiteListRepository.addToWhiteList("patron2@example.com", PatreonSyncService.WHITELIST_REASON)
whiteListRepository.addToWhiteList("patron3@example.com", PatreonSyncService.WHITELIST_REASON)
whiteListRepository.addToWhiteList("friend@example.com", "Friend")
// Create accounts for only some of the patrons
db.serverDatabase.accountQueries.testInsertAccount(
email = "patron1@example.com",
password_hash = "hash",
cipher_secret = "secret",
is_admin = false,
created = Instant.parse("2024-01-01T00:00:00Z"),
last_sync = Instant.parse("2024-01-01T00:00:00Z")
)
db.serverDatabase.accountQueries.testInsertAccount(
email = "patron3@example.com",
password_hash = "hash",
cipher_secret = "secret",
is_admin = false,
created = Instant.parse("2024-01-01T00:00:00Z"),
last_sync = Instant.parse("2024-01-01T00:00:00Z")
)
val service = createSyncService()
val count = service.getPatreonMembersWithAccountsCount()
// Only patron1 and patron3 have accounts (patron2 doesn't, friend is not a Patreon member)
assertEquals(2L, count)
}
}

View file

@ -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() }
}
}

View file

@ -20,6 +20,7 @@ import com.darkrockstudios.apps.hammer.project.ProjectSynchronizationSession
import com.darkrockstudios.apps.hammer.projects.ProjectsSynchronizationSession
import com.darkrockstudios.apps.hammer.scheduling.RecurringTaskRegistry
import com.darkrockstudios.apps.hammer.syncsessionmanager.SyncSessionManager
import com.darkrockstudios.apps.hammer.plugin.PluginRegistry
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.mockk.mockk
@ -116,6 +117,8 @@ fun Application.setupKtorTestKoin(baseTest: BaseTest, vararg modules: Module) {
modules(
module {
// Route helpers resolve the registry even when no plugins are in play.
single { PluginRegistry(emptyList()) }
single<CoroutineContext>(named(DISPATCHER_DEFAULT)) {
StandardTestDispatcher(
baseTest.scope.testScheduler,

View file

@ -18,6 +18,8 @@ import com.darkrockstudios.apps.hammer.utilities.TokenHasher
import com.darkrockstudios.apps.hammer.utilities.TouchableFileSystem
import com.darkrockstudios.apps.hammer.utilities.DiskCache
import com.darkrockstudios.apps.hammer.utilities.cacheDirectory
import com.darkrockstudios.apps.hammer.plugin.ServerPlugin
import com.darkrockstudios.apps.hammer.plugin.installedPlugins
import io.ktor.client.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
@ -57,6 +59,13 @@ abstract class EndToEndTest {
*/
protected open val serverConfig = ServerConfig(encryption = EncryptionConfig(EncryptionMode.AES))
/**
* Plugins active in the booted server. Defaults to the build's real plugin list so e2e
* runs match production; tests override to add fixtures or to isolate from plugins.
* Plugin fixtures must keep their integrations unconfigured or tests become order-dependent.
*/
protected open val serverPlugins: List<ServerPlugin> = installedPlugins()
/** The OS-assigned port the server bound to; valid after [doStartServer]. */
protected var serverPort: Int = 0
private set
@ -154,7 +163,7 @@ abstract class EndToEndTest {
port = 0,
host = "0.0.0.0",
module = {
appMain(serverConfig, testModule)
appMain(serverConfig, testModule, plugins = serverPlugins)
}
)
server.start()