diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..4db9601d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +version: 2 +enable-beta-ecosystems: true +updates: + - package-ecosystem: "gradle" + directory: "/" + schedule: + interval: "daily" + time: "10:00" + timezone: "Europe/Paris" + assignees: + - "Ziedelth" + reviewers: + - "Ziedelth" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "10:00" + timezone: "Europe/Paris" + assignees: + - "Ziedelth" + reviewers: + - "Ziedelth" \ No newline at end of file diff --git a/.github/workflows/update-gradle-wrapper.yml b/.github/workflows/update-gradle-wrapper.yml new file mode 100644 index 00000000..43e6d4e6 --- /dev/null +++ b/.github/workflows/update-gradle-wrapper.yml @@ -0,0 +1,21 @@ +name: Update Gradle Wrapper + +on: + schedule: + - cron: "30 11 * * *" + +jobs: + update-gradle-wrapper: + runs-on: ubuntu-latest + + permissions: + contents: write + pull-requests: write + + steps: + - uses: actions/checkout@v3 + + - name: Update Gradle Wrapper + uses: gradle-update/update-gradle-wrapper-action@v1 + with: + reviewers: Ziedelth \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..e87de456 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ +/metrics.json +/.jpb/ diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 00000000..b98fd8c8 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,47 @@ +val ktor_version: String by project +val kotlin_version: String by project + +plugins { + kotlin("jvm") version "1.9.21" + id("io.ktor.plugin") version "2.3.6" +} + +group = "fr.shikkanime" +version = "0.0.1" + +application { + mainClass.set("fr.shikkanime.ApplicationKt") + + val isDevelopment: Boolean = project.ext.has("development") + applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment") +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("io.ktor:ktor-server-core-jvm:$ktor_version") + implementation("io.ktor:ktor-server-auth-jvm:$ktor_version") + implementation("io.ktor:ktor-server-auth-jwt-jvm:$ktor_version") + implementation("io.ktor:ktor-server-host-common-jvm:$ktor_version") + implementation("io.ktor:ktor-server-status-pages-jvm:$ktor_version") + implementation("io.ktor:ktor-server-caching-headers-jvm:$ktor_version") + implementation("io.ktor:ktor-server-compression-jvm:$ktor_version") + implementation("io.ktor:ktor-server-cors-jvm:$ktor_version") + implementation("io.ktor:ktor-server-content-negotiation-jvm:$ktor_version") + implementation("io.ktor:ktor-serialization-gson:$ktor_version") + implementation("io.ktor:ktor-server-freemarker-jvm:$ktor_version") + implementation("io.ktor:ktor-server-netty-jvm:$ktor_version") + implementation("io.ktor:ktor-client-core:$ktor_version") + implementation("io.ktor:ktor-client-cio:$ktor_version") + implementation("org.hibernate.orm:hibernate-core:6.4.0.Final") + implementation("org.postgresql:postgresql:42.7.0") + implementation("org.reflections:reflections:0.10.2") + implementation("com.google.inject:guice:7.0.0") + implementation("org.liquibase:liquibase-core:4.25.0") + implementation("org.quartz-scheduler:quartz:2.5.0-rc1") + implementation("com.google.guava:guava:32.1.3-jre") + testImplementation("io.ktor:ktor-server-tests-jvm:$ktor_version") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..2b55b077 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +ktor_version=2.3.6 +kotlin_version=1.9.21 +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..7454180f Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..e411586a --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..1b6c7873 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..107acd32 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/hibernate.cfg.xml b/hibernate.cfg.xml new file mode 100644 index 00000000..425cd7fa --- /dev/null +++ b/hibernate.cfg.xml @@ -0,0 +1,14 @@ + + + + + + jdbc:postgresql://localhost:5432/shikkanime + postgres + mysecretpassword + false + org.hibernate.context.internal.ThreadLocalSessionContext + + \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 00000000..c70332dd --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "core" diff --git a/src/main/kotlin/fr/shikkanime/Application.kt b/src/main/kotlin/fr/shikkanime/Application.kt new file mode 100644 index 00000000..bc202a16 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/Application.kt @@ -0,0 +1,32 @@ +package fr.shikkanime + +import fr.shikkanime.jobs.FetchEpisodesJob +import fr.shikkanime.jobs.GCJob +import fr.shikkanime.jobs.MetricJob +import fr.shikkanime.plugins.configureHTTP +import fr.shikkanime.plugins.configureRouting +import fr.shikkanime.plugins.configureSecurity +import fr.shikkanime.utils.JobManager +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* + +fun main() { + JobManager.scheduleJob("*/10 * * * * ?", MetricJob::class.java) + JobManager.scheduleJob("0 */5 * * * ?", GCJob::class.java) + JobManager.scheduleJob("0 */2 * * * ?", FetchEpisodesJob::class.java) + JobManager.start() + + embeddedServer( + Netty, + port = 37100, + host = "0.0.0.0", + module = Application::module + ).start(wait = true) +} + +fun Application.module() { + configureSecurity() + configureHTTP() + configureRouting() +} diff --git a/src/main/kotlin/fr/shikkanime/caches/FromToZonedDateTimeKeyCache.kt b/src/main/kotlin/fr/shikkanime/caches/FromToZonedDateTimeKeyCache.kt new file mode 100644 index 00000000..98bcb09a --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/caches/FromToZonedDateTimeKeyCache.kt @@ -0,0 +1,28 @@ +package fr.shikkanime.caches + +import java.time.ZonedDateTime + +data class FromToZonedDateTimeKeyCache( + val from: ZonedDateTime, + val to: ZonedDateTime +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is FromToZonedDateTimeKeyCache) return false + + if (from != other.from) return false + if (to != other.to) return false + + return true + } + + override fun hashCode(): Int { + var result = from.hashCode() + result = 31 * result + to.hashCode() + return result + } + + override fun toString(): String { + return "FromToZonedDateTimeKeyCache(from=$from, to=$to)" + } +} diff --git a/src/main/kotlin/fr/shikkanime/controllers/AdminController.kt b/src/main/kotlin/fr/shikkanime/controllers/AdminController.kt new file mode 100644 index 00000000..9d48a3b0 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/controllers/AdminController.kt @@ -0,0 +1,28 @@ +package fr.shikkanime.controllers + +import fr.shikkanime.utils.routes.Controller +import fr.shikkanime.utils.routes.Path +import fr.shikkanime.utils.routes.Response +import fr.shikkanime.utils.routes.TemplateResponse +import fr.ziedelth.utils.routes.method.Get + +@Controller("/admin") +class AdminController { + @Path("/dashboard") + @Get + private fun getDashboard(): Response { + return TemplateResponse( + "admin/dashboard.ftl", + "Dashboard", + ) + } + + @Path("/platforms") + @Get + private fun getPlatforms(): Response { + return TemplateResponse( + "admin/platforms.ftl", + "Platforms", + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/controllers/api/MetricController.kt b/src/main/kotlin/fr/shikkanime/controllers/api/MetricController.kt new file mode 100644 index 00000000..8582a870 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/controllers/api/MetricController.kt @@ -0,0 +1,24 @@ +package fr.shikkanime.controllers.api + +import com.google.inject.Inject +import fr.shikkanime.converters.AbstractConverter +import fr.shikkanime.dtos.MetricDto +import fr.shikkanime.services.MetricService +import fr.shikkanime.utils.routes.Controller +import fr.shikkanime.utils.routes.Path +import fr.shikkanime.utils.routes.Response +import fr.ziedelth.utils.routes.method.Get +import java.time.ZonedDateTime + +@Controller("/api/metrics") +class MetricController { + @Inject + private lateinit var metricService: MetricService + + @Path + @Get + private fun getPlatforms(): Response { + val oneHourAgo = ZonedDateTime.now().minusHours(1) + return Response.ok(AbstractConverter.convert(metricService.findAllAfter(oneHourAgo), MetricDto::class.java)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/controllers/api/PlatformController.kt b/src/main/kotlin/fr/shikkanime/controllers/api/PlatformController.kt new file mode 100644 index 00000000..96eb9c60 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/controllers/api/PlatformController.kt @@ -0,0 +1,43 @@ +package fr.shikkanime.controllers.api + +import com.google.inject.Inject +import fr.shikkanime.converters.AbstractConverter +import fr.shikkanime.dtos.PlatformDto +import fr.shikkanime.entities.Platform +import fr.shikkanime.services.PlatformService +import fr.shikkanime.utils.routes.BodyParam +import fr.shikkanime.utils.routes.Controller +import fr.shikkanime.utils.routes.Path +import fr.shikkanime.utils.routes.Response +import fr.ziedelth.utils.routes.method.Get +import fr.ziedelth.utils.routes.method.Post +import java.util.* + +@Controller("/api/platforms") +class PlatformController { + @Inject + private lateinit var platformService: PlatformService + + @Path + @Get + private fun getPlatforms(): Response { + return Response.ok(AbstractConverter.convert(platformService.findAll(), PlatformDto::class.java)) + } + + @Path("/{uuid}") + @Get + private fun getPlatform(uuid: UUID): Response { + return Response.ok(AbstractConverter.convert(platformService.find(uuid), PlatformDto::class.java)) + } + + @Path + @Post + private fun createPlatform(@BodyParam platformDto: PlatformDto): Response { + return Response.ok( + AbstractConverter.convert( + platformService.save(AbstractConverter.convert(platformDto, Platform::class.java)), + PlatformDto::class.java + ) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/converters/AbstractConverter.kt b/src/main/kotlin/fr/shikkanime/converters/AbstractConverter.kt new file mode 100644 index 00000000..a6f1e211 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/converters/AbstractConverter.kt @@ -0,0 +1,47 @@ +package fr.shikkanime.converters + +import fr.shikkanime.utils.Constant +import java.lang.reflect.ParameterizedType + +abstract class AbstractConverter { + abstract fun convert(from: F): T + + companion object { + private val converters: MutableMap, Class<*>>, AbstractConverter<*, *>> = mutableMapOf() + + init { + val converters = Constant.reflections.getSubTypesOf(AbstractConverter::class.java) + + converters.forEach { + val (from, to) = (it.genericSuperclass as ParameterizedType).actualTypeArguments.map { argument -> argument as Class<*> } + this.converters[Pair(from, to)] = Constant.guice.getInstance(it) + } + } + + fun convert(`object`: Any?, to: Class): T { + if (`object` == null) { + throw NullPointerException("Can not convert null to \"${to.simpleName}\"") + } + + val pair = Pair(`object`.javaClass, to) + + if (!converters.containsKey(pair)) { + throw NoSuchElementException("Can not find converter \"${`object`.javaClass.simpleName}\" to \"${to.simpleName}\"") + } + + val abstractConverter = converters[pair] ?: throw IllegalStateException() + val abstractConverterClass = abstractConverter.javaClass + val method = abstractConverterClass.getMethod("convert", `object`.javaClass) + method.isAccessible = true + return try { + method.invoke(abstractConverter, `object`) as T + } catch (e: Exception) { + throw IllegalStateException("Can not convert \"${`object`.javaClass.simpleName}\" to \"${to.simpleName}\"", e) + } + } + + fun convert(list: List, to: Class): List { + return list.map { convert(it, to) } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/converters/metric/MetricToMetricDtoConverter.kt b/src/main/kotlin/fr/shikkanime/converters/metric/MetricToMetricDtoConverter.kt new file mode 100644 index 00000000..69a8e9db --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/converters/metric/MetricToMetricDtoConverter.kt @@ -0,0 +1,33 @@ +package fr.shikkanime.converters.metric + +import com.google.inject.Inject +import fr.shikkanime.converters.AbstractConverter +import fr.shikkanime.dtos.MetricDto +import fr.shikkanime.entities.Metric +import fr.shikkanime.services.MetricService +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +class MetricToMetricDtoConverter : AbstractConverter() { + private val europeParisZone = ZoneId.of("UTC") + private val dateFormatter = DateTimeFormatter.ofPattern("HH:mm:ssZ") + + @Inject + private lateinit var metricService: MetricService + + private fun Double.toDoublePoint() = String.format("%.2f", this) + + override fun convert(from: Metric): MetricDto { + val minusHours = from.date.minusHours(1) + + return MetricDto( + uuid = from.uuid, + cpuLoad = (from.cpuLoad * 100).toString().replace(',', '.'), + averageCpuLoad = metricService.getAverageCpuLoad(minusHours, from.date)?.times(100)?.toString()?.replace(',', '.') ?: "0", + memoryUsage = (from.memoryUsage / 1024.0 / 1024.0).toString().replace(',', '.'), + averageMemoryUsage = metricService.getAverageMemoryUsage(minusHours, from.date)?.div(1024)?.div(1024)?.toString()?.replace(',', '.') ?: "0", + databaseSize = (from.databaseSize / 1024.0 / 1024.0).toDoublePoint(), + date = from.date.withZoneSameInstant(europeParisZone).format(dateFormatter) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/converters/platform/PlatformDtoToPlatformConverter.kt b/src/main/kotlin/fr/shikkanime/converters/platform/PlatformDtoToPlatformConverter.kt new file mode 100644 index 00000000..58125a09 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/converters/platform/PlatformDtoToPlatformConverter.kt @@ -0,0 +1,28 @@ +package fr.shikkanime.converters.platform + +import com.google.inject.Inject +import fr.shikkanime.converters.AbstractConverter +import fr.shikkanime.dtos.PlatformDto +import fr.shikkanime.entities.Platform +import fr.shikkanime.services.PlatformService + +class PlatformDtoToPlatformConverter : AbstractConverter() { + @Inject + private lateinit var platformService: PlatformService + + override fun convert(from: PlatformDto): Platform { + if (from.uuid != null) { + val find = platformService.find(from.uuid) + + if (find != null) { + return find + } + } + + return Platform( + name = from.name, + url = from.url, + image = from.image + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/converters/platform/PlatformToPlatformDtoConverter.kt b/src/main/kotlin/fr/shikkanime/converters/platform/PlatformToPlatformDtoConverter.kt new file mode 100644 index 00000000..8b962248 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/converters/platform/PlatformToPlatformDtoConverter.kt @@ -0,0 +1,16 @@ +package fr.shikkanime.converters.platform + +import fr.shikkanime.converters.AbstractConverter +import fr.shikkanime.dtos.PlatformDto +import fr.shikkanime.entities.Platform + +class PlatformToPlatformDtoConverter : AbstractConverter() { + override fun convert(from: Platform): PlatformDto { + return PlatformDto( + uuid = from.uuid, + name = from.name!!, + url = from.url!!, + image = from.image!! + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/dtos/MetricDto.kt b/src/main/kotlin/fr/shikkanime/dtos/MetricDto.kt new file mode 100644 index 00000000..eee54f99 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/dtos/MetricDto.kt @@ -0,0 +1,14 @@ +package fr.shikkanime.dtos + +import java.io.Serializable +import java.util.* + +data class MetricDto( + val uuid: UUID?, + val cpuLoad: String, + val averageCpuLoad: String, + val memoryUsage: String, + val averageMemoryUsage: String, + val databaseSize: String, + val date: String, +) : Serializable diff --git a/src/main/kotlin/fr/shikkanime/dtos/PlatformDto.kt b/src/main/kotlin/fr/shikkanime/dtos/PlatformDto.kt new file mode 100644 index 00000000..1825410e --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/dtos/PlatformDto.kt @@ -0,0 +1,11 @@ +package fr.shikkanime.dtos + +import java.io.Serializable +import java.util.* + +data class PlatformDto( + val uuid: UUID?, + val name: String, + val url: String, + val image: String +) : Serializable diff --git a/src/main/kotlin/fr/shikkanime/entities/Country.kt b/src/main/kotlin/fr/shikkanime/entities/Country.kt new file mode 100644 index 00000000..9c090369 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/entities/Country.kt @@ -0,0 +1,46 @@ +package fr.shikkanime.entities + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Index +import jakarta.persistence.Table +import java.util.* + +@Entity +@Table( + name = "country", + indexes = [ + Index(name = "idx_country_country_code", columnList = "country_code") + ] +) +data class Country( + override val uuid: UUID? = null, + @Column(nullable = false, unique = true) + val name: String? = null, + @Column(nullable = false, unique = true, name = "country_code") + val countryCode: String? = null, +) : ShikkEntity(uuid) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Country) return false + if (!super.equals(other)) return false + + if (uuid != other.uuid) return false + if (name != other.name) return false + if (countryCode != other.countryCode) return false + + return true + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + (uuid?.hashCode() ?: 0) + result = 31 * result + (name?.hashCode() ?: 0) + result = 31 * result + (countryCode?.hashCode() ?: 0) + return result + } + + override fun toString(): String { + return "Country(uuid=$uuid, name=$name, countryCode=$countryCode)" + } +} diff --git a/src/main/kotlin/fr/shikkanime/entities/Link.kt b/src/main/kotlin/fr/shikkanime/entities/Link.kt new file mode 100644 index 00000000..d1493e36 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/entities/Link.kt @@ -0,0 +1,17 @@ +package fr.shikkanime.entities + +data class Link( + val href: String, + val icon: String, + val name: String, + var active: Boolean = false, +) { + companion object { + fun list(): List { + return listOf( + Link("/admin/dashboard", "bi bi-speedometer2", "Dashboard"), + Link("/admin/platforms", "bi bi-display", "Platforms"), + ) + } + } +} diff --git a/src/main/kotlin/fr/shikkanime/entities/Metric.kt b/src/main/kotlin/fr/shikkanime/entities/Metric.kt new file mode 100644 index 00000000..9b19ef20 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/entities/Metric.kt @@ -0,0 +1,53 @@ +package fr.shikkanime.entities + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Index +import jakarta.persistence.Table +import java.time.ZonedDateTime +import java.util.* + +@Entity +@Table( + name = "metric", + indexes = [ + Index(name = "idx_metrics_date", columnList = "date") + ] +) +data class Metric( + override val uuid: UUID? = null, + @Column(name = "cpu_load") + val cpuLoad: Double = 0.0, + @Column(name = "memory_usage") + val memoryUsage: Long = 0, + @Column(name = "database_size") + val databaseSize: Long = 0, + @Column(name = "date") + val date: ZonedDateTime = ZonedDateTime.now(), +) : ShikkEntity(uuid) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Metric) return false + + if (uuid != other.uuid) return false + if (cpuLoad != other.cpuLoad) return false + if (memoryUsage != other.memoryUsage) return false + if (databaseSize != other.databaseSize) return false + if (date != other.date) return false + + return true + } + + override fun hashCode(): Int { + var result = uuid?.hashCode() ?: 0 + result = 31 * result + cpuLoad.hashCode() + result = 31 * result + memoryUsage.hashCode() + result = 31 * result + databaseSize.hashCode() + result = 31 * result + date.hashCode() + return result + } + + override fun toString(): String { + return "Metric(uuid=$uuid, cpuLoad=$cpuLoad, memoryUsage=$memoryUsage, databaseSize=$databaseSize, date=$date)" + } +} diff --git a/src/main/kotlin/fr/shikkanime/entities/Platform.kt b/src/main/kotlin/fr/shikkanime/entities/Platform.kt new file mode 100644 index 00000000..220539f8 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/entities/Platform.kt @@ -0,0 +1,44 @@ +package fr.shikkanime.entities + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table +import java.util.* + +@Entity +@Table(name = "platform") +data class Platform( + override val uuid: UUID? = null, + @Column(nullable = false, unique = true) + val name: String? = null, + @Column(nullable = false) + val url: String? = null, + @Column(nullable = false) + val image: String? = null +) : ShikkEntity(uuid) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Platform + + if (uuid != other.uuid) return false + if (name != other.name) return false + if (url != other.url) return false + if (image != other.image) return false + + return true + } + + override fun hashCode(): Int { + var result = uuid?.hashCode() ?: 0 + result = 31 * result + (name?.hashCode() ?: 0) + result = 31 * result + (url?.hashCode() ?: 0) + result = 31 * result + (image?.hashCode() ?: 0) + return result + } + + override fun toString(): String { + return "Platform(uuid=$uuid, name=$name, url=$url, image=$image)" + } +} diff --git a/src/main/kotlin/fr/shikkanime/entities/ShikkEntity.kt b/src/main/kotlin/fr/shikkanime/entities/ShikkEntity.kt new file mode 100644 index 00000000..802de3cf --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/entities/ShikkEntity.kt @@ -0,0 +1,31 @@ +package fr.shikkanime.entities + +import jakarta.persistence.Id +import jakarta.persistence.MappedSuperclass +import org.hibernate.annotations.UuidGenerator +import java.io.Serializable +import java.util.* + +@MappedSuperclass +open class ShikkEntity( + @Id + @UuidGenerator + open val uuid: UUID? = null, +) : Serializable { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ShikkEntity) return false + + if (uuid != other.uuid) return false + + return true + } + + override fun hashCode(): Int { + return uuid?.hashCode() ?: 0 + } + + override fun toString(): String { + return "ShikkEntity(uuid=$uuid)" + } +} diff --git a/src/main/kotlin/fr/shikkanime/jobs/AbstractJob.kt b/src/main/kotlin/fr/shikkanime/jobs/AbstractJob.kt new file mode 100644 index 00000000..d12fd588 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/jobs/AbstractJob.kt @@ -0,0 +1,5 @@ +package fr.shikkanime.jobs + +abstract class AbstractJob { + abstract fun run() +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/jobs/FetchEpisodesJob.kt b/src/main/kotlin/fr/shikkanime/jobs/FetchEpisodesJob.kt new file mode 100644 index 00000000..dfe3e89e --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/jobs/FetchEpisodesJob.kt @@ -0,0 +1,24 @@ +package fr.shikkanime.jobs + +import fr.shikkanime.platforms.AbstractPlatform +import fr.shikkanime.platforms.AnimationDigitalNetworkPlatform +import fr.shikkanime.utils.Constant +import kotlinx.coroutines.runBlocking +import java.time.ZonedDateTime + +class FetchEpisodesJob : AbstractJob() { + private val platformClasses: List> = listOf(AnimationDigitalNetworkPlatform::class.java) + + override fun run() { + val zonedDateTime = ZonedDateTime.now().withNano(0) + + platformClasses.forEach { platformClass -> + val abstractPlatform = Constant.guice.getInstance(platformClass) + println("Fetching episodes for ${abstractPlatform.getPlatform().name}...") + + runBlocking { + abstractPlatform.fetchEpisodes(zonedDateTime) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/jobs/GCJob.kt b/src/main/kotlin/fr/shikkanime/jobs/GCJob.kt new file mode 100644 index 00000000..a79a5f3f --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/jobs/GCJob.kt @@ -0,0 +1,8 @@ +package fr.shikkanime.jobs + +class GCJob : AbstractJob() { + + override fun run() { + System.gc() + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/jobs/MetricJob.kt b/src/main/kotlin/fr/shikkanime/jobs/MetricJob.kt new file mode 100644 index 00000000..a892ff91 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/jobs/MetricJob.kt @@ -0,0 +1,41 @@ +package fr.shikkanime.jobs + +import com.google.inject.Inject +import fr.shikkanime.entities.Metric +import fr.shikkanime.services.MetricService +import fr.shikkanime.utils.Database +import java.lang.management.ManagementFactory +import javax.management.Attribute +import javax.management.ObjectName + +class MetricJob : AbstractJob() { + @Inject + private lateinit var metricService: MetricService + + @Inject + private lateinit var database: Database + + override fun run() { + metricService.save( + Metric( + cpuLoad = getProcessCPULoad(), + memoryUsage = getProcessMemoryUsage(), + databaseSize = database.getSize() + ) + ) + } + + private fun getProcessCPULoad(): Double { + val mbs = ManagementFactory.getPlatformMBeanServer() + val name = ObjectName.getInstance("java.lang:type=OperatingSystem") + val list = mbs.getAttributes(name, arrayOf("ProcessCpuLoad")) + if (list.isEmpty()) return Double.NaN + val value = (list.first() as Attribute).value as Double? ?: return Double.NaN + if (value < 0) return Double.NaN + return value + } + + private fun getProcessMemoryUsage(): Long { + return Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory() + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/modules/DefaultModule.kt b/src/main/kotlin/fr/shikkanime/modules/DefaultModule.kt new file mode 100644 index 00000000..7de3b090 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/modules/DefaultModule.kt @@ -0,0 +1,31 @@ +package fr.shikkanime.modules + +import com.google.inject.AbstractModule +import fr.shikkanime.platforms.AbstractPlatform +import fr.shikkanime.repositories.AbstractRepository +import fr.shikkanime.services.AbstractService +import fr.shikkanime.utils.Constant +import fr.shikkanime.utils.Database +import fr.shikkanime.utils.routes.Controller + +class DefaultModule : AbstractModule() { + override fun configure() { + bind(Database::class.java).asEagerSingleton() + + Constant.reflections.getSubTypesOf(AbstractRepository::class.java).forEach { + bind(it).asEagerSingleton() + } + + Constant.reflections.getSubTypesOf(AbstractService::class.java).forEach { + bind(it).asEagerSingleton() + } + + Constant.reflections.getSubTypesOf(AbstractPlatform::class.java).forEach { + bind(it).asEagerSingleton() + } + + Constant.reflections.getTypesAnnotatedWith(Controller::class.java).forEach { + bind(it).asEagerSingleton() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/platforms/AbstractPlatform.kt b/src/main/kotlin/fr/shikkanime/platforms/AbstractPlatform.kt new file mode 100644 index 00000000..4bc58dba --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/platforms/AbstractPlatform.kt @@ -0,0 +1,43 @@ +package fr.shikkanime.platforms + +import fr.shikkanime.entities.Country +import fr.shikkanime.entities.Platform +import fr.shikkanime.services.CountryService +import fr.shikkanime.services.PlatformService +import jakarta.inject.Inject +import java.time.ZonedDateTime + +abstract class AbstractPlatform { + data class Api( + val lastCheck: ZonedDateTime, + val content: Map = emptyMap(), + ) + + @Inject + protected lateinit var platformService: PlatformService + + @Inject + protected lateinit var countryService: CountryService + + private var apiCache: Api? = null + + abstract fun getPlatform(): Platform + protected abstract fun getCountries(): List + abstract suspend fun fetchApiContent(zonedDateTime: ZonedDateTime): Api + abstract suspend fun fetchEpisodes(zonedDateTime: ZonedDateTime): List + abstract fun reset() + + suspend fun getApiContent(country: Country, zonedDateTime: ZonedDateTime, delayHours: Long? = null): String { + if (apiCache == null) { + apiCache = fetchApiContent(zonedDateTime) + } + + val plusHours = apiCache!!.lastCheck.plusHours(delayHours ?: 0) + + if (zonedDateTime.isEqual(plusHours) || zonedDateTime.isAfter(plusHours)) { + apiCache = fetchApiContent(zonedDateTime) + } + + return apiCache!!.content[country]!! + } +} diff --git a/src/main/kotlin/fr/shikkanime/platforms/AnimationDigitalNetworkPlatform.kt b/src/main/kotlin/fr/shikkanime/platforms/AnimationDigitalNetworkPlatform.kt new file mode 100644 index 00000000..b8850fc0 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/platforms/AnimationDigitalNetworkPlatform.kt @@ -0,0 +1,93 @@ +package fr.shikkanime.platforms + +import com.google.gson.Gson +import com.google.gson.JsonObject +import fr.shikkanime.entities.Country +import fr.shikkanime.entities.Platform +import fr.shikkanime.utils.HttpRequest +import io.ktor.client.statement.* +import io.ktor.http.* +import java.time.ZonedDateTime + +class AnimationDigitalNetworkPlatform : AbstractPlatform() { + override fun getPlatform(): Platform { + val name = "Animation Digital Network" + + return platformService.findByName(name) ?: Platform( + name = name, + url = "https://animationdigitalnetwork.fr/", + image = "animation_digital_network.png", + ) + } + + override fun getCountries() = countryService.findAllByCode(listOf("FR")) + + override suspend fun fetchApiContent(zonedDateTime: ZonedDateTime): Api { + val map = mutableMapOf() + val countries = getCountries() + val toDateString = zonedDateTime.toLocalDate().toString() + + countries.forEach { country -> + val url = "https://gw.api.animationdigitalnetwork.${country.countryCode!!.lowercase()}/video/calendar?date=$toDateString" + val response = HttpRequest().get(url) + + if (response.status != HttpStatusCode.OK) { + return@forEach + } + + map[country] = response.bodyAsText() + } + + return Api(zonedDateTime, map) + } + + override suspend fun fetchEpisodes(zonedDateTime: ZonedDateTime): List { + val countries = getCountries() + + countries.forEach { country -> + val api = getApiContent(country, zonedDateTime, 1) + val array = Gson().fromJson(api, JsonObject::class.java).getAsJsonArray("videos") + + array.forEach { + convertEpisode(it.asJsonObject, zonedDateTime) + } + } + + return emptyList() + } + + override fun reset() { + TODO("Not yet implemented") + } + + private fun convertEpisode(jsonObject: JsonObject, zonedDateTime: ZonedDateTime) { + val show = jsonObject.getAsJsonObject("show") ?: return + + var animeName = show["shortTitle"]?.asString ?: show["title"]?.asString ?: return + animeName = animeName.replace(Regex("Saison \\d"), "").trim('-').trim() + + val animeImage = show["image2x"]?.asString ?: return + val animeDescription = show["summary"]?.asString ?: "" + val genres = show.getAsJsonArray("genres")?.toList() ?: emptyList() + + if (genres.isEmpty() || !genres.any { it.asString.startsWith("Animation ", true) }) { + return + } + + var isSimulcasted = show["simulcast"]?.asBoolean == true || + show["firstReleaseYear"]?.asString == zonedDateTime.toLocalDate().year.toString() + + val descriptionLowercase = animeDescription.lowercase() + + isSimulcasted = isSimulcasted || descriptionLowercase.startsWith("(premier épisode ") || + descriptionLowercase.startsWith("(diffusion des ") || + descriptionLowercase.startsWith("(diffusion du premier épisode") || + descriptionLowercase.startsWith("(diffusion de l'épisode 1 le") + + println("Anime: $animeName") + println("Image: $animeImage") + println("Description: $animeDescription") + println("Genres: ${genres.joinToString(", ")}") + println("Simulcasted: $isSimulcasted") + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/plugins/HTTP.kt b/src/main/kotlin/fr/shikkanime/plugins/HTTP.kt new file mode 100644 index 00000000..480cb7f5 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/plugins/HTTP.kt @@ -0,0 +1,45 @@ +package fr.shikkanime.plugins + +import freemarker.cache.ClassTemplateLoader +import io.ktor.http.* +import io.ktor.serialization.gson.* +import io.ktor.server.application.* +import io.ktor.server.freemarker.* +import io.ktor.server.plugins.compression.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.plugins.cors.routing.* +import io.ktor.server.plugins.statuspages.* +import io.ktor.server.response.* + +fun Application.configureHTTP() { + install(Compression) { + gzip { + priority = 1.0 + } + deflate { + priority = 10.0 + minimumSize(1024) // condition + } + } + install(CORS) { + allowMethod(HttpMethod.Options) + allowMethod(HttpMethod.Put) + allowMethod(HttpMethod.Delete) + allowMethod(HttpMethod.Patch) + allowHeader(HttpHeaders.Authorization) + allowHeader("MyCustomHeader") + anyHost() // @TODO: Don't do this in production if possible. Try to limit it. + } + install(StatusPages) { + exception { call, cause -> + call.respondText(text = "500: $cause", status = HttpStatusCode.InternalServerError) + } + } + install(ContentNegotiation) { + gson { + } + } + install(FreeMarker) { + templateLoader = ClassTemplateLoader(this::class.java.classLoader, "templates") + } +} diff --git a/src/main/kotlin/fr/shikkanime/plugins/Routing.kt b/src/main/kotlin/fr/shikkanime/plugins/Routing.kt new file mode 100644 index 00000000..e549075d --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/plugins/Routing.kt @@ -0,0 +1,198 @@ +package fr.shikkanime.plugins + +import fr.shikkanime.dtos.PlatformDto +import fr.shikkanime.entities.Link +import fr.shikkanime.utils.Constant +import fr.shikkanime.utils.routes.* +import fr.ziedelth.utils.routes.method.Delete +import fr.ziedelth.utils.routes.method.Get +import fr.ziedelth.utils.routes.method.Post +import fr.ziedelth.utils.routes.method.Put +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.auth.jwt.* +import io.ktor.server.freemarker.* +import io.ktor.server.http.content.* +import io.ktor.server.plugins.cachingheaders.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.util.* +import java.util.* +import kotlin.reflect.KFunction +import kotlin.reflect.full.declaredFunctions +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.hasAnnotation +import kotlin.reflect.jvm.isAccessible +import kotlin.reflect.jvm.javaType + +fun Application.configureRouting() { + routing { + staticResources("/assets", "assets") + createRoutes() + } +} + +private fun Routing.createRoutes() { + Constant.reflections.getTypesAnnotatedWith(Controller::class.java).forEach { controllerClass -> + val controller = Constant.guice.getInstance(controllerClass) + createControllerRoutes(controller) + } +} + +fun Routing.createControllerRoutes(controller: Any) { + val prefix = if (controller::class.hasAnnotation()) controller::class.findAnnotation()!!.value else "" + val kMethods = controller::class.declaredFunctions.filter { it.hasAnnotation() }.toMutableSet() + + route(prefix) { + kMethods.forEach { method -> + val path = method.findAnnotation()!!.value + + if (method.hasAnnotation()) { + val cached = method.findAnnotation()!!.maxAgeSeconds + + install(CachingHeaders) { + options { _, _ -> io.ktor.http.content.CachingOptions(CacheControl.MaxAge(maxAgeSeconds = cached)) } + } + } + + if (method.hasAnnotation()) { + authenticate("auth-jwt") { + handleMethods(method, prefix, controller, path) + } + } else { + handleMethods(method, prefix, controller, path) + } + } + } +} + +private fun Route.handleMethods( + method: KFunction<*>, + prefix: String, + controller: Any, + path: String, +) { + if (method.hasAnnotation()) { + get(path) { + handleRequest("GET", call, method, prefix, controller, path) + } + } + + if (method.hasAnnotation()) { + post(path) { + handleRequest("POST", call, method, prefix, controller, path) + } + } + + if (method.hasAnnotation()) { + put(path) { + handleRequest("PUT", call, method, prefix, controller, path) + } + } + + if (method.hasAnnotation()) { + delete(path) { + handleRequest("DELETE", call, method, prefix, controller, path) + } + } +} + +private suspend fun handleRequest( + httpMethod: String, + call: ApplicationCall, + method: KFunction<*>, + prefix: String, + controller: Any, + path: String +) { + val parameters = call.parameters.toMap() + val replacedPath = replacePathWithParameters("$prefix$path", parameters) + + println("$httpMethod $replacedPath") + + try { + when (val response = callMethodWithParameters(method, controller, call, parameters)) { + is MultipartResponse -> { + call.respondBytes(response.image, response.contentType) + } + + is TemplateResponse -> { + val map = response.model.toMutableMap() + + map["links"] = Link.list().map { link -> + link.active = replacedPath.startsWith(link.href) + link + } + + map["title"] = (if (!response.title.isNullOrBlank()) "${response.title} - " else "") + "Shikkanime" + + call.respond(FreeMarkerContent(response.template, map, "")) + } + + is RedirectResponse -> { + call.respondRedirect(response.path) + } + + else -> { + call.respond(response.status, response.data ?: "") + } + } + } catch (e: Exception) { + e.printStackTrace() + call.respond(HttpStatusCode.BadRequest) + } +} + +private fun replacePathWithParameters(path: String, parameters: Map>): String = + parameters.keys.fold(path) { acc, param -> + acc.replace("{$param}", parameters[param]!!.joinToString(", ")) + } + +private suspend fun callMethodWithParameters( + method: KFunction<*>, + controller: Any, + call: ApplicationCall, + parameters: Map> +): Response { + val methodParams = method.parameters.associateWith { kParameter -> + when { + kParameter.name.isNullOrBlank() -> { + controller + } + + method.hasAnnotation() && kParameter.hasAnnotation() -> { + val jwtPrincipal = call.principal() + UUID.fromString(jwtPrincipal!!.payload.getClaim("uuid").asString()) + } + + kParameter.hasAnnotation() -> { + when (kParameter.type.javaType) { + Array::class.java -> call.receive>() + PlatformDto::class.java -> call.receive() + else -> call.receive() + } + } + + kParameter.hasAnnotation() -> { + call.request.queryParameters[kParameter.name!!] + } + + else -> { + val value = parameters[kParameter.name]!!.first() + + val parsedValue: Any = when (kParameter.type.javaType) { + UUID::class.java -> UUID.fromString(value) + Int::class.java -> value.toInt() + else -> value + } + + parsedValue + } + } + } + + method.isAccessible = true + return method.callBy(methodParams) as Response +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/plugins/Security.kt b/src/main/kotlin/fr/shikkanime/plugins/Security.kt new file mode 100644 index 00000000..c8642425 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/plugins/Security.kt @@ -0,0 +1,30 @@ +package fr.shikkanime.plugins + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.auth.jwt.* + +fun Application.configureSecurity() { + // Please read the jwt property from the config file if you are using EngineMain + val jwtAudience = "jwt-audience" + val jwtDomain = "https://jwt-provider-domain/" + val jwtRealm = "ktor sample app" + val jwtSecret = "secret" + authentication { + jwt { + realm = jwtRealm + verifier( + JWT + .require(Algorithm.HMAC256(jwtSecret)) + .withAudience(jwtAudience) + .withIssuer(jwtDomain) + .build() + ) + validate { credential -> + if (credential.payload.audience.contains(jwtAudience)) JWTPrincipal(credential.payload) else null + } + } + } +} diff --git a/src/main/kotlin/fr/shikkanime/repositories/AbstractRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/AbstractRepository.kt new file mode 100644 index 00000000..7e876886 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/repositories/AbstractRepository.kt @@ -0,0 +1,62 @@ +package fr.shikkanime.repositories + +import com.google.inject.Inject +import fr.shikkanime.entities.ShikkEntity +import fr.shikkanime.utils.Database +import java.lang.reflect.ParameterizedType +import java.util.* + +abstract class AbstractRepository { + @Inject + protected lateinit var database: Database + + protected fun getEntityClass(): Class { + return (javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] as Class + } + + protected fun getEntityManager() = database.entityManager + + protected fun inTransaction(block: () -> T): T { + val transaction = getEntityManager().transaction + transaction.begin() + val result: T? + + try { + result = block() + transaction.commit() + } catch (e: Exception) { + transaction.rollback() + throw e + } + + return result + } + + fun findAll(): List { + return getEntityManager().createQuery("FROM ${getEntityClass().simpleName}", getEntityClass()).resultList + } + + fun find(uuid: UUID): E? { + return getEntityManager().find(getEntityClass(), uuid) + } + + fun save(entity: E): E { + return inTransaction { + getEntityManager().persist(entity) + entity + } + } + + fun update(entity: E): E { + return inTransaction { + getEntityManager().merge(entity) + entity + } + } + + fun delete(entity: E) { + inTransaction { + getEntityManager().remove(entity) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/repositories/CountryRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/CountryRepository.kt new file mode 100644 index 00000000..176097da --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/repositories/CountryRepository.kt @@ -0,0 +1,25 @@ +package fr.shikkanime.repositories + +import fr.shikkanime.entities.Country + +class CountryRepository : AbstractRepository() { + fun findByName(name: String): Country? { + return getEntityManager().createQuery("FROM Country WHERE name = :name", getEntityClass()) + .setParameter("name", name) + .resultList + .firstOrNull() + } + + fun findByCode(code: String): Country? { + return getEntityManager().createQuery("FROM Country WHERE countryCode = :code", getEntityClass()) + .setParameter("code", code) + .resultList + .firstOrNull() + } + + fun findAllByCode(codes: List): List { + return getEntityManager().createQuery("FROM Country WHERE countryCode IN :codes", getEntityClass()) + .setParameter("codes", codes) + .resultList + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/repositories/MetricRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/MetricRepository.kt new file mode 100644 index 00000000..f763096f --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/repositories/MetricRepository.kt @@ -0,0 +1,26 @@ +package fr.shikkanime.repositories + +import fr.shikkanime.entities.Metric +import java.time.ZonedDateTime + +class MetricRepository : AbstractRepository() { + fun findAllAfter(date: ZonedDateTime): List { + return getEntityManager().createQuery("FROM Metric WHERE date > :date", getEntityClass()) + .setParameter("date", date) + .resultList + } + + fun getAverageCpuLoad(from: ZonedDateTime, to: ZonedDateTime): Double? { + return getEntityManager().createQuery("SELECT AVG(cpuLoad) FROM Metric WHERE date BETWEEN :from AND :to", Double::class.java) + .setParameter("from", from) + .setParameter("to", to) + .singleResult + } + + fun getAverageMemoryUsage(from: ZonedDateTime, to: ZonedDateTime): Double? { + return getEntityManager().createQuery("SELECT AVG(memoryUsage) FROM Metric WHERE date BETWEEN :from AND :to", Double::class.java) + .setParameter("from", from) + .setParameter("to", to) + .singleResult + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/repositories/PlatformRepository.kt b/src/main/kotlin/fr/shikkanime/repositories/PlatformRepository.kt new file mode 100644 index 00000000..3cc6e58e --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/repositories/PlatformRepository.kt @@ -0,0 +1,12 @@ +package fr.shikkanime.repositories + +import fr.shikkanime.entities.Platform + +class PlatformRepository : AbstractRepository() { + fun findByName(name: String): Platform? { + return getEntityManager().createQuery("FROM Platform WHERE name = :name", getEntityClass()) + .setParameter("name", name) + .resultList + .firstOrNull() + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/services/AbstractService.kt b/src/main/kotlin/fr/shikkanime/services/AbstractService.kt new file mode 100644 index 00000000..8d628f11 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/services/AbstractService.kt @@ -0,0 +1,19 @@ +package fr.shikkanime.services + +import fr.shikkanime.entities.ShikkEntity +import fr.shikkanime.repositories.AbstractRepository +import java.util.* + +abstract class AbstractService> { + protected abstract fun getRepository(): R + + fun findAll() = getRepository().findAll() + + fun find(uuid: UUID) = getRepository().find(uuid) + + open fun save(entity: E) = getRepository().save(entity) + + fun update(entity: E) = getRepository().update(entity) + + fun delete(entity: E) = getRepository().delete(entity) +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/services/CountryService.kt b/src/main/kotlin/fr/shikkanime/services/CountryService.kt new file mode 100644 index 00000000..af81c92c --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/services/CountryService.kt @@ -0,0 +1,31 @@ +package fr.shikkanime.services + +import com.google.inject.Inject +import fr.shikkanime.entities.Country +import fr.shikkanime.repositories.CountryRepository +import fr.shikkanime.utils.MapCache + +class CountryService : AbstractService() { + @Inject + private lateinit var countryRepository: CountryRepository + + private val codesCache: MapCache, List> = MapCache { + countryRepository.findAllByCode(it) + } + + override fun getRepository(): CountryRepository { + return countryRepository + } + + fun findByName(name: String): Country? { + return countryRepository.findByName(name) + } + + fun findByCode(code: String): Country? { + return countryRepository.findByCode(code) + } + + fun findAllByCode(codes: List): List { + return codesCache[codes] + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/services/MetricService.kt b/src/main/kotlin/fr/shikkanime/services/MetricService.kt new file mode 100644 index 00000000..2d78df4a --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/services/MetricService.kt @@ -0,0 +1,43 @@ +package fr.shikkanime.services + +import com.google.inject.Inject +import fr.shikkanime.caches.FromToZonedDateTimeKeyCache +import fr.shikkanime.entities.Metric +import fr.shikkanime.repositories.MetricRepository +import fr.shikkanime.utils.MapCache +import java.time.Duration +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit + +class MetricService : AbstractService() { + @Inject + private lateinit var metricRepository: MetricRepository + + private val averageCpuLoadCache = MapCache( + Duration.of(1, ChronoUnit.HOURS) + ) { + metricRepository.getAverageCpuLoad(it.from, it.to) + } + + private val averageMemoryUsageCache = MapCache( + Duration.of(1, ChronoUnit.HOURS) + ) { + metricRepository.getAverageMemoryUsage(it.from, it.to) + } + + override fun getRepository(): MetricRepository { + return metricRepository + } + + fun findAllAfter(date: ZonedDateTime): List { + return metricRepository.findAllAfter(date) + } + + fun getAverageCpuLoad(from: ZonedDateTime, to: ZonedDateTime): Double? { + return averageCpuLoadCache[FromToZonedDateTimeKeyCache(from, to)] + } + + fun getAverageMemoryUsage(from: ZonedDateTime, to: ZonedDateTime): Double? { + return averageMemoryUsageCache[FromToZonedDateTimeKeyCache(from, to)] + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/services/PlatformService.kt b/src/main/kotlin/fr/shikkanime/services/PlatformService.kt new file mode 100644 index 00000000..904cc5c6 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/services/PlatformService.kt @@ -0,0 +1,18 @@ +package fr.shikkanime.services + +import com.google.inject.Inject +import fr.shikkanime.entities.Platform +import fr.shikkanime.repositories.PlatformRepository + +class PlatformService : AbstractService() { + @Inject + private lateinit var platformRepository: PlatformRepository + + override fun getRepository(): PlatformRepository { + return platformRepository + } + + fun findByName(name: String): Platform? { + return platformRepository.findByName(name) + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/utils/Constant.kt b/src/main/kotlin/fr/shikkanime/utils/Constant.kt new file mode 100644 index 00000000..9b24cb5c --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/utils/Constant.kt @@ -0,0 +1,10 @@ +package fr.shikkanime.utils + +import com.google.inject.Guice +import fr.shikkanime.modules.DefaultModule +import org.reflections.Reflections + +object Constant { + val reflections = Reflections("fr.shikkanime") + val guice = Guice.createInjector(DefaultModule()) +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/utils/Database.kt b/src/main/kotlin/fr/shikkanime/utils/Database.kt new file mode 100644 index 00000000..01ab9560 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/utils/Database.kt @@ -0,0 +1,55 @@ +package fr.shikkanime.utils + +import fr.shikkanime.entities.ShikkEntity +import jakarta.persistence.EntityManager +import liquibase.Contexts +import liquibase.LabelExpression +import liquibase.Liquibase +import liquibase.database.DatabaseFactory +import liquibase.database.jvm.JdbcConnection +import liquibase.resource.ClassLoaderResourceAccessor +import org.hibernate.cfg.Configuration +import java.io.File +import kotlin.system.exitProcess + +class Database { + val entityManager: EntityManager + + constructor(file: File) { + if (!file.exists()) { + throw Exception("File ${file.absolutePath} does not exist") + } + + val configuration = Configuration() + val entities = Constant.reflections.getSubTypesOf(ShikkEntity::class.java) + entities.forEach { configuration.addAnnotatedClass(it) } + configuration.configure(file) + val buildSessionFactory = configuration.buildSessionFactory() + entityManager = buildSessionFactory.createEntityManager() + + buildSessionFactory.openSession().doWork { + val database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(JdbcConnection(it)) + val liquibase = Liquibase("db/changelog/db.changelog-master.xml", ClassLoaderResourceAccessor(), database) + + try { + liquibase.update(Contexts(), LabelExpression()) + } catch (e: Exception) { + e.printStackTrace() + exitProcess(1) + } + } + + try { + buildSessionFactory.schemaManager.validateMappedObjects() + } catch (e: Exception) { + e.printStackTrace() + exitProcess(1) + } + } + + constructor() : this(File("hibernate.cfg.xml")) + + fun getSize(): Long { + return entityManager.createNativeQuery("SELECT pg_database_size('shikkanime')").singleResult as Long + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/utils/HttpRequest.kt b/src/main/kotlin/fr/shikkanime/utils/HttpRequest.kt new file mode 100644 index 00000000..bbc8c7f3 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/utils/HttpRequest.kt @@ -0,0 +1,19 @@ +package fr.shikkanime.utils + +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.request.* +import io.ktor.client.statement.* + + +class HttpRequest { + suspend fun get(url: String): HttpResponse { + val httpClient = HttpClient(CIO) + println("Making request to $url... (GET)") + val start = System.currentTimeMillis() + val response = httpClient.get(url) + httpClient.close() + println("Request to $url done in ${System.currentTimeMillis() - start}ms (GET)") + return response + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/utils/JobManager.kt b/src/main/kotlin/fr/shikkanime/utils/JobManager.kt new file mode 100644 index 00000000..e825b4cd --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/utils/JobManager.kt @@ -0,0 +1,35 @@ +package fr.shikkanime.utils + +import fr.shikkanime.jobs.AbstractJob +import org.quartz.* +import org.quartz.impl.StdSchedulerFactory + +object JobManager { + private val scheduler = StdSchedulerFactory().scheduler + + fun scheduleJob(cronExpression: String, jobClass: Class) { + val jobDetail = JobBuilder.newJob(JobExecutor::class.java) + .withIdentity(jobClass.name) + .build() + + val trigger = TriggerBuilder.newTrigger() + .withIdentity(jobClass.name) + .withSchedule(CronScheduleBuilder.cronSchedule(cronExpression)) + .build() + + scheduler.scheduleJob(jobDetail, trigger) + } + + fun start() { + scheduler.start() + } + + class JobExecutor : Job { + override fun execute(context: JobExecutionContext?) { + val jobName = context?.jobDetail?.key?.name ?: return + val `class` = Class.forName(jobName) ?: return + val job = Constant.guice.getInstance(`class`) as? AbstractJob ?: return + job.run() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/utils/MapCache.kt b/src/main/kotlin/fr/shikkanime/utils/MapCache.kt new file mode 100644 index 00000000..faf499b5 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/utils/MapCache.kt @@ -0,0 +1,45 @@ +package fr.shikkanime.utils + +import com.google.common.cache.CacheBuilder +import com.google.common.cache.CacheLoader +import com.google.common.cache.LoadingCache +import java.time.Duration + +class MapCache( + duration: Duration? = null, + private val classes: List> = listOf(), + private val block: (K) -> V, +) { + private val cache: LoadingCache + + init { + val builder = CacheBuilder.newBuilder() + + if (duration != null) { + builder.expireAfterWrite(duration) + } + + cache = builder + .build(object : CacheLoader() { + override fun load(key: K): V { + return block(key) + } + }) + + caches.add(this) + } + + operator fun get(key: K): V { + return cache.getUnchecked(key) + } + + companion object { + private val caches: MutableList> = mutableListOf() + + fun invalidate(vararg classes: Class<*>) { + classes.forEach { clazz -> + caches.filter { it.classes.contains(clazz) }.forEach { it.cache.invalidateAll() } + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/Authenticated.kt b/src/main/kotlin/fr/shikkanime/utils/routes/Authenticated.kt new file mode 100644 index 00000000..e7ef84f2 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/utils/routes/Authenticated.kt @@ -0,0 +1,5 @@ +package fr.shikkanime.utils.routes + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +annotation class Authenticated diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/BodyParam.kt b/src/main/kotlin/fr/shikkanime/utils/routes/BodyParam.kt new file mode 100644 index 00000000..0a5d2470 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/utils/routes/BodyParam.kt @@ -0,0 +1,5 @@ +package fr.shikkanime.utils.routes + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.VALUE_PARAMETER) +annotation class BodyParam diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/Cached.kt b/src/main/kotlin/fr/shikkanime/utils/routes/Cached.kt new file mode 100644 index 00000000..78da24ab --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/utils/routes/Cached.kt @@ -0,0 +1,5 @@ +package fr.shikkanime.utils.routes + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +annotation class Cached(val maxAgeSeconds: Int) diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/Controller.kt b/src/main/kotlin/fr/shikkanime/utils/routes/Controller.kt new file mode 100644 index 00000000..948b1edd --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/utils/routes/Controller.kt @@ -0,0 +1,5 @@ +package fr.shikkanime.utils.routes + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.CLASS) +annotation class Controller(val value: String = "") diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/JWTUser.kt b/src/main/kotlin/fr/shikkanime/utils/routes/JWTUser.kt new file mode 100644 index 00000000..3ff272c5 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/utils/routes/JWTUser.kt @@ -0,0 +1,5 @@ +package fr.shikkanime.utils.routes + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.VALUE_PARAMETER) +annotation class JWTUser diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/Path.kt b/src/main/kotlin/fr/shikkanime/utils/routes/Path.kt new file mode 100644 index 00000000..e10c921f --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/utils/routes/Path.kt @@ -0,0 +1,5 @@ +package fr.shikkanime.utils.routes + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +annotation class Path(val value: String = "") diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/QueryParam.kt b/src/main/kotlin/fr/shikkanime/utils/routes/QueryParam.kt new file mode 100644 index 00000000..52eea718 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/utils/routes/QueryParam.kt @@ -0,0 +1,5 @@ +package fr.shikkanime.utils.routes + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.VALUE_PARAMETER) +annotation class QueryParam diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/Response.kt b/src/main/kotlin/fr/shikkanime/utils/routes/Response.kt new file mode 100644 index 00000000..18720a06 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/utils/routes/Response.kt @@ -0,0 +1,29 @@ +package fr.shikkanime.utils.routes + +import io.ktor.http.* + +open class Response( + val status: HttpStatusCode = HttpStatusCode.OK, + val data: Any? = null, +) { + companion object { + fun ok(data: Any? = null): Response = Response(HttpStatusCode.OK, data) + fun created(data: Any?): Response = Response(HttpStatusCode.Created, data) + fun noContent(): Response = Response(HttpStatusCode.NoContent) + } +} + +open class MultipartResponse( + val image: ByteArray, + val contentType: ContentType, +) : Response() + +open class TemplateResponse( + val template: String, + val title: String? = null, + val model: MutableMap = mutableMapOf(), +) : Response() + +open class RedirectResponse( + val path: String, +) : Response(HttpStatusCode.Found) \ No newline at end of file diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/method/Delete.kt b/src/main/kotlin/fr/shikkanime/utils/routes/method/Delete.kt new file mode 100644 index 00000000..94fb1faa --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/utils/routes/method/Delete.kt @@ -0,0 +1,5 @@ +package fr.ziedelth.utils.routes.method + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +annotation class Delete diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/method/Get.kt b/src/main/kotlin/fr/shikkanime/utils/routes/method/Get.kt new file mode 100644 index 00000000..529f8165 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/utils/routes/method/Get.kt @@ -0,0 +1,5 @@ +package fr.ziedelth.utils.routes.method + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +annotation class Get diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/method/Post.kt b/src/main/kotlin/fr/shikkanime/utils/routes/method/Post.kt new file mode 100644 index 00000000..e91da823 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/utils/routes/method/Post.kt @@ -0,0 +1,5 @@ +package fr.ziedelth.utils.routes.method + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +annotation class Post diff --git a/src/main/kotlin/fr/shikkanime/utils/routes/method/Put.kt b/src/main/kotlin/fr/shikkanime/utils/routes/method/Put.kt new file mode 100644 index 00000000..4044a979 --- /dev/null +++ b/src/main/kotlin/fr/shikkanime/utils/routes/method/Put.kt @@ -0,0 +1,5 @@ +package fr.ziedelth.utils.routes.method + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +annotation class Put diff --git a/src/main/resources/assets/favicons/favicon-16x16.png b/src/main/resources/assets/favicons/favicon-16x16.png new file mode 100644 index 00000000..12ea9292 Binary files /dev/null and b/src/main/resources/assets/favicons/favicon-16x16.png differ diff --git a/src/main/resources/assets/favicons/favicon-32x32.png b/src/main/resources/assets/favicons/favicon-32x32.png new file mode 100644 index 00000000..87d56b63 Binary files /dev/null and b/src/main/resources/assets/favicons/favicon-32x32.png differ diff --git a/src/main/resources/assets/favicons/favicon.ico b/src/main/resources/assets/favicons/favicon.ico new file mode 100644 index 00000000..b37372b5 Binary files /dev/null and b/src/main/resources/assets/favicons/favicon.ico differ diff --git a/src/main/resources/assets/icon.jpg b/src/main/resources/assets/icon.jpg new file mode 100644 index 00000000..e67068af Binary files /dev/null and b/src/main/resources/assets/icon.jpg differ diff --git a/src/main/resources/assets/js/admin/home_chart.js b/src/main/resources/assets/js/admin/home_chart.js new file mode 100644 index 00000000..9ba94cf9 --- /dev/null +++ b/src/main/resources/assets/js/admin/home_chart.js @@ -0,0 +1,111 @@ +const cpuChartElement = document.getElementById('cpuLoadChart').getContext('2d'); +const memoryChartElement = document.getElementById('memoryUsageChart').getContext('2d'); + +const cpuChart = new Chart(cpuChartElement, { + type: 'line', + data: { + labels: [], + datasets: [ + { + label: '% CPU', + data: [], + backgroundColor: ['rgba(255, 99, 132, .2)'], + borderColor: ['rgba(255, 99, 132, 1)'], + tension: 0.1 + }, + { + label: '% CPU (average)', + data: [], + backgroundColor: ['rgba(54, 162, 235, .2)'], + borderColor: ['rgba(54, 162, 235, 1)'], + tension: 0.1 + } + ] + }, + options: { + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true + } + }, + elements: { + point: { + radius: 0 + } + }, + animation: { + duration: 0 + } + } +}); + +const memoryChart = new Chart(memoryChartElement, { + type: 'line', + data: { + labels: [], + datasets: [ + { + label: 'RAM in MB', + data: [], + backgroundColor: ['rgba(255, 99, 132, .2)'], + borderColor: ['rgba(255, 99, 132, 1)'], + tension: 0.1 + }, + { + label: 'RAM in MB (average)', + data: [], + backgroundColor: ['rgba(54, 162, 235, .2)'], + borderColor: ['rgba(54, 162, 235, 1)'], + tension: 0.1 + } + ] + }, + options: { + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true + } + }, + elements: { + point: { + radius: 0 + } + }, + animation: { + duration: 0 + } + } +}); + +document.addEventListener('DOMContentLoaded', async () => { + await setChartData(); + + setInterval(async () => { + await setChartData(); + }, 10000); +}); + +async function getMetrics() { + return await fetch('/api/metrics') + .then(response => response.json()) + .catch(error => console.error(error)); +} + +async function setChartData() { + const data = await getMetrics(); + + cpuChart.data.labels = data.map(metric => metric.date); + cpuChart.data.datasets[0].data = data.map(metric => metric.cpuLoad); + cpuChart.data.datasets[1].data = data.filter(metric => metric.averageCpuLoad !== "0").map(metric => metric.averageCpuLoad); + cpuChart.update(); + + memoryChart.data.labels = data.map(metric => metric.date); + memoryChart.data.datasets[0].data = data.map(metric => metric.memoryUsage); + memoryChart.data.datasets[1].data = data.filter(metric => metric.averageMemoryUsage !== "0").map(metric => metric.averageMemoryUsage); + memoryChart.update(); + + const lastMetric = data[data.length - 1]; + document.getElementById('dbSize').innerText = lastMetric.databaseSize + ' MB'; +} \ No newline at end of file diff --git a/src/main/resources/db/changelog/2023/12/01-changelog.xml b/src/main/resources/db/changelog/2023/12/01-changelog.xml new file mode 100644 index 00000000..9a738e7a --- /dev/null +++ b/src/main/resources/db/changelog/2023/12/01-changelog.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM country WHERE country_code = 'FR' + + + + + France + FR + + + \ No newline at end of file diff --git a/src/main/resources/db/changelog/db.changelog-master.xml b/src/main/resources/db/changelog/db.changelog-master.xml new file mode 100644 index 00000000..a1282c8f --- /dev/null +++ b/src/main/resources/db/changelog/db.changelog-master.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/src/main/resources/templates/_freemarker_implicit.ftl b/src/main/resources/templates/_freemarker_implicit.ftl new file mode 100644 index 00000000..3fa3f85d --- /dev/null +++ b/src/main/resources/templates/_freemarker_implicit.ftl @@ -0,0 +1,5 @@ +[#ftl] +[#-- @implicitly included --] +[#-- @ftlvariable name="title" type="java.lang.String" --] +[#-- @ftlvariable name="metrics" type="kotlin.collections.AbstractList" --] +[#-- @ftlvariable name="links" type="kotlin.collections.AbstractList" --] diff --git a/src/main/resources/templates/admin/_layout.ftl b/src/main/resources/templates/admin/_layout.ftl new file mode 100644 index 00000000..15fd11d6 --- /dev/null +++ b/src/main/resources/templates/admin/_layout.ftl @@ -0,0 +1,61 @@ +<#macro main> + + + + + + ${title} + + <#-- Favicons --> + + + + + + + + + +
+
+ + Icon + Shikkanime + +
+ +
+ +
+ <#nested> +
+
+ + + + + \ No newline at end of file diff --git a/src/main/resources/templates/admin/dashboard.ftl b/src/main/resources/templates/admin/dashboard.ftl new file mode 100644 index 00000000..25309e6e --- /dev/null +++ b/src/main/resources/templates/admin/dashboard.ftl @@ -0,0 +1,16 @@ +<#import "_layout.ftl" as layout /> +<@layout.main> +
Database current size:
+ +
+
+ +
+
+ +
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/admin/platforms.ftl b/src/main/resources/templates/admin/platforms.ftl new file mode 100644 index 00000000..c078bd74 --- /dev/null +++ b/src/main/resources/templates/admin/platforms.ftl @@ -0,0 +1,4 @@ +<#import "_layout.ftl" as layout /> +<@layout.main> + + \ No newline at end of file diff --git a/src/test/kotlin/fr/shikkanime/ApplicationTest.kt b/src/test/kotlin/fr/shikkanime/ApplicationTest.kt new file mode 100644 index 00000000..30094fe8 --- /dev/null +++ b/src/test/kotlin/fr/shikkanime/ApplicationTest.kt @@ -0,0 +1,22 @@ +package fr.shikkanime + +import fr.shikkanime.plugins.configureRouting +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.testing.* +import kotlin.test.Test +import kotlin.test.assertEquals + +class ApplicationTest { + @Test + fun testRoot() = testApplication { + application { + configureRouting() + } + client.get("/").apply { + assertEquals(HttpStatusCode.OK, status) + assertEquals("Hello World!", bodyAsText()) + } + } +}