From 897f30f57142441e823eee3c94e26f865a19aec8 Mon Sep 17 00:00:00 2001 From: Ziedelth Date: Fri, 5 Apr 2024 15:28:49 +0200 Subject: [PATCH] Reformat --- .../kotlin/fr/shikkanime/modules/Routing.kt | 274 +++++------------- .../fr/shikkanime/modules/SEOManager.kt | 47 +++ .../kotlin/fr/shikkanime/modules/Security.kt | 114 +++----- .../fr/shikkanime/modules/SwaggerRouting.kt | 80 +++++ .../fr/shikkanime/services/AnimeService.kt | 16 +- .../kotlin/fr/shikkanime/utils/Extensions.kt | 5 + .../fr/shikkanime/utils/routes/Response.kt | 24 -- 7 files changed, 252 insertions(+), 308 deletions(-) create mode 100644 src/main/kotlin/fr/shikkanime/modules/SEOManager.kt create mode 100644 src/main/kotlin/fr/shikkanime/modules/SwaggerRouting.kt diff --git a/src/main/kotlin/fr/shikkanime/modules/Routing.kt b/src/main/kotlin/fr/shikkanime/modules/Routing.kt index bbc613fc..a114ea7f 100644 --- a/src/main/kotlin/fr/shikkanime/modules/Routing.kt +++ b/src/main/kotlin/fr/shikkanime/modules/Routing.kt @@ -1,26 +1,21 @@ package fr.shikkanime.modules import fr.shikkanime.dtos.TokenDto -import fr.shikkanime.entities.LinkObject import fr.shikkanime.entities.enums.ConfigPropertyKey import fr.shikkanime.entities.enums.CountryCode import fr.shikkanime.entities.enums.Platform import fr.shikkanime.services.caches.ConfigCacheService -import fr.shikkanime.services.caches.SimulcastCacheService import fr.shikkanime.utils.Constant import fr.shikkanime.utils.LoggerFactory -import fr.shikkanime.utils.StringUtils import fr.shikkanime.utils.routes.* import fr.shikkanime.utils.routes.method.Delete import fr.shikkanime.utils.routes.method.Get import fr.shikkanime.utils.routes.method.Post import fr.shikkanime.utils.routes.method.Put -import fr.shikkanime.utils.routes.openapi.OpenAPI import fr.shikkanime.utils.routes.param.BodyParam import fr.shikkanime.utils.routes.param.PathParam import fr.shikkanime.utils.routes.param.QueryParam import io.github.smiley4.ktorswaggerui.dsl.* -import io.github.smiley4.ktorswaggerui.dsl.get import io.ktor.http.* import io.ktor.http.content.* import io.ktor.server.application.* @@ -35,16 +30,15 @@ import io.ktor.server.response.* import io.ktor.server.routing.* import io.ktor.server.sessions.* import io.ktor.util.* +import io.ktor.util.pipeline.* import java.time.ZonedDateTime import java.util.* import java.util.logging.Level -import kotlin.collections.set import kotlin.reflect.KFunction import kotlin.reflect.KParameter import kotlin.reflect.full.* import kotlin.reflect.jvm.isAccessible import kotlin.reflect.jvm.javaType -import kotlin.reflect.jvm.jvmErasure private val logger = LoggerFactory.getLogger("Routing") private val callStartTime = AttributeKey("CallStartTime") @@ -54,53 +48,56 @@ fun Application.configureRouting() { environment.monitor.subscribe(Routing.RoutingCallStarted) { call -> call.attributes.put(callStartTime, ZonedDateTime.now()) - - // Security headers - call.response.pipeline.intercept(ApplicationSendPipeline.Transform) { - context.response.header(HttpHeaders.StrictTransportSecurity, "max-age=${Constant.DEFAULT_CACHE_DURATION}; includeSubDomains; preload") - - context.response.header( - "Content-Security-Policy", "default-src 'self'; " + - "img-src data: 'self' 'unsafe-inline' 'unsafe-eval' https://api.shikkanime.fr https://www.shikkanime.fr; " + - "style-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; " + - "font-src 'self' https://cdn.jsdelivr.net; " + - "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; " + - configCacheService.getValueAsString(ConfigPropertyKey.ANALYTICS_API) - ?.let { "connect-src 'self' $it; " } - ) - - context.response.header("X-Frame-Options", "DENY") - context.response.header("X-Content-Type-Options", "nosniff") - context.response.header("Referrer-Policy", "no-referrer") - context.response.header("Permissions-Policy", "geolocation=(), microphone=()") - context.response.header("X-XSS-Protection", "1; mode=block") - } + setSecurityHeaders(call, configCacheService) } environment.monitor.subscribe(Routing.RoutingCallFinished) { call -> - val startTime = call.attributes[callStartTime] - val duration = ZonedDateTime.now().toInstant().toEpochMilli() - startTime.toInstant().toEpochMilli() - val path = call.request.path() - val httpMethod = call.request.httpMethod.value - val userAgent = call.request.userAgent() - val status = call.response.status() ?: "Unhandled" - - logger.info("$httpMethod ${call.request.origin.uri} [$status - $duration ms] -> $path${if (userAgent != null) " ($userAgent)" else ""}") + logCallDetails(call) } routing { staticResources("/assets", "assets") { preCompressed(CompressedFileType.BROTLI, CompressedFileType.GZIP) - - cacheControl { - listOf(CacheControl.MaxAge(maxAgeSeconds = Constant.DEFAULT_CACHE_DURATION)) - } + cacheControl { listOf(CacheControl.MaxAge(maxAgeSeconds = Constant.DEFAULT_CACHE_DURATION)) } } createRoutes() } } +private fun setSecurityHeaders(call: ApplicationCall, configCacheService: ConfigCacheService) { + call.response.pipeline.intercept(ApplicationSendPipeline.Transform) { + context.response.header( + HttpHeaders.StrictTransportSecurity, + "max-age=${Constant.DEFAULT_CACHE_DURATION}; includeSubDomains; preload" + ) + context.response.header("Content-Security-Policy", "default-src 'self'; " + + "img-src data: 'self' 'unsafe-inline' 'unsafe-eval' https://api.shikkanime.fr https://www.shikkanime.fr; " + + "style-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; " + + "font-src 'self' https://cdn.jsdelivr.net; " + + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; " + + configCacheService.getValueAsString(ConfigPropertyKey.ANALYTICS_API) + ?.let { "connect-src 'self' $it; " } + ) + context.response.header("X-Frame-Options", "DENY") + context.response.header("X-Content-Type-Options", "nosniff") + context.response.header("Referrer-Policy", "no-referrer") + context.response.header("Permissions-Policy", "geolocation=(), microphone=()") + context.response.header("X-XSS-Protection", "1; mode=block") + } +} + +private fun logCallDetails(call: ApplicationCall) { + val startTime = call.attributes[callStartTime] + val duration = ZonedDateTime.now().toInstant().toEpochMilli() - startTime.toInstant().toEpochMilli() + val path = call.request.path() + val httpMethod = call.request.httpMethod.value + val userAgent = call.request.userAgent() + val status = call.response.status()?.value ?: 0 + + logger.info("$httpMethod ${call.request.origin.uri} [$status - $duration ms] -> $path${if (userAgent != null) " ($userAgent)" else ""}") +} + private fun Routing.createRoutes() { Constant.reflections.getTypesAnnotatedWith(Controller::class.java).forEach { controllerClass -> val controller = Constant.injector.getInstance(controllerClass) @@ -109,101 +106,22 @@ private fun Routing.createRoutes() { } fun Routing.createControllerRoutes(controller: Any) { - val prefix = - if (controller::class.hasAnnotation()) controller::class.findAnnotation()!!.value else "" + val prefix = controller::class.findAnnotation()?.value ?: "" val kMethods = controller::class.declaredFunctions.filter { it.hasAnnotation() }.toMutableSet() - if (prefix != "/") { - route("$prefix/", { - hidden = true - }) { - get { - call.respondRedirect(prefix) - } - } - } - route(prefix) { kMethods.forEach { method -> val path = method.findAnnotation()!!.value + val routeHandler: Route.() -> Unit = { handleMethods(method, prefix, controller, path) } - if (method.hasAnnotation()) { - authenticate("auth-jwt") { - handleMethods(method, prefix, controller, path) - } - } else if (method.hasAnnotation()) { - authenticate("auth-admin-session") { - handleMethods(method, prefix, controller, path) - } - } else { - handleMethods(method, prefix, controller, path) - } - } - } -} - -private fun swagger( - method: KFunction<*>, - routeTags: List, - hiddenRoute: Boolean -): OpenApiRoute.() -> Unit { - val openApi = method.findAnnotation() ?: return { - tags = routeTags - hidden = hiddenRoute - } + when { + method.hasAnnotation() -> authenticate("auth-jwt", build = routeHandler) + method.hasAnnotation() -> authenticate( + "auth-admin-session", + build = routeHandler + ) - return { - tags = routeTags - hidden = hiddenRoute || openApi.hidden - description = openApi.description - request { - method.parameters.filter { it.hasAnnotation() }.forEach { parameter -> - val qp = parameter.findAnnotation()!! - val name = qp.name.ifBlank { parameter.name!! } - val type = if (qp.type == Unit::class) parameter.type.jvmErasure else qp.type - - queryParameter(name, type) { - description = qp.description - required = qp.required - } - } - - method.parameters.filter { it.hasAnnotation() }.forEach { parameter -> - val pp = parameter.findAnnotation()!! - val name = pp.name.ifBlank { parameter.name!! } - val type = if (pp.type == Unit::class) parameter.type.jvmErasure else pp.type - - pathParameter(name, type) { - description = pp.description - required = true - } - } - } - response { - openApi.responses.forEach { response -> - HttpStatusCode.fromValue(response.status) to { - description = response.description - - if (response.type.java.isArray) { - body(BodyTypeDescriptor.multipleOf(response.type.java.componentType.kotlin)) { - mediaType( - ContentType( - response.contentType.split("/")[0], - response.contentType.split("/")[1] - ) - ) - } - } else { - body(response.type) { - mediaType( - ContentType( - response.contentType.split("/")[0], - response.contentType.split("/")[1] - ) - ) - } - } - } + else -> routeHandler() } } } @@ -218,29 +136,14 @@ private fun Route.handleMethods( val routeTags = listOf(controller.javaClass.simpleName.replace("Controller", "")) val hiddenRoute = !"$prefix$path".startsWith("/api") val swaggerBuilder = swagger(method, routeTags, hiddenRoute) - - if (method.hasAnnotation()) { - get(path, swaggerBuilder) { - handleRequest(call, method, prefix, controller, path) - } - } - - if (method.hasAnnotation()) { - post(path, swaggerBuilder) { - handleRequest(call, method, prefix, controller, path) - } - } - - if (method.hasAnnotation()) { - put(path, swaggerBuilder) { - handleRequest(call, method, prefix, controller, path) - } - } - - if (method.hasAnnotation()) { - delete(path, swaggerBuilder) { - handleRequest(call, method, prefix, controller, path) - } + val routeHandler: suspend PipelineContext.(Unit) -> Unit = + { handleRequest(call, method, prefix, controller, path) } + + when { + method.hasAnnotation() -> get(path, swaggerBuilder, routeHandler) + method.hasAnnotation() -> post(path, swaggerBuilder, routeHandler) + method.hasAnnotation() -> put(path, swaggerBuilder, routeHandler) + method.hasAnnotation() -> delete(path, swaggerBuilder, routeHandler) } } @@ -287,52 +190,15 @@ private suspend fun handleTemplateResponse( replacedPath: String, response: Response ) { - val configCacheService = Constant.injector.getInstance(ConfigCacheService::class.java) - val simulcastCacheService = Constant.injector.getInstance(SimulcastCacheService::class.java) - val map = response.data as Map // NOSONAR val modelMap = (map["model"] as Map).toMutableMap() // NOSONAR - modelMap["su"] = StringUtils - - val linkObjects = LinkObject.list() - - val list = if (controller.javaClass.simpleName.startsWith("Admin")) - linkObjects.filter { it.href.startsWith("/admin") } - else - linkObjects.filter { !it.href.startsWith("/admin") } - - modelMap["links"] = list.filter { !it.footer }.map { link -> - link.href = link.href.replace("{currentSimulcast}", simulcastCacheService.currentSimulcast?.slug ?: "") - link.active = if (link.href == "/") - replacedPath == link.href - else - replacedPath.startsWith(link.href) - - link - } - - - modelMap["footerLinks"] = list.filter { it.footer } - - modelMap["title"] = (map["title"] as? String)?.let { title -> - if (title.contains(Constant.NAME)) title else "$title - ${Constant.NAME}" - } ?: Constant.NAME - - modelMap["seoDescription"] = configCacheService.getValueAsString(ConfigPropertyKey.SEO_DESCRIPTION) - configCacheService.getValueAsString(ConfigPropertyKey.GOOGLE_SITE_VERIFICATION_ID) - ?.let { modelMap["googleSiteVerification"] = it } - simulcastCacheService.currentSimulcast?.let { modelMap["currentSimulcast"] = it } - - configCacheService.getValueAsString(ConfigPropertyKey.ANALYTICS_DOMAIN)?.let { modelMap["analyticsDomain"] = it } - configCacheService.getValueAsString(ConfigPropertyKey.ANALYTICS_API)?.let { modelMap["analyticsApi"] = it } - configCacheService.getValueAsString(ConfigPropertyKey.ANALYTICS_SCRIPT)?.let { modelMap["analyticsScript"] = it } - + setGlobalAttributes(modelMap, controller, replacedPath, map["title"] as String?) call.respond(response.status, FreeMarkerContent(map["template"] as String, modelMap, "", response.contentType)) } -private fun replacePathWithParameters(path: String, parameters: Map>): String = - parameters.keys.fold(path) { acc, param -> - acc.replace("{$param}", parameters[param]!!.joinToString(", ")) +private fun replacePathWithParameters(path: String, parameters: Map>) = + parameters.entries.fold(path) { acc, (param, values) -> + acc.replace("{$param}", values.joinToString(", ")) } private suspend fun callMethodWithParameters( @@ -344,12 +210,11 @@ private suspend fun callMethodWithParameters( val methodParams = method.parameters.associateWith { kParameter -> when { kParameter.name.isNullOrBlank() -> controller - method.hasAnnotation() && kParameter.hasAnnotation() -> - UUID.fromString(call.principal()!!.payload.getClaim("uuid").asString()) - - method.hasAnnotation() && kParameter.hasAnnotation() -> - call.principal() + kParameter.hasAnnotation() -> UUID.fromString( + call.principal()!!.payload.getClaim("uuid").asString() + ) + kParameter.hasAnnotation() -> call.principal() kParameter.hasAnnotation() -> handleBodyParam(kParameter, call) kParameter.hasAnnotation() -> handleQueryParam(kParameter, call) kParameter.hasAnnotation() -> handlePathParam(kParameter, parameters) @@ -370,28 +235,25 @@ private suspend fun handleBodyParam(kParameter: KParameter, call: ApplicationCal } private fun handleQueryParam(kParameter: KParameter, call: ApplicationCall): Any? { - val name = kParameter.findAnnotation()!!.name.ifBlank { kParameter.name!! } - val queryParamValue = call.request.queryParameters[name] + val name = kParameter.findAnnotation()?.name ?: kParameter.name + val queryParamValue = name?.let { call.request.queryParameters[it] } return when (kParameter.type) { Int::class.starProjectedType.withNullability(true) -> queryParamValue?.toIntOrNull() String::class.starProjectedType.withNullability(true) -> queryParamValue CountryCode::class.starProjectedType.withNullability(true) -> CountryCode.fromNullable(queryParamValue) - UUID::class.starProjectedType.withNullability(true) -> if (queryParamValue.isNullOrBlank()) null else UUID.fromString( - queryParamValue - ) - + UUID::class.starProjectedType.withNullability(true) -> queryParamValue?.let { UUID.fromString(it) } else -> throw Exception("Unknown type ${kParameter.type}") } } private fun handlePathParam(kParameter: KParameter, parameters: Map>): Any? { - val name = kParameter.findAnnotation()!!.name.ifBlank { kParameter.name!! } + val name = kParameter.findAnnotation()?.name ?: kParameter.name val pathParamValue = parameters[name]?.firstOrNull() return when (kParameter.type.javaType) { - UUID::class.java -> UUID.fromString(pathParamValue) - Platform::class.java -> Platform.valueOf(pathParamValue!!) + UUID::class.java -> pathParamValue?.let { UUID.fromString(it) } + Platform::class.java -> pathParamValue?.let { Platform.valueOf(it) } String::class.java -> pathParamValue else -> throw Exception("Unknown type ${kParameter.type}") } diff --git a/src/main/kotlin/fr/shikkanime/modules/SEOManager.kt b/src/main/kotlin/fr/shikkanime/modules/SEOManager.kt new file mode 100644 index 00000000..4732828a --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/modules/SEOManager.kt @@ -0,0 +1,47 @@ +package fr.shikkanime.modules + +import fr.shikkanime.entities.LinkObject +import fr.shikkanime.entities.enums.ConfigPropertyKey +import fr.shikkanime.services.caches.ConfigCacheService +import fr.shikkanime.services.caches.SimulcastCacheService +import fr.shikkanime.utils.Constant +import fr.shikkanime.utils.StringUtils + +private const val ADMIN = "/admin" + +fun setGlobalAttributes( + modelMap: MutableMap, + controller: Any, + replacedPath: String, + title: String? +) { + val configCacheService = Constant.injector.getInstance(ConfigCacheService::class.java) + val simulcastCacheService = Constant.injector.getInstance(SimulcastCacheService::class.java) + + modelMap["su"] = StringUtils + modelMap["links"] = getLinks(controller, replacedPath, simulcastCacheService) + modelMap["footerLinks"] = getFooterLinks(controller) + modelMap["title"] = getTitle(title) + modelMap["seoDescription"] = configCacheService.getValueAsString(ConfigPropertyKey.SEO_DESCRIPTION) + modelMap["googleSiteVerification"] = + configCacheService.getValueAsString(ConfigPropertyKey.GOOGLE_SITE_VERIFICATION_ID) + modelMap["currentSimulcast"] = simulcastCacheService.currentSimulcast + modelMap["analyticsDomain"] = configCacheService.getValueAsString(ConfigPropertyKey.ANALYTICS_DOMAIN) + modelMap["analyticsApi"] = configCacheService.getValueAsString(ConfigPropertyKey.ANALYTICS_API) + modelMap["analyticsScript"] = configCacheService.getValueAsString(ConfigPropertyKey.ANALYTICS_SCRIPT) +} + +private fun getLinks(controller: Any, replacedPath: String, simulcastCacheService: SimulcastCacheService) = + LinkObject.list() + .filter { it.href.startsWith(ADMIN) == controller.javaClass.simpleName.startsWith("Admin") && !it.footer } + .map { link -> + link.href = link.href.replace("{currentSimulcast}", simulcastCacheService.currentSimulcast?.slug ?: "") + link.active = if (link.href == "/") replacedPath == link.href else replacedPath.startsWith(link.href) + link + } + +private fun getFooterLinks(controller: Any) = LinkObject.list() + .filter { it.href.startsWith(ADMIN) == controller.javaClass.simpleName.startsWith("Admin") && it.footer } + +private fun getTitle(title: String?): String = + title?.takeIf { it.contains(Constant.NAME) } ?: "$title - ${Constant.NAME}" \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/modules/Security.kt b/src/main/kotlin/fr/shikkanime/modules/Security.kt index eec8ae04..3628650e 100644 --- a/src/main/kotlin/fr/shikkanime/modules/Security.kt +++ b/src/main/kotlin/fr/shikkanime/modules/Security.kt @@ -8,7 +8,6 @@ import fr.shikkanime.dtos.TokenDto import fr.shikkanime.entities.enums.Role import fr.shikkanime.services.caches.MemberCacheService import fr.shikkanime.utils.Constant -import fr.shikkanime.utils.LoggerFactory import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.auth.* @@ -17,47 +16,15 @@ import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.sessions.* import java.util.* -import java.util.logging.Level -private val logger = LoggerFactory.getLogger("Security") +private val memberCacheService = Constant.injector.getInstance(MemberCacheService::class.java) fun Application.configureSecurity() { - val memberCacheService = Constant.injector.getInstance(MemberCacheService::class.java) - - val jwtVerifier = JWT - .require(Algorithm.HMAC256(Constant.jwtSecret)) - .withAudience(Constant.jwtAudience) - .withIssuer(Constant.jwtDomain) - .withClaimPresence("uuid") - .withClaimPresence("username") - .withClaimPresence("creationDateTime") - .withClaimPresence("roles") - .build() + val jwtVerifier = setupJWTVerifier() authentication { - jwt { - realm = Constant.jwtRealm - verifier(jwtVerifier) - validate { credential -> - if (credential.payload.audience.contains(Constant.jwtAudience)) JWTPrincipal(credential.payload) else null - } - } - - session("auth-admin-session") { - validate { session -> - return@validate validationSession(jwtVerifier, session, memberCacheService) - } - challenge { - if (call.request.contentType() != ContentType.Text.Html) { - call.respond( - HttpStatusCode.Unauthorized, - MessageDto(MessageDto.Type.ERROR, "You are not authorized to access this page") - ) - } else { - call.respondRedirect("/admin?error=2", permanent = true) - } - } - } + setupJWTAuthentication(jwtVerifier) + setupSessionAuthentication(jwtVerifier) } install(Sessions) { @@ -68,42 +35,49 @@ fun Application.configureSecurity() { } } -private fun validationSession( - jwtVerifier: JWTVerifier, - session: TokenDto, - memberCacheService: MemberCacheService -): TokenDto? { - try { - val jwtPrincipal = jwtVerifier.verify(session.token) - val uuid = UUID.fromString(jwtPrincipal.getClaim("uuid").asString()) - val username = jwtPrincipal.getClaim("username").asString() - val creationDateTime = jwtPrincipal.getClaim("creationDateTime").asString() - val roles = jwtPrincipal.getClaim("roles").asArray(Role::class.java) - val member = memberCacheService.find(uuid) ?: return null - - if (member.username != username) { - logger.log(Level.SEVERE, "Error while validating session: username mismatch") - return null - } +private fun setupJWTVerifier(): JWTVerifier = JWT + .require(Algorithm.HMAC256(Constant.jwtSecret)) + .withAudience(Constant.jwtAudience) + .withIssuer(Constant.jwtDomain) + .withClaimPresence("uuid") + .withClaimPresence("username") + .withClaimPresence("creationDateTime") + .withClaimPresence("roles") + .build() - if (!member.roles.toTypedArray().contentEquals(roles)) { - logger.log(Level.SEVERE, "Error while validating session: roles mismatch") - return null - } - - if (member.creationDateTime.toString() != creationDateTime) { - logger.log(Level.SEVERE, "Error while validating session: creationDateTime mismatch") - return null +private fun AuthenticationConfig.setupJWTAuthentication(jwtVerifier: JWTVerifier) { + jwt { + realm = Constant.jwtRealm + verifier(jwtVerifier) + validate { credential -> + if (credential.payload.audience.contains(Constant.jwtAudience)) JWTPrincipal(credential.payload) else null } + } +} - if (member.roles.none { it == Role.ADMIN }) { - logger.log(Level.SEVERE, "Error while validating session: role is not admin") - return null +private fun AuthenticationConfig.setupSessionAuthentication(jwtVerifier: JWTVerifier) { + session("auth-admin-session") { + validate { session -> validationSession(jwtVerifier, session) } + challenge { + if (call.request.contentType() != ContentType.Text.Html) { + call.respond( + HttpStatusCode.Unauthorized, + MessageDto(MessageDto.Type.ERROR, "You are not authorized to access this page") + ) + } else { + call.respondRedirect("/admin?error=2", permanent = true) + } } - - return session - } catch (e: Exception) { - logger.log(Level.SEVERE, "Error while validating session", e) - return null } } + +private fun validationSession(jwtVerifier: JWTVerifier, session: TokenDto): TokenDto? { + val jwtPrincipal = jwtVerifier.verify(session.token) ?: return null + val member = memberCacheService.find(UUID.fromString(jwtPrincipal.getClaim("uuid").asString())) ?: return null + + return if (member.username == jwtPrincipal.getClaim("username").asString() && + member.roles.toTypedArray().contentEquals(jwtPrincipal.getClaim("roles").asArray(Role::class.java)) && + member.creationDateTime.toString() == jwtPrincipal.getClaim("creationDateTime").asString() && + member.roles.any { it == Role.ADMIN } + ) session else null +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/modules/SwaggerRouting.kt b/src/main/kotlin/fr/shikkanime/modules/SwaggerRouting.kt new file mode 100644 index 00000000..47d665d7 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/modules/SwaggerRouting.kt @@ -0,0 +1,80 @@ +package fr.shikkanime.modules + +import fr.shikkanime.utils.routes.openapi.OpenAPI +import fr.shikkanime.utils.routes.param.PathParam +import fr.shikkanime.utils.routes.param.QueryParam +import io.github.smiley4.ktorswaggerui.dsl.BodyTypeDescriptor +import io.github.smiley4.ktorswaggerui.dsl.OpenApiRoute +import io.github.smiley4.ktorswaggerui.dsl.OpenApiSimpleBody +import io.ktor.http.* +import kotlin.reflect.KFunction +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.hasAnnotation +import kotlin.reflect.jvm.jvmErasure + +fun swagger( + method: KFunction<*>, + routeTags: List, + hiddenRoute: Boolean +): OpenApiRoute.() -> Unit { + val openApi = method.findAnnotation() ?: return { + tags = routeTags + hidden = hiddenRoute + } + + return { + tags = routeTags + hidden = hiddenRoute || openApi.hidden + description = openApi.description + swaggerRequest(method) + swaggerResponse(openApi) + } +} + +private fun OpenApiRoute.swaggerRequest(method: KFunction<*>) { + request { + method.parameters.filter { it.hasAnnotation() || it.hasAnnotation() } + .forEach { parameter -> + val name = parameter.name!! + val type = parameter.type.jvmErasure + + when { + parameter.hasAnnotation() -> { + val qp = parameter.findAnnotation()!! + queryParameter(name, type) { + description = qp.description + required = qp.required + } + } + + parameter.hasAnnotation() -> { + val pp = parameter.findAnnotation()!! + pathParameter(name, type) { + description = pp.description + required = true + } + } + } + } + } +} + +private fun OpenApiRoute.swaggerResponse(openApi: OpenAPI) { + response { + openApi.responses.forEach { response -> + HttpStatusCode.fromValue(response.status) to { + description = response.description + val block: OpenApiSimpleBody.() -> Unit = { mediaType(ContentType.parse(response.contentType)) } + + when { + response.type.java.isArray -> body( + BodyTypeDescriptor.multipleOf(response.type.java.componentType.kotlin), + block + ) + + else -> body(response.type, block) + } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/services/AnimeService.kt b/src/main/kotlin/fr/shikkanime/services/AnimeService.kt index 8e0fec95..dbd1ec1f 100644 --- a/src/main/kotlin/fr/shikkanime/services/AnimeService.kt +++ b/src/main/kotlin/fr/shikkanime/services/AnimeService.kt @@ -7,9 +7,11 @@ import fr.shikkanime.dtos.WeeklyAnimeDto import fr.shikkanime.dtos.WeeklyAnimesDto import fr.shikkanime.dtos.animes.AnimeNoStatusDto import fr.shikkanime.entities.Anime +import fr.shikkanime.entities.Episode import fr.shikkanime.entities.SortParameter import fr.shikkanime.entities.enums.CountryCode import fr.shikkanime.repositories.AnimeRepository +import fr.shikkanime.utils.Constant import fr.shikkanime.utils.MapCache import fr.shikkanime.utils.StringUtils.capitalizeWords import fr.shikkanime.utils.withUTC @@ -52,10 +54,10 @@ class AnimeService : AbstractService() { fun findAllUUIDAndImage() = animeRepository.findAllUUIDAndImage() fun getWeeklyAnimes(startOfWeekDay: LocalDate, countryCode: CountryCode): List { - val start = ZonedDateTime.parse("${startOfWeekDay.minusDays(7)}T00:00:00Z") - val end = ZonedDateTime.parse("${startOfWeekDay.plusDays(7)}T23:59:59Z") + val start = startOfWeekDay.minusDays(7).atStartOfDay(Constant.utcZoneId) + val end = startOfWeekDay.plusDays(7).atTime(23, 59, 59).withUTC() val list = episodeService.findAllByDateRange(countryCode, start, end) - val pattern = DateTimeFormatter.ofPattern("EEEE", Locale.of(countryCode.locale.split("-")[0], countryCode.locale.split("-")[1])) + val pattern = DateTimeFormatter.ofPattern("EEEE", Locale.forLanguageTag(countryCode.locale)) return startOfWeekDay.datesUntil(startOfWeekDay.plusDays(7)).toList().map { date -> val dateTitle = date.format(pattern).capitalizeWords() @@ -65,11 +67,9 @@ class AnimeService : AbstractService() { WeeklyAnimesDto( dateTitle, episodes.distinctBy { episode -> episode.anime?.uuid }.map { distinctEpisode -> - val platforms = episodes.mapNotNull { episode -> - if (episode.anime?.uuid == distinctEpisode.anime?.uuid) - episode.platform!! - else null - }.distinct() + val platforms = episodes.filter { it.anime?.uuid == distinctEpisode.anime?.uuid } + .mapNotNull(Episode::platform) + .distinct() WeeklyAnimeDto( AbstractConverter.convert(distinctEpisode.anime, AnimeNoStatusDto::class.java).toAnimeDto(), diff --git a/src/main/kotlin/fr/shikkanime/utils/Extensions.kt b/src/main/kotlin/fr/shikkanime/utils/Extensions.kt index c78d9fbe..8a79e440 100644 --- a/src/main/kotlin/fr/shikkanime/utils/Extensions.kt +++ b/src/main/kotlin/fr/shikkanime/utils/Extensions.kt @@ -2,6 +2,7 @@ package fr.shikkanime.utils import com.mortennobel.imagescaling.ResampleOp import java.awt.image.BufferedImage +import java.time.LocalDateTime import java.time.LocalTime import java.time.ZonedDateTime @@ -13,6 +14,10 @@ fun ZonedDateTime.withUTC(): ZonedDateTime { return this.withZoneSameInstant(Constant.utcZoneId) } +fun LocalDateTime.withUTC(): ZonedDateTime { + return this.atZone(Constant.utcZoneId) +} + fun LocalTime.isEqualOrAfter(other: LocalTime): Boolean { return this == other || this.isAfter(other) } diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/Response.kt b/src/main/kotlin/fr/shikkanime/utils/routes/Response.kt index fc1554e8..8fc53ee1 100644 --- a/src/main/kotlin/fr/shikkanime/utils/routes/Response.kt +++ b/src/main/kotlin/fr/shikkanime/utils/routes/Response.kt @@ -18,30 +18,6 @@ open class Response( val data: Any? = null, val contentType: ContentType = ContentType.Application.Json, ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is Response) return false - - if (status != other.status) return false - if (data != other.data) return false - if (session != other.session) return false - if (contentType != other.contentType) return false - - return true - } - - override fun hashCode(): Int { - var result = status.hashCode() - result = 31 * result + (data?.hashCode() ?: 0) - result = 31 * result + (session?.hashCode() ?: 0) - result = 31 * result + contentType.hashCode() - return result - } - - override fun toString(): String { - return "Response(status=$status, data=$data, session=$session)" - } - companion object { fun ok(data: Any? = null, session: TokenDto? = null): Response = Response(HttpStatusCode.OK, data = data, session = session)