diff --git a/.gitignore b/.gitignore index a57ac6a..d512c1f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ dist/ out/ /src/main/resources/angular/static/* + +config.properties diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8807fd4..3d4493e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -48,7 +48,7 @@ package: ARTIFACT_NAME: "ColorRecipesExplorer-backend-$CI_PIPELINE_IID" script: - docker rm $PACKAGE_CONTAINER_NAME || true - - docker run --name $PACKAGE_CONTAINER_NAME $CI_REGISTRY_IMAGE_GRADLE gradle bootJar + - docker run --name $PACKAGE_CONTAINER_NAME $CI_REGISTRY_IMAGE_GRADLE gradle bootJar -Pversion=$CI_PIPELINE_IID - docker cp $PACKAGE_CONTAINER_NAME:/usr/src/cre/build/libs/ColorRecipesExplorer.jar $ARTIFACT_NAME.jar - docker build -t $CI_REGISTRY_IMAGE_BACKEND --build-arg JDK_VERSION=$JDK_VERSION --build-arg PORT=$PORT --build-arg ARTIFACT_NAME=$ARTIFACT_NAME . - docker push $CI_REGISTRY_IMAGE_BACKEND @@ -81,4 +81,4 @@ deploy: script: - ssh -p $DEPLOYMENT_SERVER_SSH_PORT $DEPLOYMENT_SERVER_USERNAME@$DEPLOYMENT_SERVER "docker stop $DEPLOYED_CONTAINER_NAME || true && docker rm $DEPLOYED_CONTAINER_NAME || true" - ssh -p $DEPLOYMENT_SERVER_SSH_PORT $DEPLOYMENT_SERVER_USERNAME@$DEPLOYMENT_SERVER "docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY && docker pull $CI_REGISTRY_IMAGE_BACKEND" - - ssh -p $DEPLOYMENT_SERVER_SSH_PORT $DEPLOYMENT_SERVER_USERNAME@$DEPLOYMENT_SERVER "docker run -d -p $PORT:$PORT --name=$DEPLOYED_CONTAINER_NAME -v $DATA_VOLUME:/usr/bin/cre/data -e spring_profiles_active=$SPRING_PROFILES -e spring_datasource_username=$DB_USERNAME -e spring_datasource_password=$DB_PASSWORD -e spring_datasource_url=$DB_URL -e cre_server_deployment_url=$DEPLOYMENT_URL -e databaseupdater_username=$DB_UPDATE_USERNAME -e databaseupdater_password=$DB_UPDATE_PASSWORD $CI_REGISTRY_IMAGE_BACKEND" + - ssh -p $DEPLOYMENT_SERVER_SSH_PORT $DEPLOYMENT_SERVER_USERNAME@$DEPLOYMENT_SERVER "docker run -d -p $PORT:$PORT --name=$DEPLOYED_CONTAINER_NAME -v $DATA_VOLUME:/usr/bin/cre/data -e spring_profiles_active=$SPRING_PROFILES -e $CI_REGISTRY_IMAGE_BACKEND" diff --git a/build.gradle.kts b/build.gradle.kts index e437730..efcc6f0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -58,6 +58,12 @@ dependencies { runtimeOnly("mysql:mysql-connector-java:8.0.22") runtimeOnly("org.postgresql:postgresql:42.2.16") runtimeOnly("com.microsoft.sqlserver:mssql-jdbc:9.2.1.jre11") + + implementation("org.springframework.cloud:spring-cloud-starter:2.2.8.RELEASE") +} + +springBoot { + buildInfo() } java { diff --git a/src/main/java/dev/fyloz/colorrecipesexplorer/service/files/XlsService.java b/src/main/java/dev/fyloz/colorrecipesexplorer/service/files/XlsService.java index 50553b6..812bd0a 100644 --- a/src/main/java/dev/fyloz/colorrecipesexplorer/service/files/XlsService.java +++ b/src/main/java/dev/fyloz/colorrecipesexplorer/service/files/XlsService.java @@ -5,6 +5,7 @@ import dev.fyloz.colorrecipesexplorer.service.RecipeService; import dev.fyloz.colorrecipesexplorer.xlsx.XlsxExporter; import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import java.io.ByteArrayOutputStream; @@ -14,6 +15,7 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @Service +@Profile("!emergency") public class XlsService { private final RecipeService recipeService; diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/ColorRecipesExplorerApplication.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/ColorRecipesExplorerApplication.kt index 17c867a..13cef5b 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/ColorRecipesExplorerApplication.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/ColorRecipesExplorerApplication.kt @@ -1,20 +1,37 @@ package dev.fyloz.colorrecipesexplorer -import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties -import dev.fyloz.colorrecipesexplorer.config.properties.MaterialTypeProperties +import dev.fyloz.colorrecipesexplorer.config.ApplicationInitializer import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration -import org.springframework.boot.context.properties.EnableConfigurationProperties -import org.springframework.boot.runApplication +import org.springframework.boot.builder.SpringApplicationBuilder +import org.springframework.context.ConfigurableApplicationContext @SpringBootApplication(exclude = [LiquibaseAutoConfiguration::class]) -@EnableConfigurationProperties( - MaterialTypeProperties::class, - CreProperties::class, - DatabaseUpdaterProperties::class -) class ColorRecipesExplorerApplication +var emergencyMode = false + +private lateinit var context: ConfigurableApplicationContext +private lateinit var classLoader: ClassLoader + fun main() { - runApplication() + classLoader = Thread.currentThread().contextClassLoader + context = runApplication() } + +fun restartApplication(enableEmergencyMode: Boolean = false) { + val thread = Thread { + emergencyMode = enableEmergencyMode + context.close() + context = runApplication() + } + + thread.contextClassLoader = classLoader + thread.isDaemon = false + thread.start() +} + +private fun runApplication() = + SpringApplicationBuilder(ColorRecipesExplorerApplication::class.java).apply { + listeners(ApplicationInitializer()) + }.run() diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/DatabaseVersioning.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/DatabaseVersioning.kt index fae5ad8..ca54fa3 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/DatabaseVersioning.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/DatabaseVersioning.kt @@ -1,37 +1,82 @@ package dev.fyloz.colorrecipesexplorer +import dev.fyloz.colorrecipesexplorer.config.FileConfiguration import dev.fyloz.colorrecipesexplorer.databasemanager.CreDatabase +import dev.fyloz.colorrecipesexplorer.databasemanager.CreDatabaseException import dev.fyloz.colorrecipesexplorer.databasemanager.databaseContext import dev.fyloz.colorrecipesexplorer.databasemanager.databaseUpdaterProperties +import dev.fyloz.colorrecipesexplorer.model.ConfigurationType import org.slf4j.Logger import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.jdbc.DataSourceBuilder import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.core.env.Environment +import org.springframework.context.annotation.DependsOn +import org.springframework.context.annotation.Profile +import org.springframework.core.env.ConfigurableEnvironment +import java.lang.RuntimeException import javax.sql.DataSource const val SUPPORTED_DATABASE_VERSION = 5 const val ENV_VAR_ENABLE_DATABASE_UPDATE_NAME = "CRE_ENABLE_DB_UPDATE" val DATABASE_NAME_REGEX = Regex("(\\w+)$") +@Profile("!emergency") @Configuration +@DependsOn("configurationsInitializer") class DataSourceConfiguration { @Bean(name = ["dataSource"]) - @ConfigurationProperties(prefix = "spring.datasource") fun customDataSource( - logger: Logger, - environment: Environment, - databaseUpdaterProperties: DatabaseUpdaterProperties + logger: Logger, + environment: ConfigurableEnvironment, + fileConfiguration: FileConfiguration, + databaseUpdaterProperties: DatabaseUpdaterProperties ): DataSource { - val databaseUrl: String = environment.getProperty("spring.datasource.url")!! + fun getConfiguration(type: ConfigurationType, defaultProperty: String) = + fileConfiguration.get(type)?.content ?: defaultProperty - runDatabaseVersionCheck(logger, databaseUrl, databaseUpdaterProperties) + val databaseUrl = "jdbc:" + getConfiguration(ConfigurationType.DATABASE_URL, databaseUpdaterProperties.url) + val databaseUsername = getConfiguration(ConfigurationType.DATABASE_USER, databaseUpdaterProperties.username) + val databasePassword = getConfiguration(ConfigurationType.DATABASE_PASSWORD, databaseUpdaterProperties.password) + + try { + runDatabaseVersionCheck(logger, databaseUrl, DatabaseUpdaterProperties().apply { + url = databaseUrl + username = databaseUsername + password = databasePassword + }) + } catch (ex: Exception) { + logger.error("Could not access database, restarting in emergency mode...", ex) + emergencyMode = true + + return emergencyDataSource() + } return DataSourceBuilder - .create() - .url(databaseUrl) // Hikari won't start without that - .build() + .create() + .url(databaseUrl) + .username(databaseUsername) + .password(databasePassword) + .driverClassName(getDriverClassName(databaseUrl)) + .build() + } + + private fun emergencyDataSource() = with("jdbc:h2:mem:emergency") { + DataSourceBuilder + .create() + .url(this) + .driverClassName(getDriverClassName(this)) + .username("sa") + .password("") + .build() + } + + private fun getDriverClassName(url: String) = when { + url.startsWith("jdbc:postgres") -> "org.postgresql.Driver" + url.startsWith("jdbc:mssql") -> "com.microsoft.sqlserver.jdbc.SQLServerDriver" + url.startsWith("jdbc:mysql") -> "com.mysql.cj.jdbc.Driver" + url.startsWith("jdbc:h2") -> "org.h2.Driver" + else -> "org.h2.Driver" } } @@ -75,24 +120,24 @@ fun runDatabaseUpdate(logger: Logger, database: CreDatabase) { } fun getDatabase( - databaseUrl: String, - databaseUpdaterProperties: DatabaseUpdaterProperties, - logger: Logger + databaseUrl: String, + databaseUpdaterProperties: DatabaseUpdaterProperties, + logger: Logger ): CreDatabase { val databaseName = - (DATABASE_NAME_REGEX.find(databaseUrl) ?: throw DatabaseVersioningException.InvalidUrl(databaseUrl)).value + (DATABASE_NAME_REGEX.find(databaseUrl) ?: throw DatabaseVersioningException.InvalidUrl(databaseUrl)).value return CreDatabase( - databaseContext( - properties = databaseUpdaterProperties( - targetVersion = SUPPORTED_DATABASE_VERSION, - url = databaseUrl.removeSuffix(databaseName), - dbName = databaseName, - username = databaseUpdaterProperties.username, - password = databaseUpdaterProperties.password - ), - logger - ) + databaseContext( + properties = databaseUpdaterProperties( + targetVersion = SUPPORTED_DATABASE_VERSION, + url = databaseUrl.removeSuffix(databaseName), + dbName = databaseName, + username = databaseUpdaterProperties.username, + password = databaseUpdaterProperties.password + ), + logger + ) ) } @@ -101,7 +146,7 @@ fun throwUnsupportedDatabaseVersion(version: Int, logger: Logger) { logger.error("Version $version of the database is not supported; Only version $SUPPORTED_DATABASE_VERSION is currently supported; Update this application to use the database.") } else { logger.error( - """Version $version of the database is not supported; Only version $SUPPORTED_DATABASE_VERSION is currently supported. + """Version $version of the database is not supported; Only version $SUPPORTED_DATABASE_VERSION is currently supported. |You can update the database to the supported version by either: | - Setting the environment variable '$ENV_VAR_ENABLE_DATABASE_UPDATE_NAME' to '1' to update the database automatically | - Updating the database manually with the database manager utility (https://git.fyloz.dev/color-recipes-explorer/database-manager) @@ -113,8 +158,9 @@ fun throwUnsupportedDatabaseVersion(version: Int, logger: Logger) { throw DatabaseVersioningException.UnsupportedDatabaseVersion(version) } -@ConfigurationProperties(prefix = "databaseupdater") +@ConfigurationProperties(prefix = "cre.database") class DatabaseUpdaterProperties { + var url: String = "" var username: String = "" var password: String = "" } @@ -122,5 +168,5 @@ class DatabaseUpdaterProperties { sealed class DatabaseVersioningException(message: String) : Exception(message) { class InvalidUrl(url: String) : DatabaseVersioningException("Invalid database url: $url") class UnsupportedDatabaseVersion(version: Int) : - DatabaseVersioningException("Unsupported database version: $version; Only version $SUPPORTED_DATABASE_VERSION is currently supported") + DatabaseVersioningException("Unsupported database version: $version; Only version $SUPPORTED_DATABASE_VERSION is currently supported") } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationReadyListener.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationReadyListener.kt index f135661..f70db75 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationReadyListener.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationReadyListener.kt @@ -2,23 +2,50 @@ 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 org.slf4j.Logger +import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.boot.context.properties.EnableConfigurationProperties 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 kotlin.concurrent.thread @Configuration @Order(Ordered.HIGHEST_PRECEDENCE) +@Profile("!emergency") class ApplicationReadyListener( private val materialTypeService: MaterialTypeService, private val materialTypeProperties: MaterialTypeProperties, - private val creProperties: CreProperties + 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 + } + materialTypeService.saveSystemTypes(materialTypeProperties.systemTypes) CRE_PROPERTIES = creProperties } } + +class ApplicationInitializer : ApplicationListener { + override fun onApplicationEvent(event: ApplicationEnvironmentPreparedEvent) { + if (emergencyMode) { + event.environment.setActiveProfiles("emergency") + } + } +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ConfigurationsInitializer.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ConfigurationsInitializer.kt new file mode 100644 index 0000000..28b92f6 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ConfigurationsInitializer.kt @@ -0,0 +1,54 @@ +package dev.fyloz.colorrecipesexplorer.config + +import dev.fyloz.colorrecipesexplorer.model.ConfigurationType +import dev.fyloz.colorrecipesexplorer.model.configuration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.time.LocalDateTime +import java.util.* + +@Configuration +class ConfigurationsInitializer { + @Bean + fun fileConfiguration() = FileConfiguration() +} + +const val FILE_CONFIGURATION_PATH = "config.properties" +const val FILE_CONFIGURATION_COMMENT = "---Color Recipes Explorer configuration---" + +class FileConfiguration { + val properties = Properties().apply { + with(File(FILE_CONFIGURATION_PATH)) { + if (!this.exists()) this.createNewFile() + FileInputStream(this).use { + this@apply.load(it) + } + } + } + + fun get(type: ConfigurationType) = + if (properties.containsKey(type.key)) + configuration( + type, + properties[type.key] as String, + LocalDateTime.parse(properties[configurationLastUpdateKey(type.key)] as String) + ) + else null + + fun set(type: ConfigurationType, content: String) { + properties[type.key] = content + properties[configurationLastUpdateKey(type.key)] = LocalDateTime.now().toString() + save() + } + + fun save() { + FileOutputStream(FILE_CONFIGURATION_PATH).use { + properties.store(it, FILE_CONFIGURATION_COMMENT) + } + } + + private fun configurationLastUpdateKey(key: String) = "$key.last-updated" +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/EmergencySecurityConfig.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/EmergencySecurityConfig.kt new file mode 100644 index 0000000..b944b96 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/EmergencySecurityConfig.kt @@ -0,0 +1,95 @@ +package dev.fyloz.colorrecipesexplorer.config + +import dev.fyloz.colorrecipesexplorer.emergencyMode +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import org.springframework.http.HttpMethod +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter +import org.springframework.security.config.http.SessionCreationPolicy +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.web.cors.CorsConfiguration +import org.springframework.web.cors.UrlBasedCorsConfigurationSource +import org.springframework.security.core.userdetails.User as SpringUser + +@Configuration +@Profile("emergency") +@EnableConfigurationProperties(SecurityConfigurationProperties::class) +class EmergencySecurityConfig( + val securityConfigurationProperties: SecurityConfigurationProperties +) : WebSecurityConfigurerAdapter() { + init { + emergencyMode = true + } + + @Bean + fun corsConfigurationSource() = + 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()) + } + + @Bean + fun passwordEncoder() = + BCryptPasswordEncoder() + + override fun configure(auth: AuthenticationManagerBuilder) { + auth.inMemoryAuthentication() + .withUser(securityConfigurationProperties.root!!.id.toString()) + .password(passwordEncoder().encode(securityConfigurationProperties.root!!.password)) + .authorities(SimpleGrantedAuthority("ADMIN")) + } + + override fun configure(http: HttpSecurity) { + http + .headers().frameOptions().disable() + .and() + .csrf().disable() + .cors() + .and() + .addFilter( + JwtAuthenticationFilter( + authenticationManager(), + securityConfigurationProperties + ) { } + ) + .addFilter( + JwtAuthorizationFilter( + securityConfigurationProperties, + authenticationManager(), + this::loadUserById + ) + ) + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .authorizeRequests() + .antMatchers("**").permitAll() + } + + private fun loadUserById(id: Long): UserDetails { + if (id == securityConfigurationProperties.root!!.id) { + return SpringUser( + id.toString(), + securityConfigurationProperties.root!!.password, + listOf(SimpleGrantedAuthority("ADMIN")) + ) + } + throw UsernameNotFoundException(id.toString()) + } +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/SpringConfiguration.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/SpringConfiguration.kt index 5dc9eb5..ed053e7 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/SpringConfiguration.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/SpringConfiguration.kt @@ -1,12 +1,17 @@ package dev.fyloz.colorrecipesexplorer.config import dev.fyloz.colorrecipesexplorer.ColorRecipesExplorerApplication +import dev.fyloz.colorrecipesexplorer.DatabaseUpdaterProperties +import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties +import dev.fyloz.colorrecipesexplorer.config.properties.MaterialTypeProperties import org.slf4j.Logger import org.slf4j.LoggerFactory +import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @Configuration +@EnableConfigurationProperties(MaterialTypeProperties::class, CreProperties::class, DatabaseUpdaterProperties::class) class SpringConfiguration { @Bean fun logger(): Logger = LoggerFactory.getLogger(ColorRecipesExplorerApplication::class.java) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/WebSecurityConfig.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/WebSecurityConfig.kt index 1fb70ea..80e0ae0 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/WebSecurityConfig.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/WebSecurityConfig.kt @@ -1,14 +1,13 @@ package dev.fyloz.colorrecipesexplorer.config import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import dev.fyloz.colorrecipesexplorer.emergencyMode import dev.fyloz.colorrecipesexplorer.exception.NotFoundException -import dev.fyloz.colorrecipesexplorer.model.account.UserLoginRequest import dev.fyloz.colorrecipesexplorer.model.account.Permission import dev.fyloz.colorrecipesexplorer.model.account.User -import dev.fyloz.colorrecipesexplorer.service.UserService -import dev.fyloz.colorrecipesexplorer.service.UserServiceImpl +import dev.fyloz.colorrecipesexplorer.model.account.UserLoginRequest import dev.fyloz.colorrecipesexplorer.service.CreUserDetailsService -import dev.fyloz.colorrecipesexplorer.service.CreUserDetailsServiceImpl +import dev.fyloz.colorrecipesexplorer.service.UserService import io.jsonwebtoken.ExpiredJwtException import io.jsonwebtoken.Jwts import io.jsonwebtoken.SignatureAlgorithm @@ -18,6 +17,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Lazy +import org.springframework.context.annotation.Profile import org.springframework.core.env.Environment import org.springframework.http.HttpMethod import org.springframework.security.authentication.AuthenticationManager @@ -31,7 +31,7 @@ import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.core.Authentication import org.springframework.security.core.AuthenticationException import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.security.core.userdetails.User as SpringUser +import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.web.AuthenticationEntryPoint import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter @@ -46,15 +46,17 @@ import javax.annotation.PostConstruct import javax.servlet.FilterChain 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(SecurityConfigurationProperties::class) class WebSecurityConfig( val securityConfigurationProperties: SecurityConfigurationProperties, - @Lazy val userDetailsService: CreUserDetailsServiceImpl, - @Lazy val userService: UserServiceImpl, + @Lazy val userDetailsService: CreUserDetailsService, + @Lazy val userService: UserService, val environment: Environment, val logger: Logger ) : WebSecurityConfigurerAdapter() { @@ -66,51 +68,56 @@ class WebSecurityConfig( @Bean fun passwordEncoder() = - BCryptPasswordEncoder() + BCryptPasswordEncoder() @Bean override fun authenticationManagerBean(): AuthenticationManager = - super.authenticationManagerBean() + super.authenticationManagerBean() @Bean fun corsConfigurationSource() = - 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()) - } + 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()) + } @PostConstruct fun initWebSecurity() { fun createUser( - credentials: SecurityConfigurationProperties.SystemUserCredentials?, - firstName: String, - lastName: String, - permissions: List + credentials: SecurityConfigurationProperties.SystemUserCredentials?, + firstName: String, + lastName: String, + permissions: List ) { + if (emergencyMode) { + logger.error("Emergency mode is enabled, root user will not be created") + return + } + Assert.notNull(credentials, "No root user has been defined.") credentials!! Assert.notNull(credentials.id, "The root user has no identifier defined.") Assert.notNull(credentials.password, "The root 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() - ) + User( + id = credentials.id!!, + firstName = firstName, + lastName = lastName, + password = passwordEncoder().encode(credentials.password!!), + isSystemUser = true, + permissions = permissions.toMutableSet() + ) ) } } @@ -122,37 +129,35 @@ class WebSecurityConfig( override fun configure(http: HttpSecurity) { http - .headers().frameOptions().disable() - .and() - .csrf().disable() - .addFilter( - JwtAuthenticationFilter( - authenticationManager(), - userService, - securityConfigurationProperties + .headers().frameOptions().disable() + .and() + .csrf().disable() + .addFilter( + JwtAuthenticationFilter( + authenticationManager(), + securityConfigurationProperties + ) { userService.updateLastLoginTime(it) } ) - ) - .addFilter( - JwtAuthorizationFilter( - userDetailsService, - securityConfigurationProperties, - authenticationManager() + .addFilter( + JwtAuthorizationFilter( + securityConfigurationProperties, + authenticationManager() + ) { userDetailsService.loadUserById(it, false) } ) - ) - .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) if (!debugMode) { http.authorizeRequests() - .antMatchers("/api/login").permitAll() - .antMatchers("/api/logout").authenticated() - .antMatchers("/api/user/current").authenticated() - .anyRequest().authenticated() + .antMatchers("/api/login").permitAll() + .antMatchers("/api/logout").authenticated() + .antMatchers("/api/user/current").authenticated() + .anyRequest().authenticated() } else { http - .cors() - .and() - .authorizeRequests() - .antMatchers("**").permitAll() + .cors() + .and() + .authorizeRequests() + .antMatchers("**").permitAll() } } } @@ -160,9 +165,9 @@ class WebSecurityConfig( @Component class RestAuthenticationEntryPoint : AuthenticationEntryPoint { override fun commence( - request: HttpServletRequest, - response: HttpServletResponse, - authException: AuthenticationException + request: HttpServletRequest, + response: HttpServletResponse, + authException: AuthenticationException ) = response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized") } @@ -172,8 +177,8 @@ val blacklistedJwtTokens = mutableListOf() class JwtAuthenticationFilter( private val authManager: AuthenticationManager, - private val userService: UserService, - private val securityConfigurationProperties: SecurityConfigurationProperties + private val securityConfigurationProperties: SecurityConfigurationProperties, + private val updateUserLoginTime: (Long) -> Unit ) : UsernamePasswordAuthenticationFilter() { private var debugMode = false @@ -188,31 +193,31 @@ class JwtAuthenticationFilter( } override fun successfulAuthentication( - request: HttpServletRequest, - response: HttpServletResponse, - chain: FilterChain, - authResult: Authentication + 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 SpringUser).username - userService.updateLastLoginTime(userId.toLong()) + 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() + .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" + "$authorizationCookieName=Bearer$token; Max-Age=${jwtDuration / 1000}; HttpOnly; SameSite=strict" if (!debugMode) bearerCookie += "; Secure;" response.addHeader( - "Set-Cookie", - bearerCookie + "Set-Cookie", + bearerCookie ) response.addHeader(authorizationCookieName, "Bearer $token") response.addHeader("X-Authentication-Expiration", "$expirationMs") @@ -220,9 +225,9 @@ class JwtAuthenticationFilter( } class JwtAuthorizationFilter( - private val userDetailsService: CreUserDetailsService, private val securityConfigurationProperties: SecurityConfigurationProperties, - authenticationManager: AuthenticationManager + authenticationManager: AuthenticationManager, + private val loadUserById: (Long) -> UserDetails ) : BasicAuthenticationFilter(authenticationManager) { override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) { fun tryLoginFromBearer(): Boolean { @@ -260,10 +265,10 @@ class JwtAuthorizationFilter( Assert.notNull(jwtSecret, "No JWT secret has been defined.") return try { val userId = Jwts.parser() - .setSigningKey(jwtSecret!!.toByteArray()) - .parseClaimsJws(token.replace("Bearer", "")) - .body - .subject + .setSigningKey(jwtSecret!!.toByteArray()) + .parseClaimsJws(token.replace("Bearer", "")) + .body + .subject if (userId != null) getAuthenticationToken(userId) else null } catch (_: ExpiredJwtException) { null @@ -271,7 +276,7 @@ class JwtAuthorizationFilter( } private fun getAuthenticationToken(userId: String): UsernamePasswordAuthenticationToken? = try { - val userDetails = userDetailsService.loadUserById(userId.toLong(), false) + val userDetails = loadUserById(userId.toLong()) UsernamePasswordAuthenticationToken(userDetails.username, null, userDetails.authorities) } catch (_: NotFoundException) { null diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Configuration.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Configuration.kt index 951db30..9cb732c 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Configuration.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Configuration.kt @@ -1,14 +1,33 @@ package dev.fyloz.colorrecipesexplorer.model +import com.fasterxml.jackson.annotation.JsonIgnore +import dev.fyloz.colorrecipesexplorer.exception.RestException +import org.springframework.http.HttpStatus +import org.springframework.web.multipart.MultipartFile import java.time.LocalDateTime import javax.persistence.Column import javax.persistence.Entity import javax.persistence.Id import javax.persistence.Table +import javax.validation.constraints.NotBlank + +data class Configuration( + @JsonIgnore + val type: ConfigurationType, + val content: String, + val lastUpdated: LocalDateTime +) { + val key = type.key + val requireRestart = type.requireRestart + val editable = !type.computed + + fun toEntity() = + ConfigurationEntity(key, content, lastUpdated) +} @Entity @Table(name = "configuration") -data class Configuration( +data class ConfigurationEntity( @Id @Column(name = "config_key") val key: String, @@ -17,4 +36,111 @@ data class Configuration( @Column(name = "last_updated") val lastUpdated: LocalDateTime +) { + fun toConfiguration() = + configuration(key.toConfigurationType(), content, lastUpdated) + + override fun equals(other: Any?) = + other is ConfigurationEntity && key == other.key && content == other.content + + override fun hashCode(): Int { + var result = key.hashCode() + result = 31 * result + content.hashCode() + return result + } +} + +data class ConfigurationDto( + val key: String, + + @NotBlank + val content: String ) + +data class ConfigurationImageDto( + val key: String, + + val image: MultipartFile +) + +fun configuration( + type: ConfigurationType, + content: String, + lastUpdated: LocalDateTime? = null +) = Configuration(type, content, lastUpdated ?: LocalDateTime.now()) + +enum class ConfigurationType( + val key: String, + val computed: Boolean = false, + val file: Boolean = false, + val requireRestart: Boolean = false, + val public: Boolean = false +) { + INSTANCE_NAME("instance.name", public = true), + INSTANCE_LOGO_PATH("instance.logo.path", public = true), + INSTANCE_ICON_PATH("instance.icon.path", public = true), + INSTANCE_URL("instance.url", public = true), + + DATABASE_URL("database.url", file = true, requireRestart = true), + DATABASE_USER("database.user", file = true, requireRestart = true), + DATABASE_PASSWORD("database.password", file = true, requireRestart = true), + DATABASE_SUPPORTED_VERSION("database.version.supported", computed = true), + + TOUCH_UP_KIT_CACHE_PDF("touchupkit.pdf.cache"), + + EMERGENCY_MODE_ENABLED("env.emergency", computed = true, public = true), + VERSION("env.version", computed = true), + JAVA_VERSION("env.java.version", computed = true), + OPERATING_SYSTEM("env.os", computed = true) + ; + + override fun toString() = key +} + +fun String.toConfigurationType() = + ConfigurationType.values().firstOrNull { it.key == this } + ?: throw InvalidConfigurationKeyException(this) + +class InvalidConfigurationKeyException(val key: String) : + RestException( + "invalid-configuration-key", + "Invalid configuration key", + HttpStatus.BAD_REQUEST, + "The configuration key '$key' does not exists", + mapOf( + "key" to key + ) + ) + +class InvalidImageConfigurationException(val type: ConfigurationType) : + RestException( + "invalid-configuration-image", + "Invalid image configuration", + HttpStatus.BAD_REQUEST, + "The configuration with the key '${type.key}' does not accept images as content", + mapOf( + "key" to type.key + ) + ) + +class ConfigurationNotSetException(val type: ConfigurationType) : + RestException( + "unset-configuration", + "Unset configuration", + HttpStatus.NOT_FOUND, + "The configuration with the key '${type.key}' is not set", + mapOf( + "key" to type.key + ) + ) + +class CannotSetComputedConfigurationException(val type: ConfigurationType) : + RestException( + "cannot-set-computed-configuration", + "Cannot set computed configuration", + HttpStatus.BAD_REQUEST, + "The configuration with the key '${type.key}' is a computed configuration and cannot be modified", + mapOf( + "key" to type.key + ) + ) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Recipe.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Recipe.kt index 4f6551a..fbbe87f 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Recipe.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Recipe.kt @@ -6,7 +6,7 @@ import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import dev.fyloz.colorrecipesexplorer.model.account.Group import dev.fyloz.colorrecipesexplorer.model.account.group import dev.fyloz.colorrecipesexplorer.rest.CRE_PROPERTIES -import dev.fyloz.colorrecipesexplorer.rest.files.FILE_CONTROLLER_PATH +import dev.fyloz.colorrecipesexplorer.rest.FILE_CONTROLLER_PATH import java.net.URLEncoder import java.nio.charset.StandardCharsets import java.time.LocalDate @@ -67,8 +67,8 @@ data class Recipe( fun groupInformationForGroup(groupId: Long) = groupsInformation.firstOrNull { it.group.id == groupId } - fun imageUrl(name: String) = - "${CRE_PROPERTIES.deploymentUrl}$FILE_CONTROLLER_PATH?path=${ + fun imageUrl(deploymentUrl: String, name: String) = + "$deploymentUrl$FILE_CONTROLLER_PATH?path=${ URLEncoder.encode( "${this.imagesDirectoryPath}/$name", StandardCharsets.UTF_8 diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/ConfigurationRepository.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/ConfigurationRepository.kt new file mode 100644 index 0000000..99ac6d2 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/ConfigurationRepository.kt @@ -0,0 +1,7 @@ +package dev.fyloz.colorrecipesexplorer.repository + +import dev.fyloz.colorrecipesexplorer.model.ConfigurationEntity +import org.springframework.data.jpa.repository.JpaRepository + +interface ConfigurationRepository : JpaRepository { +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt index 69165f4..52e61d0 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt @@ -5,6 +5,7 @@ import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewUsers import dev.fyloz.colorrecipesexplorer.model.account.* import dev.fyloz.colorrecipesexplorer.service.UserService import dev.fyloz.colorrecipesexplorer.service.GroupService +import org.springframework.context.annotation.Profile import org.springframework.http.MediaType import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.* @@ -18,6 +19,7 @@ private const val GROUP_CONTROLLER_PATH = "api/user/group" @RestController @RequestMapping(USER_CONTROLLER_PATH) +@Profile("!emergency") class UserController(private val userService: UserService) { @GetMapping @PreAuthorizeViewUsers @@ -93,6 +95,7 @@ class UserController(private val userService: UserService) { @RestController @RequestMapping(GROUP_CONTROLLER_PATH) +@Profile("!emergency") class GroupsController( private val groupService: GroupService, private val userService: UserService @@ -155,10 +158,11 @@ class GroupsController( @RestController @RequestMapping("api") +@Profile("!emergency") class LogoutController(private val userService: UserService) { @GetMapping("logout") fun logout(request: HttpServletRequest) = - ok { + ok { userService.logout(request) } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/CompanyController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/CompanyController.kt index f16f253..77704da 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/CompanyController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/CompanyController.kt @@ -5,6 +5,7 @@ import dev.fyloz.colorrecipesexplorer.model.Company import dev.fyloz.colorrecipesexplorer.model.CompanySaveDto import dev.fyloz.colorrecipesexplorer.model.CompanyUpdateDto import dev.fyloz.colorrecipesexplorer.service.CompanyService +import org.springframework.context.annotation.Profile import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.* import javax.validation.Valid @@ -13,6 +14,7 @@ private const val COMPANY_CONTROLLER_PATH = "api/company" @RestController @RequestMapping(COMPANY_CONTROLLER_PATH) +@Profile("!emergency") @PreAuthorizeViewCatalog class CompanyController(private val companyService: CompanyService) { @GetMapping diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/ConfigurationController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/ConfigurationController.kt new file mode 100644 index 0000000..a3017c6 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/ConfigurationController.kt @@ -0,0 +1,56 @@ +package dev.fyloz.colorrecipesexplorer.rest + +import dev.fyloz.colorrecipesexplorer.model.Configuration +import dev.fyloz.colorrecipesexplorer.model.ConfigurationDto +import dev.fyloz.colorrecipesexplorer.model.ConfigurationImageDto +import dev.fyloz.colorrecipesexplorer.model.account.Permission +import dev.fyloz.colorrecipesexplorer.model.account.toAuthority +import dev.fyloz.colorrecipesexplorer.restartApplication +import dev.fyloz.colorrecipesexplorer.service.ConfigurationService +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.Authentication +import org.springframework.web.bind.annotation.* +import org.springframework.web.multipart.MultipartFile +import javax.validation.constraints.NotBlank + +@RestController +@RequestMapping("api/config") +class ConfigurationController(val configurationService: ConfigurationService) { + @GetMapping + fun getAll(@RequestParam(required = false) keys: String?, authentication: Authentication?) = + ok(with(configurationService) { + if (keys != null) getAll(keys) else getAll() + }.filter { + authentication.hasAuthority(it) + }) + + @GetMapping("{key}") + fun get(@PathVariable key: String, authentication: Authentication?) = with(configurationService.get(key)) { + if (authentication.hasAuthority(this)) ok(this) else forbidden() + } + + @PutMapping + @PreAuthorize("hasAuthority('ADMIN')") + fun set(@RequestBody configurations: List) = noContent { + configurationService.set(configurations) + } + + @PutMapping("image") + @PreAuthorize("hasAuthority('ADMIN')") + fun setImage(@RequestParam @NotBlank key: String, @RequestParam @NotBlank image: MultipartFile) = noContent { + configurationService.set(ConfigurationImageDto(key, image)) + } + + @PostMapping("restart") + @PreAuthorize("hasAuthority('ADMIN')") + fun restart() = noContent { + restartApplication() + } +} + +private fun Authentication?.hasAuthority(configuration: Configuration) = when { + configuration.type.public -> true + this != null && Permission.ADMIN.toAuthority() in this.authorities -> true + else -> false +} + diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/files/FileController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/FileController.kt similarity index 84% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/files/FileController.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/FileController.kt index cad230a..e91186c 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/files/FileController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/FileController.kt @@ -1,7 +1,7 @@ -package dev.fyloz.colorrecipesexplorer.rest.files +package dev.fyloz.colorrecipesexplorer.rest -import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties -import dev.fyloz.colorrecipesexplorer.rest.noContent +import dev.fyloz.colorrecipesexplorer.model.ConfigurationType +import dev.fyloz.colorrecipesexplorer.service.ConfigurationService import dev.fyloz.colorrecipesexplorer.service.files.FileService import org.springframework.core.io.ByteArrayResource import org.springframework.http.MediaType @@ -18,10 +18,9 @@ private const val DEFAULT_MEDIA_TYPE = MediaType.APPLICATION_OCTET_STREAM_VALUE @RequestMapping(FILE_CONTROLLER_PATH) class FileController( private val fileService: FileService, - private val creProperties: CreProperties + private val configService: ConfigurationService ) { @GetMapping(produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE]) - @PreAuthorize("hasAnyAuthority('READ_FILE')") fun upload( @RequestParam path: String, @RequestParam(required = false) mediaType: String? @@ -55,7 +54,7 @@ class FileController( private fun created(path: String): ResponseEntity = ResponseEntity - .created(URI.create("${creProperties.deploymentUrl}$FILE_CONTROLLER_PATH?path=$path")) + .created(URI.create("${configService.get(ConfigurationType.INSTANCE_URL)}$FILE_CONTROLLER_PATH?path=$path")) .build() private fun getFileNameFromPath(path: String) = diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/InventoryController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/InventoryController.kt index 636c3af..abf6d49 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/InventoryController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/InventoryController.kt @@ -3,6 +3,7 @@ package dev.fyloz.colorrecipesexplorer.rest import dev.fyloz.colorrecipesexplorer.model.MaterialQuantityDto import dev.fyloz.colorrecipesexplorer.model.MixDeductDto import dev.fyloz.colorrecipesexplorer.service.InventoryService +import org.springframework.context.annotation.Profile import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.PutMapping @@ -14,6 +15,7 @@ private const val INVENTORY_CONTROLLER_PATH = "api/inventory" @RestController @RequestMapping(INVENTORY_CONTROLLER_PATH) +@Profile("!emergency") class InventoryController( private val inventoryService: InventoryService ) { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialController.kt index 0bec46a..e5d13f9 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialController.kt @@ -4,6 +4,7 @@ import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewCatalog import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties import dev.fyloz.colorrecipesexplorer.model.* import dev.fyloz.colorrecipesexplorer.service.MaterialService +import org.springframework.context.annotation.Profile import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize @@ -16,6 +17,7 @@ private const val MATERIAL_CONTROLLER_PATH = "api/material" @RestController @RequestMapping(MATERIAL_CONTROLLER_PATH) +@Profile("!emergency") @PreAuthorizeViewCatalog class MaterialController( private val materialService: MaterialService diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialTypeController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialTypeController.kt index 877f8d1..a8ff9bd 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialTypeController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialTypeController.kt @@ -5,6 +5,7 @@ import dev.fyloz.colorrecipesexplorer.model.MaterialType import dev.fyloz.colorrecipesexplorer.model.MaterialTypeSaveDto import dev.fyloz.colorrecipesexplorer.model.MaterialTypeUpdateDto import dev.fyloz.colorrecipesexplorer.service.MaterialTypeService +import org.springframework.context.annotation.Profile import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.* import javax.validation.Valid @@ -13,6 +14,7 @@ private const val MATERIAL_TYPE_CONTROLLER_PATH = "api/materialtype" @RestController @RequestMapping(MATERIAL_TYPE_CONTROLLER_PATH) +@Profile("!emergency") @PreAuthorizeViewCatalog class MaterialTypeController(private val materialTypeService: MaterialTypeService) { @GetMapping diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RecipeController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RecipeController.kt index 0b840a9..9d601ba 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RecipeController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RecipeController.kt @@ -6,6 +6,7 @@ import dev.fyloz.colorrecipesexplorer.model.* import dev.fyloz.colorrecipesexplorer.service.MixService import dev.fyloz.colorrecipesexplorer.service.RecipeImageService import dev.fyloz.colorrecipesexplorer.service.RecipeService +import org.springframework.context.annotation.Profile import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize @@ -19,6 +20,7 @@ private const val MIX_CONTROLLER_PATH = "api/recipe/mix" @RestController @RequestMapping(RECIPE_CONTROLLER_PATH) +@Profile("!emergency") @PreAuthorizeViewRecipes class RecipeController( private val recipeService: RecipeService, @@ -84,6 +86,7 @@ class RecipeController( @RestController @RequestMapping(MIX_CONTROLLER_PATH) +@Profile("!emergency") @PreAuthorizeViewRecipes class MixController(private val mixService: MixService) { @GetMapping("{id}") diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RestUtils.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RestUtils.kt index c7a82fc..23d59da 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RestUtils.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RestUtils.kt @@ -19,7 +19,7 @@ fun ok(body: T, headers: HttpHeaders): ResponseEntity = ResponseEntity(body, headers, HttpStatus.OK) /** Executes the given [action] then returns an HTTP OK [ResponseEntity] form the given [body]. */ -fun ok(action: () -> Unit): ResponseEntity { +fun ok(action: () -> Unit): ResponseEntity { action() return ResponseEntity.ok().build() } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/TouchUpKitController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/TouchUpKitController.kt index c39684d..8e27621 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/TouchUpKitController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/TouchUpKitController.kt @@ -4,6 +4,7 @@ import dev.fyloz.colorrecipesexplorer.model.touchupkit.TouchUpKitOutputDto import dev.fyloz.colorrecipesexplorer.model.touchupkit.TouchUpKitSaveDto import dev.fyloz.colorrecipesexplorer.model.touchupkit.TouchUpKitUpdateDto import dev.fyloz.colorrecipesexplorer.service.touchupkit.TouchUpKitService +import org.springframework.context.annotation.Profile import org.springframework.core.io.ByteArrayResource import org.springframework.http.MediaType import org.springframework.http.ResponseEntity @@ -15,6 +16,7 @@ const val TOUCH_UP_KIT_CONTROLLER_PATH = "/api/touchupkit" @RestController @RequestMapping(TOUCH_UP_KIT_CONTROLLER_PATH) +@Profile("!emergency") @PreAuthorize("hasAuthority('VIEW_TOUCH_UP_KITS')") class TouchUpKitController( private val touchUpKitService: TouchUpKitService diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountService.kt index 5d6bd11..bab0b70 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountService.kt @@ -8,6 +8,7 @@ import dev.fyloz.colorrecipesexplorer.model.validation.or import dev.fyloz.colorrecipesexplorer.repository.UserRepository import dev.fyloz.colorrecipesexplorer.repository.GroupRepository import org.springframework.context.annotation.Lazy +import org.springframework.context.annotation.Profile import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.core.userdetails.UsernameNotFoundException @@ -74,6 +75,7 @@ interface CreUserDetailsService : UserDetailsService { } @Service +@Profile("!emergency") class UserServiceImpl( userRepository: UserRepository, @Lazy val groupService: GroupService, @@ -229,6 +231,7 @@ class UserServiceImpl( const val defaultGroupCookieMaxAge = 10 * 365 * 24 * 60 * 60 // 10 ans @Service +@Profile("!emergency") class GroupServiceImpl( private val userService: UserService, groupRepository: GroupRepository @@ -298,6 +301,7 @@ class GroupServiceImpl( } @Service +@Profile("!emergency") class CreUserDetailsServiceImpl( private val userService: UserService ) : diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/CompanyService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/CompanyService.kt index 72a2f47..3e8e0a9 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/CompanyService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/CompanyService.kt @@ -3,6 +3,7 @@ package dev.fyloz.colorrecipesexplorer.service import dev.fyloz.colorrecipesexplorer.model.* import dev.fyloz.colorrecipesexplorer.repository.CompanyRepository import org.springframework.context.annotation.Lazy +import org.springframework.context.annotation.Profile import org.springframework.stereotype.Service interface CompanyService : @@ -12,6 +13,7 @@ interface CompanyService : } @Service +@Profile("!emergency") class CompanyServiceImpl( companyRepository: CompanyRepository, @Lazy val recipeService: RecipeService diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/ConfigurationService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/ConfigurationService.kt new file mode 100644 index 0000000..d18384d --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/ConfigurationService.kt @@ -0,0 +1,152 @@ +package dev.fyloz.colorrecipesexplorer.service + +import dev.fyloz.colorrecipesexplorer.DatabaseUpdaterProperties +import dev.fyloz.colorrecipesexplorer.SUPPORTED_DATABASE_VERSION +import dev.fyloz.colorrecipesexplorer.config.FileConfiguration +import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties +import dev.fyloz.colorrecipesexplorer.emergencyMode +import dev.fyloz.colorrecipesexplorer.model.* +import dev.fyloz.colorrecipesexplorer.repository.ConfigurationRepository +import dev.fyloz.colorrecipesexplorer.service.files.FileService +import org.springframework.boot.info.BuildProperties +import org.springframework.context.annotation.Lazy +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import java.io.File +import javax.annotation.PostConstruct + +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 of the configuration with the given [type]. */ + fun set(type: ConfigurationType, content: String) + + /** Sets the content of the configuration matching the given [configuration] with a given image. */ + fun set(configuration: ConfigurationImageDto) +} + +const val CONFIGURATION_LOGO_FILE_PATH = "images/logo" +const val CONFIGURATION_ICON_FILE_PATH = "images/icon" +const val CONFIGURATION_FORMATTED_LIST_DELIMITER = ';' + +@Service +class ConfigurationServiceImpl( + @Lazy private val repository: ConfigurationRepository, + private val fileService: FileService, + private val fileConfiguration: FileConfiguration, + private val creProperties: CreProperties, + private val databaseProperties: DatabaseUpdaterProperties, + private val buildInfo: BuildProperties +) : ConfigurationService { + override fun getAll() = + ConfigurationType.values().mapNotNull { + try { + get(it) + } catch (_: ConfigurationNotSetException) { + null + } + } + + override fun getAll(formattedKeyList: String) = + formattedKeyList.split(CONFIGURATION_FORMATTED_LIST_DELIMITER).map(this::get) + + override fun get(key: String) = + get(key.toConfigurationType()) + + override fun get(type: ConfigurationType) = when { + type.computed -> getComputedConfiguration(type) + type.file -> fileConfiguration.get(type) + !emergencyMode -> repository.findByIdOrNull(type.key)?.toConfiguration() + else -> null + } ?: throw ConfigurationNotSetException(type) + + override fun set(configurations: List) { + configurations.forEach(this::set) + } + + override fun set(configuration: ConfigurationDto) = with(configuration) { + set(key.toConfigurationType(), content) + } + + override fun set(type: ConfigurationType, content: String) { + when { + type.computed -> throw CannotSetComputedConfigurationException(type) + type.file -> fileConfiguration.set(type, content) + !emergencyMode -> repository.save(configuration(type, content).toEntity()) + } + } + + 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) + } + + @PostConstruct + fun initializeProperties() { + ConfigurationType.values().filter { !it.computed }.forEach { + try { + get(it) + } catch (_: ConfigurationNotSetException) { + set(it, it.defaultContent) + } + } + } + + private val ConfigurationType.defaultContent: String + get() = when (this) { + ConfigurationType.INSTANCE_NAME -> "Color Recipes Explorer" + ConfigurationType.INSTANCE_LOGO_PATH -> "images/logo" + ConfigurationType.INSTANCE_ICON_PATH -> "images/icon" + ConfigurationType.INSTANCE_URL -> creProperties.deploymentUrl + ConfigurationType.DATABASE_URL -> databaseProperties.url + ConfigurationType.DATABASE_USER -> databaseProperties.username + ConfigurationType.DATABASE_PASSWORD -> databaseProperties.password + ConfigurationType.TOUCH_UP_KIT_CACHE_PDF -> "true" + else -> "" + } + + private fun getComputedConfiguration(key: ConfigurationType) = configuration( + key, when (key) { + ConfigurationType.EMERGENCY_MODE_ENABLED -> emergencyMode + ConfigurationType.VERSION -> buildInfo.version + 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 ${key.key} because it is not a computed configuration") + }.toString() + ) +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/InventoryService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/InventoryService.kt index 4fb4ee3..77f10cb 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/InventoryService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/InventoryService.kt @@ -3,6 +3,7 @@ package dev.fyloz.colorrecipesexplorer.service import dev.fyloz.colorrecipesexplorer.exception.RestException import dev.fyloz.colorrecipesexplorer.model.* import dev.fyloz.colorrecipesexplorer.utils.mapMayThrow +import org.springframework.context.annotation.Profile import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import javax.transaction.Transactional @@ -25,6 +26,7 @@ interface InventoryService { } @Service +@Profile("!emergency") class InventoryServiceImpl( private val materialService: MaterialService, private val mixService: MixService diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt index cb5c3ab..fdf1bde 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt @@ -2,11 +2,11 @@ package dev.fyloz.colorrecipesexplorer.service import dev.fyloz.colorrecipesexplorer.model.* import dev.fyloz.colorrecipesexplorer.repository.MaterialRepository -import dev.fyloz.colorrecipesexplorer.rest.CRE_PROPERTIES -import dev.fyloz.colorrecipesexplorer.rest.files.FILE_CONTROLLER_PATH +import dev.fyloz.colorrecipesexplorer.rest.FILE_CONTROLLER_PATH import dev.fyloz.colorrecipesexplorer.service.files.FileService import io.jsonwebtoken.lang.Assert import org.springframework.context.annotation.Lazy +import org.springframework.context.annotation.Profile import org.springframework.stereotype.Service import java.net.URLEncoder import java.nio.charset.StandardCharsets @@ -33,12 +33,14 @@ interface MaterialService : } @Service +@Profile("!emergency") class MaterialServiceImpl( materialRepository: MaterialRepository, val recipeService: RecipeService, val mixService: MixService, @Lazy val materialTypeService: MaterialTypeService, - val fileService: FileService + val fileService: FileService, + val configService: ConfigurationService ) : AbstractExternalNamedModelService( materialRepository @@ -57,7 +59,7 @@ class MaterialServiceImpl( isMixType = this.isMixType, materialType = this.materialType!!, simdutUrl = if (fileService.exists(this.simdutFilePath)) - "${CRE_PROPERTIES.deploymentUrl}$FILE_CONTROLLER_PATH?path=${ + "${configService.get(ConfigurationType.INSTANCE_URL)}$FILE_CONTROLLER_PATH?path=${ URLEncoder.encode( this.simdutFilePath, StandardCharsets.UTF_8 diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialTypeService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialTypeService.kt index e088ee4..8d0ce96 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialTypeService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialTypeService.kt @@ -5,6 +5,7 @@ import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.model.* import dev.fyloz.colorrecipesexplorer.model.validation.isNotNullAndNotBlank import dev.fyloz.colorrecipesexplorer.repository.MaterialTypeRepository +import org.springframework.context.annotation.Profile import org.springframework.stereotype.Service interface MaterialTypeService : @@ -26,6 +27,7 @@ interface MaterialTypeService : } @Service +@Profile("!emergency") class MaterialTypeServiceImpl(repository: MaterialTypeRepository, private val materialService: MaterialService) : AbstractExternalNamedModelService( repository diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMaterialService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMaterialService.kt index 92339e8..e977852 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMaterialService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMaterialService.kt @@ -6,6 +6,7 @@ import dev.fyloz.colorrecipesexplorer.repository.MixMaterialRepository import dev.fyloz.colorrecipesexplorer.utils.findDuplicated import dev.fyloz.colorrecipesexplorer.utils.hasGaps import org.springframework.context.annotation.Lazy +import org.springframework.context.annotation.Profile import org.springframework.http.HttpStatus import org.springframework.stereotype.Service @@ -33,6 +34,7 @@ interface MixMaterialService : ModelService } @Service +@Profile("!emergency") class MixMaterialServiceImpl( mixMaterialRepository: MixMaterialRepository, @Lazy val materialService: MaterialService diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixService.kt index af2217c..72c0009 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixService.kt @@ -4,6 +4,7 @@ import dev.fyloz.colorrecipesexplorer.model.* import dev.fyloz.colorrecipesexplorer.repository.MixRepository import dev.fyloz.colorrecipesexplorer.utils.setAll import org.springframework.context.annotation.Lazy +import org.springframework.context.annotation.Profile import org.springframework.stereotype.Service import javax.transaction.Transactional @@ -22,6 +23,7 @@ interface MixService : ExternalModelService { @@ -26,6 +27,7 @@ interface MixTypeService : NamedModelService { } @Service +@Profile("!emergency") class MixTypeServiceImpl( mixTypeRepository: MixTypeRepository, @Lazy val materialService: MaterialService, diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt index c28ef6e..ee26293 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt @@ -7,6 +7,7 @@ import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository import dev.fyloz.colorrecipesexplorer.service.files.FileService import dev.fyloz.colorrecipesexplorer.utils.setAll import org.springframework.context.annotation.Lazy +import org.springframework.context.annotation.Profile import org.springframework.stereotype.Service import org.springframework.web.multipart.MultipartFile import java.io.File @@ -37,13 +38,15 @@ interface RecipeService : } @Service +@Profile("!emergency") class RecipeServiceImpl( recipeRepository: RecipeRepository, val companyService: CompanyService, val mixService: MixService, val recipeStepService: RecipeStepService, @Lazy val groupService: GroupService, - val recipeImageService: RecipeImageService + val recipeImageService: RecipeImageService, + val configService: ConfigurationService ) : AbstractExternalModelService( recipeRepository @@ -69,7 +72,7 @@ class RecipeServiceImpl( }.toSet(), this.groupsInformation, recipeImageService.getAllImages(this) - .map { this.imageUrl(it) } + .map { this.imageUrl(configService.get(ConfigurationType.INSTANCE_URL).content, it) } .toSet() ) @@ -206,6 +209,7 @@ const val RECIPE_IMAGE_ID_DELIMITER = "_" const val RECIPE_IMAGE_EXTENSION = ".jpg" @Service +@Profile("!emergency") class RecipeImageServiceImpl( val fileService: FileService ) : RecipeImageService { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeStepService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeStepService.kt index 2475ed3..0bde45d 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeStepService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeStepService.kt @@ -6,6 +6,7 @@ import dev.fyloz.colorrecipesexplorer.model.account.Group import dev.fyloz.colorrecipesexplorer.repository.RecipeStepRepository import dev.fyloz.colorrecipesexplorer.utils.findDuplicated import dev.fyloz.colorrecipesexplorer.utils.hasGaps +import org.springframework.context.annotation.Profile import org.springframework.http.HttpStatus import org.springframework.stereotype.Service @@ -22,6 +23,7 @@ interface RecipeStepService : ModelService { } @Service +@Profile("!emergency") class RecipeStepServiceImpl(recipeStepRepository: RecipeStepRepository) : AbstractModelService(recipeStepRepository), RecipeStepService { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/touchupkit/TouchUpKitService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/touchupkit/TouchUpKitService.kt index 79c9440..e17ba65 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/touchupkit/TouchUpKitService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/touchupkit/TouchUpKitService.kt @@ -1,13 +1,16 @@ package dev.fyloz.colorrecipesexplorer.service.touchupkit import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties +import dev.fyloz.colorrecipesexplorer.model.ConfigurationType import dev.fyloz.colorrecipesexplorer.model.touchupkit.* import dev.fyloz.colorrecipesexplorer.repository.TouchUpKitRepository import dev.fyloz.colorrecipesexplorer.rest.TOUCH_UP_KIT_CONTROLLER_PATH import dev.fyloz.colorrecipesexplorer.service.AbstractExternalModelService +import dev.fyloz.colorrecipesexplorer.service.ConfigurationService import dev.fyloz.colorrecipesexplorer.service.ExternalModelService import dev.fyloz.colorrecipesexplorer.service.files.FileService import dev.fyloz.colorrecipesexplorer.utils.* +import org.springframework.context.annotation.Profile import org.springframework.core.io.ByteArrayResource import org.springframework.stereotype.Service @@ -34,10 +37,11 @@ interface TouchUpKitService : } @Service +@Profile("!emergency") class TouchUpKitServiceImpl( private val fileService: FileService, - touchUpKitRepository: TouchUpKitRepository, - private val creProperties: CreProperties, + private val configService: ConfigurationService, + touchUpKitRepository: TouchUpKitRepository ) : AbstractExternalModelService( touchUpKitRepository ), TouchUpKitService { @@ -98,7 +102,7 @@ class TouchUpKitServiceImpl( } override fun generateJobPdfResource(job: String): ByteArrayResource { - if (creProperties.cacheGeneratedFiles) { + if (cacheGeneratedFiles()) { with(job.pdfDocumentPath()) { if (fileService.exists(this)) { return fileService.read(this) @@ -112,7 +116,7 @@ class TouchUpKitServiceImpl( } override fun String.cachePdfDocument(document: PdfDocument) { - if (!creProperties.cacheGeneratedFiles) return + if (!cacheGeneratedFiles()) return fileService.write(document.toByteArrayResource(), this.pdfDocumentPath(), true) } @@ -121,5 +125,8 @@ class TouchUpKitServiceImpl( "$TOUCH_UP_KIT_FILES_PATH/$this.pdf" private fun TouchUpKit.pdfUrl() = - "${creProperties.deploymentUrl}$TOUCH_UP_KIT_CONTROLLER_PATH/pdf?job=$project" + "${configService.get(ConfigurationType.INSTANCE_URL)}$TOUCH_UP_KIT_CONTROLLER_PATH/pdf?job=$project" + + private fun cacheGeneratedFiles() = + configService.get(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF).content == "true" } diff --git a/src/main/resources/application-emergency.properties b/src/main/resources/application-emergency.properties new file mode 100644 index 0000000..b35406d --- /dev/null +++ b/src/main/resources/application-emergency.properties @@ -0,0 +1 @@ +spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration,org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration diff --git a/src/main/resources/application-mysql.properties b/src/main/resources/application-mysql.properties index 06d0241..c1fcf1c 100644 --- a/src/main/resources/application-mysql.properties +++ b/src/main/resources/application-mysql.properties @@ -1,6 +1,7 @@ -spring.datasource.url=jdbc:mysql://172.66.1.1/cre -spring.datasource.username=root -spring.datasource.password=pass -spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect spring.jpa.hibernate.ddl-auto=none + +# Database manager +cre.database.url=mysql://localhost/cre +cre.database.username=root +cre.database.password=pass diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d6fad50..69a48e9 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -17,9 +17,6 @@ entities.material-types.systemTypes[1].name=Base entities.material-types.systemTypes[1].prefix=BAS entities.material-types.systemTypes[1].usepercentages=false entities.material-types.baseName=Base -# Database manager -databaseupdater.username=root -databaseupdater.password=pass # DEBUG spring.jpa.show-sql=false # Do not modify @@ -33,3 +30,5 @@ spring.h2.console.enabled=false spring.jackson.deserialization.fail-on-null-for-primitives=true spring.jackson.default-property-inclusion=non_null spring.profiles.active=@spring.profiles.active@ + +spring.datasource.continue-on-error=true diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/ConfigurationServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/ConfigurationServiceTest.kt new file mode 100644 index 0000000..5275e97 --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/ConfigurationServiceTest.kt @@ -0,0 +1,255 @@ +package dev.fyloz.colorrecipesexplorer.service + +import dev.fyloz.colorrecipesexplorer.config.FileConfiguration +import dev.fyloz.colorrecipesexplorer.model.* +import dev.fyloz.colorrecipesexplorer.repository.ConfigurationRepository +import io.mockk.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.time.LocalDateTime +import java.util.* +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConfigurationServiceTest { + private val repository = mockk() + private val fileConfiguration = mockk() + private val service = spyk(ConfigurationServiceImpl(repository, mockk(), fileConfiguration, mockk(), mockk(), mockk())) + + @AfterEach + fun afterEach() { + clearAllMocks() + } + + // getAll() + + @Test + fun `getAll() gets the Configuration of each ConfigurationType`() { + every { service.get(any()) } answers { throw ConfigurationNotSetException(this.args[0] as ConfigurationType) } + + service.getAll() + + verify { + service.getAll() + ConfigurationType.values().forEach { + service.get(it) + } + } + confirmVerified(service) + } + + @Test + fun `getAll() only returns set configurations`() { + val unsetConfigurationTypes = listOf( + ConfigurationType.INSTANCE_NAME, + ConfigurationType.INSTANCE_LOGO_PATH, + ConfigurationType.INSTANCE_ICON_PATH + ) + + every { service.get(match { it in unsetConfigurationTypes }) } answers { + throw ConfigurationNotSetException(this.firstArg() as ConfigurationType) + } + every { service.get(match { it !in unsetConfigurationTypes }) } answers { + val type = firstArg() + configuration(type, type.key) + } + + val found = service.getAll() + + assertFalse { + found.any { + it.type in unsetConfigurationTypes + } + } + + verify { + service.getAll() + ConfigurationType.values().forEach { + service.get(it) + } + } + confirmVerified(service) + } + + @Test + fun `getAll() only includes configurations matching the formatted formatted key list`() { + val configurationTypes = listOf( + ConfigurationType.INSTANCE_NAME, + ConfigurationType.INSTANCE_LOGO_PATH, + ConfigurationType.INSTANCE_ICON_PATH + ) + val formattedKeyList = configurationTypes + .map { it.key } + .reduce { acc, s -> "$acc$CONFIGURATION_FORMATTED_LIST_DELIMITER$s" } + + every { service.get(any()) } answers { + val key = firstArg() + configuration(key.toConfigurationType(), key) + } + + val found = service.getAll(formattedKeyList) + + assertTrue { + found.all { it.type in configurationTypes } + } + + verify { + service.getAll(formattedKeyList) + configurationTypes.forEach { + service.get(it.key) + } + } + confirmVerified(service) + } + + // get() + + @Test + fun `get(key) calls get() with the ConfigurationType matching the given key`() { + val type = ConfigurationType.INSTANCE_ICON_PATH + val key = type.key + + every { service.get(type) } answers { + val type = firstArg() + configuration(type, type.key) + } + + service.get(key) + + verify { + service.get(key) + service.get(type) + } + confirmVerified(service) + } + + @Test + fun `get(type) gets in the repository when the given ConfigurationType is not computed or a file property`() { + val type = ConfigurationType.INSTANCE_ICON_PATH + + every { repository.findById(type.key) } returns Optional.of( + ConfigurationEntity(type.key, type.key, LocalDateTime.now()) + ) + + val configuration = service.get(type) + + assertTrue { + configuration.key == type.key + } + + verify { + service.get(type) + repository.findById(type.key) + } + confirmVerified(service, repository) + } + + @Test + fun `get(type) gets in the FileConfiguration when the gien ConfigurationType is a file property`() { + val type = ConfigurationType.DATABASE_URL + + every { fileConfiguration.get(type) } returns configuration(type, type.key) + + val configuration = service.get(type) + + assertTrue { + configuration.key == type.key + } + + verify { + service.get(type) + fileConfiguration.get(type) + } + verify(exactly = 0) { + repository.findById(type.key) + } + confirmVerified(service, fileConfiguration, repository) + } + + @Test + fun `get(type) computes computed properties`() { + val type = ConfigurationType.JAVA_VERSION + + val configuration = service.get(type) + + assertTrue { + configuration.key == type.key + } + + verify { + service.get(type) + } + verify(exactly = 0) { + repository.findById(type.key) + fileConfiguration.get(type) + } + confirmVerified(service, repository, fileConfiguration) + } + + @Test + fun `get(type) throws ConfigurationNotSetException when the given ConfigurationType has no set configuration`() { + val type = ConfigurationType.INSTANCE_ICON_PATH + + every { repository.findById(type.key) } returns Optional.empty() + + with(assertThrows { service.get(type) }) { + assertEquals(type, this.type) + } + + verify { + service.get(type) + repository.findById(type.key) + } + } + + @Test + fun `set() set the configuration in the FileConfiguration when the given ConfigurationType is a file configuration`() { + val type = ConfigurationType.DATABASE_URL + val content = "url" + + every { fileConfiguration.set(type, content) } just runs + + service.set(type, content) + + verify { + service.set(type, content) + fileConfiguration.set(type, content) + } + confirmVerified(service, fileConfiguration) + } + + @Test + fun `set() set the configuration in the repository when the given ConfigurationType is not a computed configuration of a file configuration`() { + val type = ConfigurationType.INSTANCE_ICON_PATH + val content = "path" + val configuration = configuration(type, content) + val entity = configuration.toEntity() + + every { repository.save(entity) } returns entity + + service.set(type, content) + + verify { + service.set(type, content) + repository.save(entity) + } + confirmVerified(service, repository) + } + + @Test + fun `set() throws CannotSetComputedConfigurationException when the given ConfigurationType is a computed configuration`() { + val type = ConfigurationType.JAVA_VERSION + val content = "5" + + with(assertThrows { service.set(type, content) }) { + assertEquals(type, this.type) + } + + verify { + service.set(type, content) + } + confirmVerified(service) + } +} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt index 9b3dacb..dd42bf2 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt @@ -23,7 +23,7 @@ class MaterialServiceTest : private val materialTypeService: MaterialTypeService = mock() private val fileService: FileService = mock() override val service: MaterialService = - spy(MaterialServiceImpl(repository, recipeService, mixService, materialTypeService, fileService)) + spy(MaterialServiceImpl(repository, recipeService, mixService, materialTypeService, fileService, mock())) override val entity: Material = material(id = 0L, name = "material") private val entityOutput = materialOutputDto(entity) diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt index a8ccd0b..8d4e612 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt @@ -27,7 +27,7 @@ class RecipeServiceTest : private val groupService: GroupService = mock() private val recipeStepService: RecipeStepService = mock() override val service: RecipeService = - spy(RecipeServiceImpl(repository, companyService, mixService, recipeStepService, groupService, mock())) + spy(RecipeServiceImpl(repository, companyService, mixService, recipeStepService, groupService, mock(), mock())) private val company: Company = company(id = 0L) override val entity: Recipe = recipe(id = 0L, name = "recipe", company = company) diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/TouchUpKitServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/TouchUpKitServiceTest.kt index 3813fd5..cdbebf7 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/TouchUpKitServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/TouchUpKitServiceTest.kt @@ -1,7 +1,10 @@ package dev.fyloz.colorrecipesexplorer.service.files import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties +import dev.fyloz.colorrecipesexplorer.model.ConfigurationType +import dev.fyloz.colorrecipesexplorer.model.configuration import dev.fyloz.colorrecipesexplorer.repository.TouchUpKitRepository +import dev.fyloz.colorrecipesexplorer.service.ConfigurationService import dev.fyloz.colorrecipesexplorer.service.touchupkit.TOUCH_UP_TEXT_EN import dev.fyloz.colorrecipesexplorer.service.touchupkit.TOUCH_UP_TEXT_FR import dev.fyloz.colorrecipesexplorer.service.touchupkit.TouchUpKitServiceImpl @@ -21,7 +24,8 @@ private class TouchUpKitServiceTestContext { val creProperties = mockk { every { cacheGeneratedFiles } returns false } - val touchUpKitService = spyk(TouchUpKitServiceImpl(fileService, touchUpKitRepository, creProperties)) + val configService = mockk(relaxed = true) + val touchUpKitService = spyk(TouchUpKitServiceImpl(fileService, configService, touchUpKitRepository)) val pdfDocumentData = mockk() val pdfDocument = mockk { mockkStatic(PdfDocument::toByteArrayResource) @@ -81,6 +85,7 @@ class TouchUpKitServiceTest { every { creProperties.cacheGeneratedFiles } returns true every { fileService.exists(any()) } returns true every { fileService.read(any()) } returns pdfDocumentData + every { configService.get(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF) } returns configuration(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF, "true") val redResource = touchUpKitService.generateJobPdfResource(job) @@ -109,6 +114,7 @@ class TouchUpKitServiceTest { fun `cachePdfDocument() writes the given document to the FileService when cache is enabled`() { test { every { creProperties.cacheGeneratedFiles } returns true + every { configService.get(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF) } returns configuration(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF, "true") with(touchUpKitService) { job.cachePdfDocument(pdfDocument)