diff --git a/.gitignore b/.gitignore index bb791d1..d512c1f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,6 @@ gradle/ build/ logs/ -config/ data/ dokka/ dist/ diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationListeners.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationListeners.kt new file mode 100644 index 0000000..353ee71 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationListeners.kt @@ -0,0 +1,73 @@ +package dev.fyloz.colorrecipesexplorer.config + +import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties +import dev.fyloz.colorrecipesexplorer.config.properties.MaterialTypeProperties +import dev.fyloz.colorrecipesexplorer.emergencyMode +import dev.fyloz.colorrecipesexplorer.rest.CRE_PROPERTIES +import dev.fyloz.colorrecipesexplorer.restartApplication +import dev.fyloz.colorrecipesexplorer.service.MaterialTypeService +import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService +import org.slf4j.Logger +import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.ApplicationListener +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +import javax.annotation.PostConstruct +import kotlin.concurrent.thread + +@Configuration +@Order(Ordered.HIGHEST_PRECEDENCE) +@Profile("!emergency") +class ApplicationReadyListener( + private val materialTypeService: MaterialTypeService, + private val configurationService: ConfigurationService, + private val materialTypeProperties: MaterialTypeProperties, + private val creProperties: CreProperties, + private val logger: Logger +) : ApplicationListener { + override fun onApplicationEvent(event: ApplicationReadyEvent) { + if (emergencyMode) { + logger.error("Emergency mode is enabled, default material types will not be created") + thread { + Thread.sleep(1000) + logger.warn("Restarting in emergency mode...") + restartApplication(true) + } + return + } + + initDatabaseConfigurations() + initMaterialTypes() + CRE_PROPERTIES = creProperties + } + + private fun initMaterialTypes() { + logger.info("Initializing system material types") + materialTypeService.saveSystemTypes(materialTypeProperties.systemTypes) + } + + private fun initDatabaseConfigurations() { + configurationService.initializeProperties { !it.file } + } +} + +@Configuration("configurationsInitializer") +class ConfigurationsInitializer( + private val configurationService: ConfigurationService +) { + @PostConstruct + fun initializeFileConfigurations() { + configurationService.initializeProperties { it.file } + } +} + +class ApplicationInitializer : ApplicationListener { + override fun onApplicationEvent(event: ApplicationEnvironmentPreparedEvent) { + if (emergencyMode) { + event.environment.setActiveProfiles("emergency") + } + } +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt new file mode 100644 index 0000000..bca86e3 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt @@ -0,0 +1,135 @@ +package dev.fyloz.colorrecipesexplorer.config.security + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties +import dev.fyloz.colorrecipesexplorer.exception.NotFoundException +import dev.fyloz.colorrecipesexplorer.model.account.UserLoginRequest +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SignatureAlgorithm +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter +import org.springframework.util.Assert +import org.springframework.web.util.WebUtils +import java.util.* +import javax.servlet.FilterChain +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +const val authorizationCookieName = "Authorization" +const val defaultGroupCookieName = "Default-Group" +val blacklistedJwtTokens = mutableListOf() + +class JwtAuthenticationFilter( + private val authManager: AuthenticationManager, + private val securityConfigurationProperties: CreSecurityProperties, + private val updateUserLoginTime: (Long) -> Unit +) : UsernamePasswordAuthenticationFilter() { + private var debugMode = false + + init { + setFilterProcessesUrl("/api/login") + debugMode = "debug" in environment.activeProfiles + } + + override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication { + val loginRequest = jacksonObjectMapper().readValue(request.inputStream, UserLoginRequest::class.java) + return authManager.authenticate(UsernamePasswordAuthenticationToken(loginRequest.id, loginRequest.password)) + } + + override fun successfulAuthentication( + request: HttpServletRequest, + response: HttpServletResponse, + chain: FilterChain, + authResult: Authentication + ) { + val jwtSecret = securityConfigurationProperties.jwtSecret + val jwtDuration = securityConfigurationProperties.jwtDuration + Assert.notNull(jwtSecret, "No JWT secret has been defined.") + Assert.notNull(jwtDuration, "No JWT duration has been defined.") + val userId = (authResult.principal as User).username + updateUserLoginTime(userId.toLong()) + val expirationMs = System.currentTimeMillis() + jwtDuration + val expirationDate = Date(expirationMs) + val token = Jwts.builder() + .setSubject(userId) + .setExpiration(expirationDate) + .signWith(SignatureAlgorithm.HS512, jwtSecret.toByteArray()) + .compact() + response.addHeader("Access-Control-Expose-Headers", "X-Authentication-Expiration") + var bearerCookie = + "$authorizationCookieName=Bearer$token; Max-Age=${jwtDuration / 1000}; HttpOnly; SameSite=strict" + if (!debugMode) bearerCookie += "; Secure;" + response.addHeader( + "Set-Cookie", + bearerCookie + ) + response.addHeader(authorizationCookieName, "Bearer $token") + response.addHeader("X-Authentication-Expiration", "$expirationMs") + } +} + +class JwtAuthorizationFilter( + private val securityConfigurationProperties: CreSecurityProperties, + authenticationManager: AuthenticationManager, + private val loadUserById: (Long) -> UserDetails +) : BasicAuthenticationFilter(authenticationManager) { + override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) { + fun tryLoginFromBearer(): Boolean { + val authorizationCookie = WebUtils.getCookie(request, authorizationCookieName) + // Check for an authorization token cookie or header + val authorizationToken = if (authorizationCookie != null) + authorizationCookie.value + else + request.getHeader(authorizationCookieName) + + // An authorization token is valid if it starts with "Bearer", is not expired and is not blacklisted + if (authorizationToken != null && authorizationToken.startsWith("Bearer") && authorizationToken !in blacklistedJwtTokens) { + val authenticationToken = getAuthentication(authorizationToken) ?: return false + SecurityContextHolder.getContext().authentication = authenticationToken + return true + } + return false + } + + fun tryLoginFromDefaultGroupCookie() { + val defaultGroupCookie = WebUtils.getCookie(request, defaultGroupCookieName) + if (defaultGroupCookie != null) { + val authenticationToken = getAuthenticationToken(defaultGroupCookie.value) + SecurityContextHolder.getContext().authentication = authenticationToken + } + } + + if (!tryLoginFromBearer()) + tryLoginFromDefaultGroupCookie() + chain.doFilter(request, response) + } + + private fun getAuthentication(token: String): UsernamePasswordAuthenticationToken? { + val jwtSecret = securityConfigurationProperties.jwtSecret + Assert.notNull(jwtSecret, "No JWT secret has been defined.") + return try { + val userId = Jwts.parser() + .setSigningKey(jwtSecret.toByteArray()) + .parseClaimsJws(token.replace("Bearer", "")) + .body + .subject + if (userId != null) getAuthenticationToken(userId) else null + } catch (_: ExpiredJwtException) { + null + } + } + + private fun getAuthenticationToken(userId: String): UsernamePasswordAuthenticationToken? = try { + val userDetails = loadUserById(userId.toLong()) + UsernamePasswordAuthenticationToken(userDetails.username, null, userDetails.authorities) + } catch (_: NotFoundException) { + null + } +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt new file mode 100644 index 0000000..5440e61 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt @@ -0,0 +1,239 @@ +package dev.fyloz.colorrecipesexplorer.config.security + +import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties +import dev.fyloz.colorrecipesexplorer.emergencyMode +import dev.fyloz.colorrecipesexplorer.model.account.Permission +import dev.fyloz.colorrecipesexplorer.model.account.User +import dev.fyloz.colorrecipesexplorer.service.CreUserDetailsService +import dev.fyloz.colorrecipesexplorer.service.UserService +import org.slf4j.Logger +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.* +import org.springframework.core.env.Environment +import org.springframework.http.HttpMethod +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.core.AuthenticationException +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UsernameNotFoundException +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.stereotype.Component +import org.springframework.util.Assert +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.UrlBasedCorsConfigurationSource +import javax.annotation.PostConstruct +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse +import org.springframework.security.core.userdetails.User as SpringUser + +@Configuration +@Profile("!emergency") +@EnableWebSecurity +@EnableGlobalMethodSecurity(prePostEnabled = true) +@EnableConfigurationProperties(CreSecurityProperties::class) +class SecurityConfig( + private val securityProperties: CreSecurityProperties, + @Lazy private val userDetailsService: CreUserDetailsService, + @Lazy private val userService: UserService, + private val environment: Environment, + private val logger: Logger +) : WebSecurityConfigurerAdapter() { + var debugMode = false + + override fun configure(authBuilder: AuthenticationManagerBuilder) { + authBuilder.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()) + } + + @Bean + fun passwordEncoder() = + getPasswordEncoder() + + @Bean + fun corsConfigurationSource() = + getCorsConfigurationSource() + + @PostConstruct + fun initWebSecurity() { + if (emergencyMode) { + logger.error("Emergency mode is enabled, system users will not be created") + return + } + + debugMode = "debug" in environment.activeProfiles + if (debugMode) logger.warn("Debug mode is enabled, security will be decreased!") + + // Create Root user + assertRootUserNotNull(securityProperties) + createSystemUser( + securityProperties.root!!, + userService, + passwordEncoder(), + "Root", + "User", + listOf(Permission.ADMIN) + ) + } + + override fun configure(http: HttpSecurity) { + http + .headers().frameOptions().disable() + .and() + .csrf().disable() + .addFilter( + JwtAuthenticationFilter(authenticationManager(), securityProperties) { + userService.updateLastLoginTime(it) + } + ) + .addFilter( + JwtAuthorizationFilter(securityProperties, authenticationManager()) { + userDetailsService.loadUserById(it, false) + } + ) + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + + if (!debugMode) { + http.authorizeRequests() + .antMatchers("/api/login").permitAll() + .antMatchers("/api/logout").fullyAuthenticated() + .antMatchers("/api/user/current").fullyAuthenticated() + .anyRequest().fullyAuthenticated() + } else { + http + .cors() + .and() + .authorizeRequests() + .antMatchers("**").permitAll() + } + } +} + +@Configuration +@Profile("emergency") +@EnableConfigurationProperties(CreSecurityProperties::class) +class EmergencySecurityConfig( + private val securityProperties: CreSecurityProperties, + private val environment: Environment +) : WebSecurityConfigurerAdapter() { + private val rootUserRole = Permission.ADMIN.name + + init { + emergencyMode = true + } + + @Bean + fun corsConfigurationSource() = + getCorsConfigurationSource() + + @Bean + fun passwordEncoder() = + getPasswordEncoder() + + override fun configure(auth: AuthenticationManagerBuilder) { + assertRootUserNotNull(securityProperties) + // Create in-memory root user + auth.inMemoryAuthentication() + .withUser(securityProperties.root!!.id.toString()) + .password(passwordEncoder().encode(securityProperties.root!!.password)) + .authorities(SimpleGrantedAuthority(rootUserRole)) + } + + override fun configure(http: HttpSecurity) { + val debugMode = "debug" in environment.activeProfiles + + http + .headers().frameOptions().disable() + .and() + .csrf().disable() + .addFilter( + JwtAuthenticationFilter(authenticationManager(), securityProperties) { } + ) + .addFilter( + JwtAuthorizationFilter(securityProperties, authenticationManager(), this::loadUserById) + ) + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .authorizeRequests() + .antMatchers("**").fullyAuthenticated() + .antMatchers("/api/login").permitAll() + + if (debugMode) { + http.cors() + } + } + + private fun loadUserById(id: Long): UserDetails { + assertRootUserNotNull(securityProperties) + if (id == securityProperties.root!!.id) { + return SpringUser( + id.toString(), + securityProperties.root!!.password, + listOf(SimpleGrantedAuthority(rootUserRole)) + ) + } + throw UsernameNotFoundException(id.toString()) + } +} + +@Component +class RestAuthenticationEntryPoint : AuthenticationEntryPoint { + override fun commence( + request: HttpServletRequest, + response: HttpServletResponse, + authException: AuthenticationException + ) = response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized") +} + +fun createSystemUser( + credentials: CreSecurityProperties.SystemUserCredentials, + userService: UserService, + passwordEncoder: PasswordEncoder, + firstName: String, + lastName: String, + permissions: List +) { + Assert.notNull(credentials.id, "A system user has no identifier defined") + Assert.notNull(credentials.password, "A system user has no password defined") + + if (!userService.existsById(credentials.id)) { + userService.save( + User( + id = credentials.id, + firstName = firstName, + lastName = lastName, + password = passwordEncoder.encode(credentials.password), + isSystemUser = true, + permissions = permissions.toMutableSet() + ) + ) + } +} + +fun getPasswordEncoder() = + BCryptPasswordEncoder() + +fun getCorsConfigurationSource() = + UrlBasedCorsConfigurationSource().apply { + registerCorsConfiguration("/**", CorsConfiguration().apply { + allowedOrigins = listOf("http://localhost:4200") // Angular development server + allowedMethods = listOf( + HttpMethod.GET.name, + HttpMethod.POST.name, + HttpMethod.PUT.name, + HttpMethod.DELETE.name, + HttpMethod.OPTIONS.name, + HttpMethod.HEAD.name + ) + allowCredentials = true + }.applyPermitDefaultValues()) + } + +private fun assertRootUserNotNull(securityProperties: CreSecurityProperties) { + Assert.notNull(securityProperties.root, "cre.security.root should be defined") +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/config/ConfigurationService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/config/ConfigurationService.kt new file mode 100644 index 0000000..03eef2f --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/config/ConfigurationService.kt @@ -0,0 +1,194 @@ +package dev.fyloz.colorrecipesexplorer.service.config + +import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties +import dev.fyloz.colorrecipesexplorer.model.* +import dev.fyloz.colorrecipesexplorer.service.FileService +import dev.fyloz.colorrecipesexplorer.utils.decrypt +import dev.fyloz.colorrecipesexplorer.utils.encrypt +import org.slf4j.Logger +import org.springframework.context.annotation.Lazy +import org.springframework.security.crypto.keygen.KeyGenerators +import org.springframework.stereotype.Service + +interface ConfigurationService { + /** Gets all set configurations. */ + fun getAll(): List + + /** + * Gets all configurations with keys contained in the given [formattedKeyList]. + * The [formattedKeyList] contains wanted configuration keys separated by a semi-colon. + */ + fun getAll(formattedKeyList: String): List + + /** + * Gets the configuration with the given [key]. + * If the [key] does not exists, an [InvalidConfigurationKeyException] will be thrown. + */ + fun get(key: String): Configuration + + /** Gets the configuration with the given [type]. */ + fun get(type: ConfigurationType): Configuration + + /** Sets the content of each configuration in the given [configurations] list. */ + fun set(configurations: List) + + /** + * Sets the content of the configuration matching the given [configuration]. + * If the given key does not exists, an [InvalidConfigurationKeyException] will be thrown. + */ + fun set(configuration: ConfigurationDto) + + /** Sets the content given [configuration]. */ + fun set(configuration: Configuration) + + /** Sets the content of the configuration matching the given [configuration] with a given image. */ + fun set(configuration: ConfigurationImageDto) + + /** Initialize the properties matching the given [predicate]. */ + fun initializeProperties(predicate: (ConfigurationType) -> Boolean) +} + +const val CONFIGURATION_LOGO_FILE_PATH = "images/logo" +const val CONFIGURATION_ICON_FILE_PATH = "images/icon" +const val CONFIGURATION_FORMATTED_LIST_DELIMITER = ';' + +@Service("configurationService") +class ConfigurationServiceImpl( + @Lazy private val fileService: FileService, + private val configurationSource: ConfigurationSource, + private val securityProperties: CreSecurityProperties, + private val logger: Logger +) : ConfigurationService { + private val saltConfigurationType = ConfigurationType.GENERATED_ENCRYPTION_SALT + private val encryptionSalt by lazy { + securityProperties.configSalt ?: getGeneratedSalt() + } + + override fun getAll() = + ConfigurationType.values().mapNotNull { + try { + get(it) + } catch (_: ConfigurationNotSetException) { + null + } catch (_: InvalidConfigurationKeyException) { + null + } + } + + override fun getAll(formattedKeyList: String) = + formattedKeyList.split(CONFIGURATION_FORMATTED_LIST_DELIMITER).mapNotNull { + try { + get(it) + } catch (_: ConfigurationNotSetException) { + null + } catch (_: InvalidConfigurationKeyException) { + null + } + } + + override fun get(key: String) = + get(key.toConfigurationType()) + + override fun get(type: ConfigurationType): Configuration { + // Encryption salt should never be returned, but cannot be set as "secure" without encrypting it + if (type == ConfigurationType.GENERATED_ENCRYPTION_SALT) throw InvalidConfigurationKeyException(type.key) + + val configuration = configurationSource.get(type) ?: throw ConfigurationNotSetException(type) + return if (type.secure) { + decryptConfiguration(configuration) + } else { + configuration + } + } + + override fun set(configurations: List) { + configurationSource.set( + configurations + .map(::configuration) + .map(this::encryptConfigurationIfSecure) + ) + } + + override fun set(configuration: ConfigurationDto) = + set(configuration(configuration)) + + override fun set(configuration: Configuration) { + configurationSource.set(encryptConfigurationIfSecure(configuration)) + } + + override fun set(configuration: ConfigurationImageDto) { + val filePath = when (val configurationType = configuration.key.toConfigurationType()) { + ConfigurationType.INSTANCE_LOGO_PATH -> CONFIGURATION_LOGO_FILE_PATH + ConfigurationType.INSTANCE_ICON_PATH -> CONFIGURATION_ICON_FILE_PATH + else -> throw InvalidImageConfigurationException(configurationType) + } + + fileService.write(configuration.image, filePath, true) + } + + override fun initializeProperties(predicate: (ConfigurationType) -> Boolean) { + ConfigurationType.values() + .filter(predicate) + .filter { !it.computed } // Can't initialize computed configurations + .filter { it != ConfigurationType.GENERATED_ENCRYPTION_SALT } + .forEach { + try { + get(it) + } catch (_: ConfigurationNotSetException) { + with(it.defaultContent) { + if (this != null) { // Ignores configurations with null default values + logger.info("Configuration ${it.key} was not set and will be initialized to a default value") + set(configuration(type = it, content = this.toString())) + } + } + } + } + } + + private fun encryptConfigurationIfSecure(configuration: Configuration) = + with(configuration) { + if (type.secure) { + encryptConfiguration(this) + } else { + this + } + } + + private fun encryptConfiguration(configuration: Configuration) = + with(configuration) { + configuration( + type = type, + content = content.encrypt(type.key, encryptionSalt) + ) + } + + private fun decryptConfiguration(configuration: Configuration) = + with(configuration) { + try { + configuration( + type = type, + content = content.decrypt(type.key, encryptionSalt) + ) + } catch (ex: IllegalStateException) { + logger.error( + "Could not read encrypted configuration, using default value. Are you using the correct salt?", + ex + ) + configuration(type = type) + } + } + + private fun getGeneratedSalt(): String { + logger.warn("Sensitives configurations encryption salt was not configured, using generated salt") + logger.warn("Consider configuring the encryption salt. More details at: https://git.fyloz.dev/color-recipes-explorer/backend/-/wikis/Configuration/S%C3%A9curit%C3%A9/#sel") + + var saltConfiguration = configurationSource.get(saltConfigurationType) + if (saltConfiguration == null) { + val generatedSalt = KeyGenerators.string().generateKey() + saltConfiguration = configuration(type = saltConfigurationType, content = generatedSalt) + configurationSource.set(saltConfiguration) + } + + return saltConfiguration.content + } +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/config/ConfigurationSource.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/config/ConfigurationSource.kt new file mode 100644 index 0000000..0b00a97 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/config/ConfigurationSource.kt @@ -0,0 +1,171 @@ +package dev.fyloz.colorrecipesexplorer.service.config + +import dev.fyloz.colorrecipesexplorer.SUPPORTED_DATABASE_VERSION +import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties +import dev.fyloz.colorrecipesexplorer.emergencyMode +import dev.fyloz.colorrecipesexplorer.model.CannotSetComputedConfigurationException +import dev.fyloz.colorrecipesexplorer.model.Configuration +import dev.fyloz.colorrecipesexplorer.model.ConfigurationType +import dev.fyloz.colorrecipesexplorer.model.configuration +import dev.fyloz.colorrecipesexplorer.repository.ConfigurationRepository +import dev.fyloz.colorrecipesexplorer.service.create +import dev.fyloz.colorrecipesexplorer.utils.excludeAll +import org.slf4j.Logger +import org.springframework.boot.info.BuildProperties +import org.springframework.context.annotation.Lazy +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Component +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.util.* + +const val CONFIGURATION_FILE_PATH = "config.properties" +const val CONFIGURATION_FILE_COMMENT = "---Color Recipes Explorer configuration---" + +interface ConfigurationSource { + fun get(type: ConfigurationType): Configuration? + fun set(configuration: Configuration) + fun set(configurations: Iterable) +} + +@Component("configurationSource") +class CompositeConfigurationSource( + @Lazy private val configurationRepository: ConfigurationRepository, + private val properties: CreProperties, + private val buildInfo: BuildProperties, + private val logger: Logger +) : ConfigurationSource { + private val repository by lazy { RepositoryConfigurationSource(configurationRepository) } + private val file by lazy { + FileConfigurationSource("${properties.configDirectory}/$CONFIGURATION_FILE_PATH") + } + private val computed by lazy { + ComputedConfigurationSource(buildInfo) + } + + override fun get(type: ConfigurationType) = + when { + type.file -> file.get(type) + type.computed -> computed.get(type) + !emergencyMode -> repository.get(type) + else -> null + } + + override fun set(configuration: Configuration) = + when { + configuration.type.file -> file.set(configuration) + configuration.type.computed -> throw CannotSetComputedConfigurationException(configuration.type) + !emergencyMode -> repository.set(configuration) + else -> { + } + } + + override fun set(configurations: Iterable) { + val mutableConfigurations = configurations.toMutableList() + val fileConfigurations = mutableConfigurations.excludeAll { it.type.file } + val repositoryConfigurations = mutableConfigurations.excludeAll { !emergencyMode } + + repository.set(repositoryConfigurations) + file.set(fileConfigurations) + + mutableConfigurations.forEach { + logger.warn("Could not find where to store updated value of configuration '${it.key}'") + } + } +} + +private class RepositoryConfigurationSource( + private val repository: ConfigurationRepository +) : ConfigurationSource { + override fun get(type: ConfigurationType) = + repository.findByIdOrNull(type.key)?.toConfiguration() + + override fun set(configuration: Configuration) { + repository.save(configuration.toEntity()) + } + + override fun set(configurations: Iterable) = + configurations.forEach { set(it) } +} + +private class FileConfigurationSource( + private val configFilePath: String +) : ConfigurationSource { + private val properties = Properties().apply { + with(File(configFilePath)) { + if (!this.exists()) this.create() + FileInputStream(this).use { + this@apply.load(it) + } + } + } + + override fun get(type: ConfigurationType) = + if (properties.containsKey(type.key)) + configuration( + type, + getConfigurationContent(type.key), + LocalDateTime.parse(getConfigurationContent(configurationLastUpdateKey(type.key))) + ) + else null + + override fun set(configuration: Configuration) { + setConfigurationContent(configuration.type.key, configuration.content) + save() + } + + override fun set(configurations: Iterable) { + configurations.forEach { + setConfigurationContent(it.type.key, it.content) + } + save() + } + + fun save() { + FileOutputStream(configFilePath).use { + properties.store(it, CONFIGURATION_FILE_COMMENT) + } + } + + private fun getConfigurationContent(key: String) = + properties[key] as String + + private fun setConfigurationContent(key: String, content: String) { + properties[key] = content + properties[configurationLastUpdateKey(key)] = LocalDateTime.now().toString() + } + + private fun configurationLastUpdateKey(key: String) = "$key.last-updated" +} + +private class ComputedConfigurationSource( + private val buildInfo: BuildProperties +) : ConfigurationSource { + override fun get(type: ConfigurationType) = configuration( + type, when (type) { + ConfigurationType.EMERGENCY_MODE_ENABLED -> emergencyMode + ConfigurationType.BUILD_VERSION -> buildInfo.version + ConfigurationType.BUILD_TIME -> LocalDate.ofInstant(buildInfo.time, ZoneId.systemDefault()).toString() + ConfigurationType.DATABASE_SUPPORTED_VERSION -> SUPPORTED_DATABASE_VERSION + ConfigurationType.JAVA_VERSION -> Runtime.version() + ConfigurationType.OPERATING_SYSTEM -> "${System.getProperty("os.name")} ${System.getProperty("os.version")} ${ + System.getProperty( + "os.arch" + ) + }" + else -> throw IllegalArgumentException("Cannot get the value of the configuration with the key ${type.key} because it is not a computed configuration") + }.toString() + ) + + override fun set(configuration: Configuration) { + throw UnsupportedOperationException("Cannot set computed configurations") + } + + override fun set(configurations: Iterable) { + throw UnsupportedOperationException("Cannot set computed configurations") + } +}