Files
OSGAccountServer/src/main/kotlin/com/osglab/account/config/AppConfig.kt
T
Rocky 4c9e5feec0
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Make session refresh retries idempotent
Preserve the successor session for legitimate refresh retries so transient failures no longer revoke the user's session family.
2026-08-25 13:13:41 +08:00

704 lines
29 KiB
Kotlin

package com.osglab.account.config
import com.osglab.account.features.storekit.domain.StoreKitProduct
import io.ktor.server.config.ApplicationConfig
import java.net.URI
import java.time.ZoneId
import java.util.Base64
import java.util.UUID
data class AppConfig(
val environment: Environment,
val publicBaseUrl: String,
val inviteBaseUrl: String,
val appStoreUrl: String,
val database: DatabaseConfig,
val session: SessionConfig,
val encryption: EncryptionConfig,
val antiAbuse: AntiAbuseConfig,
val apple: AppleConfig,
val credits: CreditsConfig,
val storeKit: StoreKitConfig = StoreKitConfig(),
val providers: ProvidersConfig,
val integrity: IntegrityConfig,
val admin: AdminConfig = AdminConfig(),
val hintFeed: HintFeedConfig = HintFeedConfig(),
) {
val isProduction: Boolean = environment == Environment.PRODUCTION
companion object {
fun from(config: ApplicationConfig): AppConfig {
val environment = Environment.parse(config.required("app.environment"))
val production = environment == Environment.PRODUCTION
val databaseUsername = config.required("app.database.username")
val databasePassword = config.secret("app.database.password", production)
val migrationUsername = if (production) {
config.required("app.database.migrationUsername")
} else {
config.optionalValue("app.database.migrationUsername") ?: databaseUsername
}
val migrationPassword = config.optionalSecret(
"app.database.migrationPassword",
production = production,
) ?: databasePassword
val database = DatabaseConfig(
jdbcUrl = config.required("app.database.jdbcUrl"),
username = databaseUsername,
password = databasePassword,
migrationUsername = migrationUsername,
migrationPassword = migrationPassword,
maximumPoolSize = config.positiveInt("app.database.maximumPoolSize"),
)
val session = SessionConfig(
issuer = config.required("app.session.issuer"),
audience = config.required("app.session.audience"),
hmacSecret = config.secret("app.session.secret", production).toByteArray(),
accessMinutes = config.positiveLong("app.session.accessMinutes"),
refreshDays = config.positiveLong("app.session.refreshDays"),
legacyRefreshReplaySeconds = config.positiveLong(
"app.session.legacyRefreshReplaySeconds",
30,
),
gatewayGrantDays = config.positiveLong("app.session.gatewayGrantDays", 30),
)
val encryption = EncryptionConfig(
key = config.base64Key("app.encryption.keyBase64", production),
)
val antiAbuse = AntiAbuseConfig(
identityHmacKey = config.base64Key("app.antiAbuse.identityHmacKeyBase64", production),
tombstoneRetentionDays = config.positiveLong(
"app.antiAbuse.tombstoneRetentionDays",
365,
),
)
val apple = AppleConfig(
teamId = config.optionalSecret("app.apple.teamId", production),
keyId = config.optionalSecret("app.apple.keyId", production),
clientId = config.required("app.apple.clientId"),
privateKeyPem = config.optionalSecret("app.apple.privateKeyPem", production)
?.replace("\\n", "\n"),
jwksUrl = config.httpsUrl("app.apple.jwksUrl", production),
tokenUrl = config.httpsUrl("app.apple.tokenUrl", production),
revokeUrl = config.httpsUrl("app.apple.revokeUrl", production),
)
val credits = CreditsConfig(
signupTrial = config.positiveLong("app.credits.signupTrial", 1_000),
referralInviter = config.positiveLong("app.credits.referralInviter", 1_000),
referralInvitee = config.positiveLong("app.credits.referralInvitee", 1_000),
referralBindingDays = config.positiveLong("app.credits.referralBindingDays", 7),
)
val storeKitEnabled = config.booleanOrDefault("app.storeKit.enabled", false)
val storeKitAppAppleId = config.optionalValue("app.storeKit.appAppleId")?.let { raw ->
raw.toLongOrNull()?.takeIf { it > 0 }
?: throw ConfigValidationException("app.storeKit.appAppleId must be positive")
}
val storeKit = StoreKitConfig(
enabled = storeKitEnabled,
bundleId = config.valueOrDefault("app.storeKit.bundleId", apple.clientId),
appAppleId = storeKitAppAppleId,
products = config.storeKitProducts("app.storeKit.products"),
)
if (storeKitEnabled) {
require(storeKit.bundleId == apple.clientId) {
"app.storeKit.bundleId must match app.apple.clientId"
}
require(storeKit.appAppleId != null) {
"app.storeKit.appAppleId is required when StoreKit is enabled"
}
require(storeKit.products.isNotEmpty()) {
"app.storeKit.products is required when StoreKit is enabled"
}
}
val deepSeekModel = config.valueOrDefault(
"app.providers.deepseek.model",
"deepseek-v4-flash",
)
val providers = ProvidersConfig(
volcengine = VolcengineConfig(
endpoint = config.valueOrDefault(
"app.providers.volcengine.endpoint",
"wss://openspeech.bytedance.com/api/v3/sauc/bigmodel",
),
appId = config.optionalValue("app.providers.volcengine.appId"),
accessToken = config.optionalValue("app.providers.volcengine.accessToken"),
apiKey = config.optionalValue("app.providers.volcengine.apiKey"),
resourceId = config.valueOrDefault(
"app.providers.volcengine.resourceId",
"volc.seedasr.sauc.duration",
),
),
deepSeek = DeepSeekConfig(
endpoint = config.valueOrDefault(
"app.providers.deepseek.endpoint",
"https://api.deepseek.com/v1",
),
apiKey = config.optionalValue("app.providers.deepseek.apiKey"),
model = deepSeekModel,
reasoningModel = config.optionalValue("app.providers.deepseek.reasoningModel")
?: deepSeekModel,
),
)
val integrity = IntegrityConfig(
deviceCheckPolicy = IntegrityPolicy.fromEnforced(
config.boolean("app.integrity.enforceDeviceCheck"),
),
appAttestPolicy = IntegrityPolicy.fromEnforced(
config.boolean("app.integrity.enforceAppAttest"),
),
appleEnvironment = AppleServiceEnvironment.parse(
config.valueOrDefault(
"app.integrity.appleEnvironment",
if (production) "production" else "development",
),
),
allowDevelopmentAppAttest = config.booleanOrDefault(
"app.integrity.allowDevelopmentAppAttest",
false,
),
challengeLifetimeSeconds = config.positiveLong(
"app.integrity.challengeLifetimeSeconds",
300,
),
)
val adminEnabled = config.booleanOrDefault("app.admin.enabled", false)
val adminBootstrapEnabled = config.booleanOrDefault(
"app.admin.bootstrapEnabled",
false,
)
require(!adminBootstrapEnabled || adminEnabled) {
"app.admin.bootstrapEnabled requires app.admin.enabled"
}
val admin = AdminConfig(
enabled = adminEnabled,
mtlsRequired = config.booleanOrDefault("app.admin.mtlsRequired", true),
bootstrapEnabled = adminBootstrapEnabled,
bootstrapOperatorId = config.optionalValue("app.admin.bootstrapOperatorId")
?.let {
runCatching { UUID.fromString(it) }.getOrElse { cause ->
throw ConfigValidationException(
"app.admin.bootstrapOperatorId must be a UUID",
cause,
)
}
},
bootstrapUsername = config.optionalValue("app.admin.bootstrapUsername"),
bootstrapPasswordHash = config.optionalLiteralSecret(
"app.admin.bootstrapPasswordHash",
production && adminBootstrapEnabled,
),
bootstrapTotpSecretBase32 = config.optionalSecret(
"app.admin.bootstrapTotpSecretBase32",
production && adminBootstrapEnabled,
),
sessionHours = config.positiveLong("app.admin.sessionHours", 8),
maximumManualGrant = config.positiveLong(
"app.admin.maximumManualGrant",
100_000,
),
)
val hintFeed = HintFeedConfig(
enabled = config.booleanOrDefault("app.hintFeed.enabled", false),
topHubApiKey = config.optionalSecret(
"app.hintFeed.topHubApiKey",
production = false,
),
zoneId = config.valueOrDefault("app.hintFeed.zoneId", "UTC").let { raw ->
runCatching { ZoneId.of(raw) }.getOrElse { cause ->
throw ConfigValidationException("app.hintFeed.zoneId must be a valid time zone", cause)
}
},
)
require(session.hmacSecret.size >= MIN_HMAC_SECRET_BYTES) {
"app.session.secret must contain at least $MIN_HMAC_SECRET_BYTES bytes"
}
require(encryption.key.size == AES_256_KEY_BYTES) {
"app.encryption.keyBase64 must decode to exactly $AES_256_KEY_BYTES bytes"
}
require(antiAbuse.identityHmacKey.size >= MIN_HMAC_SECRET_BYTES) {
"app.antiAbuse.identityHmacKeyBase64 must decode to at least $MIN_HMAC_SECRET_BYTES bytes"
}
require(
!session.hmacSecret.contentEquals(encryption.key) &&
!session.hmacSecret.contentEquals(antiAbuse.identityHmacKey) &&
!encryption.key.contentEquals(antiAbuse.identityHmacKey)
) {
"Session, field-encryption, and identity-HMAC secrets must be distinct"
}
require(database.jdbcUrl.startsWith("jdbc:mysql:")) {
"app.database.jdbcUrl must use MySQL"
}
require(
!production ||
(!database.migrationUsername.isPlaceholder() &&
database.migrationUsername != database.username)
) {
"Production migration and runtime database users must be distinct"
}
require(!production || database.migrationPassword != database.password) {
"Production migration and runtime database passwords must be distinct"
}
require(database.maximumPoolSize in 1..100) {
"app.database.maximumPoolSize must be between 1 and 100"
}
require(session.accessMinutes in 1..60) {
"app.session.accessMinutes must be between 1 and 60"
}
require(session.refreshDays in 1..365) {
"app.session.refreshDays must be between 1 and 365"
}
require(session.legacyRefreshReplaySeconds in 5..120) {
"app.session.legacyRefreshReplaySeconds must be between 5 and 120"
}
require(!production || providers.volcengine.credentialsAvailable) {
"Production Volcengine credentials are missing"
}
require(!production || providers.deepSeek.credentialsAvailable) {
"Production DeepSeek credentials are missing"
}
if (production) {
requireExactProviderEndpoint(
providers.deepSeek.endpoint,
"https",
"api.deepseek.com",
"DeepSeek",
)
requireExactProviderEndpoint(
providers.volcengine.endpoint,
"wss",
"openspeech.bytedance.com",
"Volcengine",
)
}
require(integrity.challengeLifetimeSeconds in 30..600) {
"app.integrity.challengeLifetimeSeconds must be between 30 and 600 seconds"
}
require(!production || integrity.appleEnvironment == AppleServiceEnvironment.PRODUCTION) {
"Production must use the Apple production integrity environment"
}
require(
!production ||
(
integrity.deviceCheckPolicy == IntegrityPolicy.ENFORCE &&
integrity.appAttestPolicy == IntegrityPolicy.ENFORCE
)
) {
"Production must enforce both DeviceCheck and App Attest"
}
require(!production || apple.teamId == APP_ATTEST_TEAM_ID) {
"Production Apple team ID must be $APP_ATTEST_TEAM_ID"
}
require(!production || apple.clientId == APP_ATTEST_BUNDLE_ID) {
"Production Apple client ID must be $APP_ATTEST_BUNDLE_ID"
}
require(admin.sessionHours in 1..24) {
"app.admin.sessionHours must be between 1 and 24"
}
require(admin.maximumManualGrant in 1..100_000_000) {
"app.admin.maximumManualGrant must be between 1 and 100000000"
}
if (admin.bootstrapEnabled) {
requireNotNull(admin.bootstrapOperatorId) {
"app.admin.bootstrapOperatorId is required when admin bootstrap is enabled"
}
require(!admin.bootstrapUsername.isNullOrBlank()) {
"app.admin.bootstrapUsername is required when admin bootstrap is enabled"
}
require(!admin.bootstrapPasswordHash.isNullOrBlank()) {
"app.admin.bootstrapPasswordHash is required when admin bootstrap is enabled"
}
require(!admin.bootstrapTotpSecretBase32.isNullOrBlank()) {
"app.admin.bootstrapTotpSecretBase32 is required when admin bootstrap is enabled"
}
}
if (production) {
requireExactAppleEndpoint(apple.jwksUrl, "/auth/keys", "JWKS")
requireExactAppleEndpoint(apple.tokenUrl, "/auth/token", "token")
requireExactAppleEndpoint(apple.revokeUrl, "/auth/revoke", "revoke")
}
val publicBaseUrl = config.productionValueOrDefault(
"app.publicBaseUrl",
"http://localhost:8080",
production,
).validatedExternalUrl("app.publicBaseUrl", production)
val inviteBaseUrl = config.productionValueOrDefault(
"app.inviteBaseUrl",
"https://osglab.com/i",
production,
).validatedExternalUrl("app.inviteBaseUrl", production)
val appStoreUrl = config.productionValueOrDefault(
"app.appStoreUrl",
"https://apps.apple.com",
production,
).validatedExternalUrl("app.appStoreUrl", production)
if (production) {
requireExactExternalUrl(publicBaseUrl, "account.osglab.com", "", "PUBLIC_BASE_URL")
requireExactExternalUrl(inviteBaseUrl, "osglab.com", "/i", "INVITE_BASE_URL")
requireOfficialAppStoreUrl(appStoreUrl)
}
return AppConfig(
environment = environment,
publicBaseUrl = publicBaseUrl,
inviteBaseUrl = inviteBaseUrl,
appStoreUrl = appStoreUrl,
database = database,
session = session,
encryption = encryption,
antiAbuse = antiAbuse,
apple = apple,
credits = credits,
storeKit = storeKit,
providers = providers,
integrity = integrity,
admin = admin,
hintFeed = hintFeed,
)
}
}
}
enum class Environment {
DEVELOPMENT,
TEST,
PRODUCTION;
companion object {
fun parse(value: String): Environment = entries.firstOrNull {
it.name.equals(value, ignoreCase = true)
} ?: throw ConfigValidationException("Unsupported app.environment: $value")
}
}
data class DatabaseConfig(
val jdbcUrl: String,
val username: String,
val password: String,
val maximumPoolSize: Int,
val migrationUsername: String = username,
val migrationPassword: String = password,
)
data class SessionConfig(
val issuer: String,
val audience: String,
val hmacSecret: ByteArray,
val accessMinutes: Long,
val refreshDays: Long,
val legacyRefreshReplaySeconds: Long = 30,
val gatewayGrantDays: Long = 30,
)
data class EncryptionConfig(val key: ByteArray)
data class AntiAbuseConfig(
val identityHmacKey: ByteArray,
val tombstoneRetentionDays: Long,
)
data class HintFeedConfig(
val enabled: Boolean = false,
val topHubApiKey: String? = null,
val zoneId: ZoneId = ZoneId.of("UTC"),
)
data class AppleConfig(
val teamId: String?,
val keyId: String?,
val clientId: String,
val privateKeyPem: String?,
val jwksUrl: String,
val tokenUrl: String,
val revokeUrl: String,
) {
val clientCredentialsAvailable: Boolean
get() = teamId != null && keyId != null && privateKeyPem != null
}
data class CreditsConfig(
val signupTrial: Long,
val referralInviter: Long,
val referralInvitee: Long,
val referralBindingDays: Long,
)
data class StoreKitConfig(
val enabled: Boolean = false,
val bundleId: String = "com.osgkeyboard.ios",
val appAppleId: Long? = null,
val products: List<StoreKitProduct> = emptyList(),
)
data class ProvidersConfig(
val volcengine: VolcengineConfig,
val deepSeek: DeepSeekConfig,
)
data class VolcengineConfig(
val endpoint: String,
val appId: String?,
val accessToken: String?,
val apiKey: String?,
val resourceId: String,
) {
val credentialsAvailable: Boolean
get() = !apiKey.isNullOrBlank() || (!appId.isNullOrBlank() && !accessToken.isNullOrBlank())
}
data class DeepSeekConfig(
val endpoint: String,
val apiKey: String?,
val model: String,
val reasoningModel: String = model,
) {
val credentialsAvailable: Boolean
get() = !apiKey.isNullOrBlank()
}
data class IntegrityConfig(
val deviceCheckPolicy: IntegrityPolicy,
val appAttestPolicy: IntegrityPolicy,
val appleEnvironment: AppleServiceEnvironment = AppleServiceEnvironment.DEVELOPMENT,
val allowDevelopmentAppAttest: Boolean = false,
val challengeLifetimeSeconds: Long = 300,
val appAttestTeamId: String = APP_ATTEST_TEAM_ID,
val appAttestBundleId: String = APP_ATTEST_BUNDLE_ID,
)
data class AdminConfig(
val enabled: Boolean = false,
val mtlsRequired: Boolean = true,
val bootstrapEnabled: Boolean = false,
val bootstrapOperatorId: UUID? = null,
val bootstrapUsername: String? = null,
val bootstrapPasswordHash: String? = null,
val bootstrapTotpSecretBase32: String? = null,
val sessionHours: Long = 8,
val maximumManualGrant: Long = 100_000,
)
enum class IntegrityPolicy {
MONITOR,
ENFORCE;
companion object {
fun fromEnforced(enforced: Boolean): IntegrityPolicy = if (enforced) ENFORCE else MONITOR
}
}
enum class AppleServiceEnvironment {
DEVELOPMENT,
PRODUCTION;
companion object {
fun parse(value: String): AppleServiceEnvironment = entries.firstOrNull {
it.name.equals(value, ignoreCase = true)
} ?: throw ConfigValidationException("Unsupported Apple integrity environment: $value")
}
}
class ConfigValidationException(message: String, cause: Throwable? = null) :
IllegalStateException(message, cause)
private const val MIN_HMAC_SECRET_BYTES = 32
private const val AES_256_KEY_BYTES = 32
const val APP_ATTEST_TEAM_ID = "X329MZU23S"
const val APP_ATTEST_BUNDLE_ID = "com.osgkeyboard.ios"
private val PLACEHOLDER_MARKERS = listOf("replace-with", "change-me", "\${", "$")
private fun ApplicationConfig.required(path: String): String =
propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)
?: throw ConfigValidationException("Missing required configuration: $path")
private fun ApplicationConfig.valueOrDefault(path: String, default: String): String =
propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty) ?: default
private fun ApplicationConfig.productionValueOrDefault(
path: String,
default: String,
production: Boolean,
): String = if (production) required(path) else valueOrDefault(path, default)
private fun ApplicationConfig.optionalValue(path: String): String? =
propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)
?.takeUnless(String::isPlaceholder)
private fun ApplicationConfig.secret(path: String, production: Boolean): String =
optionalSecret(path, production)
?: throw ConfigValidationException("Missing required secret: $path")
private fun ApplicationConfig.optionalSecret(path: String, production: Boolean): String? {
val value = propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)
if (production && (value == null || value.isPlaceholder())) {
throw ConfigValidationException("Production secret is missing or uses a placeholder: $path")
}
return value?.takeUnless(String::isPlaceholder)
}
private fun ApplicationConfig.optionalLiteralSecret(path: String, production: Boolean): String? {
val value = propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)
val placeholder = value?.let {
it.contains("replace-with", ignoreCase = true) ||
it.contains("change-me", ignoreCase = true) ||
it.contains("\${")
} == true
if (production && (value == null || placeholder)) {
throw ConfigValidationException("Production secret is missing or uses a placeholder: $path")
}
return value?.takeUnless { placeholder }
}
private fun String.isPlaceholder(): Boolean =
PLACEHOLDER_MARKERS.any { marker -> contains(marker, ignoreCase = true) }
private fun ApplicationConfig.positiveInt(path: String): Int =
required(path).toIntOrNull()?.takeIf { it > 0 }
?: throw ConfigValidationException("$path must be a positive integer")
private fun ApplicationConfig.positiveLong(path: String): Long =
required(path).toLongOrNull()?.takeIf { it > 0 }
?: throw ConfigValidationException("$path must be a positive integer")
private fun ApplicationConfig.positiveLong(path: String, default: Long): Long =
propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)?.let {
it.toLongOrNull()?.takeIf { value -> value > 0 }
?: throw ConfigValidationException("$path must be a positive integer")
} ?: default
private fun ApplicationConfig.storeKitProducts(path: String): List<StoreKitProduct> {
val raw = optionalValue(path) ?: return emptyList()
val products = raw.split(',').map { entry ->
val parts = entry.split(':', limit = 2).map(String::trim)
if (parts.size != 2) {
throw ConfigValidationException("$path must use productId:credits entries")
}
val credits = parts[1].toLongOrNull()?.takeIf { it > 0 }
?: throw ConfigValidationException("$path credits must be positive integers")
runCatching { StoreKitProduct(productId = parts[0], credits = credits) }
.getOrElse { throw ConfigValidationException("$path contains an invalid product", it) }
}
if (products.map(StoreKitProduct::productId).distinct().size != products.size) {
throw ConfigValidationException("$path contains duplicate product IDs")
}
return products
}
private fun ApplicationConfig.boolean(path: String): Boolean =
required(path).let {
when (it.lowercase()) {
"true" -> true
"false" -> false
else -> throw ConfigValidationException("$path must be true or false")
}
}
private fun ApplicationConfig.booleanOrDefault(path: String, default: Boolean): Boolean =
propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)?.let {
when (it.lowercase()) {
"true" -> true
"false" -> false
else -> throw ConfigValidationException("$path must be true or false")
}
} ?: default
private fun ApplicationConfig.base64Key(path: String, production: Boolean): ByteArray {
val encoded = secret(path, production)
return try {
Base64.getDecoder().decode(encoded)
} catch (exception: IllegalArgumentException) {
throw ConfigValidationException("$path must be valid Base64", exception)
}
}
private fun ApplicationConfig.httpsUrl(path: String, production: Boolean): String {
val value = required(path)
val uri = runCatching { URI(value) }
.getOrElse { throw ConfigValidationException("$path must be a valid URL", it) }
require(uri.isAbsolute && !uri.host.isNullOrBlank() && uri.userInfo == null) {
"$path must be an absolute URL without user information"
}
require(uri.scheme.equals("https", true) || (!production && uri.scheme.equals("http", true))) {
"$path must use HTTP or HTTPS"
}
require(!production || uri.scheme.equals("https", ignoreCase = true)) {
"$path must use HTTPS in production"
}
return value
}
private fun String.validatedExternalUrl(path: String, production: Boolean): String {
val uri = runCatching { URI(this) }
.getOrElse { throw ConfigValidationException("$path must be a valid URL", it) }
require(uri.isAbsolute && !uri.host.isNullOrBlank() && uri.userInfo == null) {
"$path must be an absolute URL without user information"
}
require(uri.scheme.equals("https", true) || (!production && uri.scheme.equals("http", true))) {
"$path must use HTTPS${if (production) "" else " or HTTP"}"
}
return this
}
private fun requireExactAppleEndpoint(value: String, path: String, name: String) {
val uri = URI(value)
require(
uri.scheme.equals("https", true) &&
uri.host.equals("appleid.apple.com", true) &&
uri.port == -1 &&
uri.path == path &&
uri.rawQuery == null &&
uri.rawFragment == null
) {
"Apple $name endpoint must be https://appleid.apple.com$path"
}
}
private fun requireExactProviderEndpoint(
value: String,
scheme: String,
host: String,
provider: String,
) {
val uri = runCatching { URI(value) }
.getOrElse { throw ConfigValidationException("$provider endpoint is invalid", it) }
require(
uri.scheme.equals(scheme, ignoreCase = true) &&
uri.host.equals(host, ignoreCase = true) &&
uri.userInfo == null &&
(uri.port == -1 || uri.port == 443)
) {
"$provider endpoint must use $scheme://$host on the default TLS port"
}
}
private fun requireExactExternalUrl(value: String, host: String, path: String, name: String) {
val uri = URI(value)
require(
uri.scheme.equals("https", ignoreCase = true) &&
uri.host.equals(host, ignoreCase = true) &&
uri.port == -1 &&
uri.path.trimEnd('/') == path &&
uri.rawQuery == null &&
uri.rawFragment == null
) {
"$name must be https://$host$path"
}
}
private fun requireOfficialAppStoreUrl(value: String) {
val uri = URI(value)
require(
uri.scheme.equals("https", ignoreCase = true) &&
uri.host.equals("apps.apple.com", ignoreCase = true) &&
uri.port == -1 &&
uri.userInfo == null &&
uri.rawFragment == null &&
APP_STORE_APP_PATH.matches(uri.path)
) {
"APP_STORE_URL must be an official apps.apple.com URL ending in a non-zero numeric App ID"
}
}
private val APP_STORE_APP_PATH = Regex("/.+/id[1-9][0-9]*")