From 514cd3f2275cd1373c150bfbafe6e7b6867b7c84 Mon Sep 17 00:00:00 2001 From: juliangojani Date: Sun, 31 May 2026 15:18:32 +0200 Subject: [PATCH 01/22] Init Kotlin Java Backend transition --- backend-kt/.gitignore | 45 ++++ backend-kt/.idea/.gitignore | 3 + backend-kt/.idea/gradle.xml | 16 ++ backend-kt/.idea/kotlinc.xml | 6 + backend-kt/.idea/misc.xml | 7 + backend-kt/.idea/vcs.xml | 6 + backend-kt/build.gradle.kts | 89 +++++++ backend-kt/docker-compose.yaml | 15 ++ backend-kt/gradle.properties | 1 + backend-kt/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 60756 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + backend-kt/gradlew | 234 ++++++++++++++++++ backend-kt/gradlew.bat | 89 +++++++ backend-kt/settings.gradle.kts | 7 + .../main/kotlin/de/interaapps/pastefy/Main.kt | 15 ++ .../de/interaapps/pastefy/auth/AuthFilter.kt | 20 ++ .../controller/pastes/PasteController.kt | 65 +++++ .../pastes/PasteMetaSSRController.kt | 13 + .../controller/pastes/PasteRawController.kt | 20 ++ .../pastes/PasteThumbnailController.kt | 16 ++ .../public/PublicPastesController.kt | 25 ++ .../controller/public/PublicUserController.kt | 15 ++ .../controller/public/TagsController.kt | 18 ++ .../interaapps/pastefy/dto/ActionResponse.kt | 5 + .../pastefy/dto/pastes/CreatePasteRequest.kt | 19 ++ .../pastefy/dto/pastes/CreatePasteResponse.kt | 6 + .../pastefy/dto/pastes/EditPasteRequest.kt | 16 ++ .../pastefy/dto/pastes/PasteResponse.kt | 33 +++ .../pastefy/dto/user/PublicUserDto.kt | 6 + .../de/interaapps/pastefy/entities/Paste.kt | 116 +++++++++ .../de/interaapps/pastefy/entities/User.kt | 106 ++++++++ .../de/interaapps/pastefy/enums/PasteType.kt | 5 + .../pastefy/enums/PasteVisibility.kt | 5 + .../interaapps/pastefy/enums/StorageType.kt | 5 + .../pastefy/repositories/PasteRepository.kt | 12 + .../repositories/PasteStarRepository.kt | 12 + .../pastefy/repositories/UserRepository.kt | 15 ++ .../pastefy/service/PasteService.kt | 95 +++++++ .../interaapps/pastefy/service/UserService.kt | 122 +++++++++ .../src/resources/application.properties | 22 ++ 40 files changed, 1331 insertions(+) create mode 100644 backend-kt/.gitignore create mode 100644 backend-kt/.idea/.gitignore create mode 100644 backend-kt/.idea/gradle.xml create mode 100644 backend-kt/.idea/kotlinc.xml create mode 100644 backend-kt/.idea/misc.xml create mode 100644 backend-kt/.idea/vcs.xml create mode 100644 backend-kt/build.gradle.kts create mode 100644 backend-kt/docker-compose.yaml create mode 100644 backend-kt/gradle.properties create mode 100644 backend-kt/gradle/wrapper/gradle-wrapper.jar create mode 100644 backend-kt/gradle/wrapper/gradle-wrapper.properties create mode 100755 backend-kt/gradlew create mode 100644 backend-kt/gradlew.bat create mode 100644 backend-kt/settings.gradle.kts create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/Main.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/AuthFilter.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteController.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteMetaSSRController.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteRawController.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteThumbnailController.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/PublicPastesController.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/PublicUserController.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/TagsController.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/ActionResponse.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/CreatePasteRequest.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/CreatePasteResponse.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/EditPasteRequest.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/PasteResponse.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/user/PublicUserDto.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/Paste.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/User.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/enums/PasteType.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/enums/PasteVisibility.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/enums/StorageType.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteRepository.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteStarRepository.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/UserRepository.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteService.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/service/UserService.kt create mode 100644 backend-kt/src/resources/application.properties diff --git a/backend-kt/.gitignore b/backend-kt/.gitignore new file mode 100644 index 0000000..b1dff0d --- /dev/null +++ b/backend-kt/.gitignore @@ -0,0 +1,45 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Kotlin ### +.kotlin + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/backend-kt/.idea/.gitignore b/backend-kt/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/backend-kt/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/backend-kt/.idea/gradle.xml b/backend-kt/.idea/gradle.xml new file mode 100644 index 0000000..14746e7 --- /dev/null +++ b/backend-kt/.idea/gradle.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/backend-kt/.idea/kotlinc.xml b/backend-kt/.idea/kotlinc.xml new file mode 100644 index 0000000..4cb7457 --- /dev/null +++ b/backend-kt/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/backend-kt/.idea/misc.xml b/backend-kt/.idea/misc.xml new file mode 100644 index 0000000..e4d905b --- /dev/null +++ b/backend-kt/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/backend-kt/.idea/vcs.xml b/backend-kt/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/backend-kt/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/backend-kt/build.gradle.kts b/backend-kt/build.gradle.kts new file mode 100644 index 0000000..6744b07 --- /dev/null +++ b/backend-kt/build.gradle.kts @@ -0,0 +1,89 @@ +plugins { + kotlin("jvm") version "1.9.25" + kotlin("plugin.spring") version "1.9.25" + id("org.springframework.boot") version "3.5.6" + id("io.spring.dependency-management") version "1.1.7" + kotlin("plugin.jpa") version "1.9.25" +} + +group = "de.interaapps.pastefy" +version = "1.0-SNAPSHOT" +description = "API for Ody" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +configurations { + compileOnly { + extendsFrom(configurations.annotationProcessor.get()) + } +} + +repositories { + mavenCentral() +} + +extra["springAiVersion"] = "1.0.3" + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-mail") + // implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("com.squareup.okhttp3:okhttp:4.11.0") + // implementation("org.springframework.ai:spring-ai-starter-model-openai") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13") + implementation("com.vladmihalcea:hibernate-types-60:2.21.1") + + implementation("co.elastic.clients:elasticsearch-java:9.0.0") + implementation("io.minio:minio:8.5.5") + + implementation("redis.clients:jedis:5.2.0") + implementation("org.apache.xmlgraphics:batik-code:1.16") + implementation("org.apache.xmlgraphics:batik-transcoder:1.17") + + developmentOnly("org.springframework.boot:spring-boot-devtools") + developmentOnly("org.springframework.boot:spring-boot-docker-compose") + runtimeOnly("com.mysql:mysql-connector-j") + developmentOnly("org.springframework.ai:spring-ai-spring-boot-docker-compose") + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-testcontainers") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("org.springframework.ai:spring-ai-spring-boot-testcontainers") + // testImplementation("org.springframework.security:spring-security-test") + testImplementation("org.testcontainers:junit-jupiter") + testImplementation("org.testcontainers:mysql") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + implementation(kotlin("stdlib-jdk8")) + + +} + +dependencyManagement { + imports { + mavenBom("org.springframework.ai:spring-ai-bom:${property("springAiVersion")}") + } +} + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +allOpen { + annotation("jakarta.persistence.Entity") + annotation("jakarta.persistence.MappedSuperclass") + annotation("jakarta.persistence.Embeddable") +} + +tasks.withType { + useJUnitPlatform() +} \ No newline at end of file diff --git a/backend-kt/docker-compose.yaml b/backend-kt/docker-compose.yaml new file mode 100644 index 0000000..93ae5fe --- /dev/null +++ b/backend-kt/docker-compose.yaml @@ -0,0 +1,15 @@ +services: + mysql: + image: 'mysql:latest' + environment: + - 'MYSQL_DATABASE=mydatabase' + - 'MYSQL_PASSWORD=secret' + - 'MYSQL_ROOT_PASSWORD=verysecret' + - 'MYSQL_USER=myuser' + ports: + - '3306:3306' + volumes: + - 'pastefy-test-mysql-data:/var/lib/mysql' +volumes: + pastefy-test-mysql-data: + driver: local \ No newline at end of file diff --git a/backend-kt/gradle.properties b/backend-kt/gradle.properties new file mode 100644 index 0000000..7fc6f1f --- /dev/null +++ b/backend-kt/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official diff --git a/backend-kt/gradle/wrapper/gradle-wrapper.jar b/backend-kt/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..249e5832f090a2944b7473328c07c9755baa3196 GIT binary patch literal 60756 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 '} + 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/backend-kt/gradlew.bat b/backend-kt/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/backend-kt/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/backend-kt/settings.gradle.kts b/backend-kt/settings.gradle.kts new file mode 100644 index 0000000..07dd984 --- /dev/null +++ b/backend-kt/settings.gradle.kts @@ -0,0 +1,7 @@ +pluginManagement { + plugins { + kotlin("jvm") version "2.0.21" + } +} + +rootProject.name = "backend-kt" \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/Main.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/Main.kt new file mode 100644 index 0000000..a13f87a --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/Main.kt @@ -0,0 +1,15 @@ +package de.interaapps.pastefy.de.interaapps.pastefy + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.cache.annotation.EnableCaching +import org.springframework.scheduling.annotation.EnableScheduling + +@SpringBootApplication +@EnableCaching +@EnableScheduling +class ApiApplication + +fun main(args: Array) { + runApplication(*args) +} \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/AuthFilter.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/AuthFilter.kt new file mode 100644 index 0000000..8b425b7 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/AuthFilter.kt @@ -0,0 +1,20 @@ +package de.interaapps.pastefy.auth + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +@Component +class AuthFilter() : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + + request.setAttribute("a", "b") + TODO("Not yet implemented") + } +} \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteController.kt new file mode 100644 index 0000000..e8be4e2 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteController.kt @@ -0,0 +1,65 @@ +package de.interaapps.pastefy.controller.pastes + +import de.interaapps.pastefy.dto.ActionResponse +import de.interaapps.pastefy.dto.pastes.CreatePasteRequest +import de.interaapps.pastefy.dto.pastes.CreatePasteResponse +import de.interaapps.pastefy.dto.pastes.EditPasteRequest +import de.interaapps.pastefy.dto.pastes.PasteResponse +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v2/paste") +class PasteController { + @PostMapping + fun createPaste( + @Valid @RequestBody request: CreatePasteRequest + ): PasteResponse { + TODO() + } + + @GetMapping + fun getPastes(@PathVariable id: String): List { + TODO() + } + + @GetMapping("/{id}") + fun getPaste( + @PathVariable id: String, + @RequestParam fromFrontend: Boolean = false + ): PasteResponse { + TODO() + } + + @PutMapping("/{id}") + fun putPaste( + @PathVariable id: String, + @Valid @RequestBody request: EditPasteRequest, + ): CreatePasteResponse { + TODO() + } + + @DeleteMapping("/{id}") + fun deletePaste(@PathVariable id: String): ActionResponse { + TODO() + } + + + @PutMapping("/{id}/star") + fun addStarToPaste(@PathVariable id: String): ActionResponse { + TODO() + } + + @DeleteMapping("/{id}/star") + fun removeStarFromPaste(@PathVariable id: String): ActionResponse { + TODO() + } +} \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteMetaSSRController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteMetaSSRController.kt new file mode 100644 index 0000000..7eeb21d --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteMetaSSRController.kt @@ -0,0 +1,13 @@ +package de.interaapps.pastefy.controller.pastes + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RestController + +@RestController +class PasteMetaSSRController { + @GetMapping("/{id}") + fun getPasteMetaSSR(@PathVariable id: String): String { + TODO() + } +} \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteRawController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteRawController.kt new file mode 100644 index 0000000..96c88c9 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteRawController.kt @@ -0,0 +1,20 @@ +package de.interaapps.pastefy.controller.pastes + +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RestController + +@RestController +class PasteRawController { + @GetMapping( + "/{id}/raw", + produces = [MediaType.TEXT_PLAIN_VALUE] + ) + fun getRaw( + @PathVariable id: String, + @PathVariable part: String? = null + ): String { + TODO() + } +} \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteThumbnailController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteThumbnailController.kt new file mode 100644 index 0000000..c7a93e7 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteThumbnailController.kt @@ -0,0 +1,16 @@ +package de.interaapps.pastefy.controller.pastes + +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RestController + +@RestController +class PasteThumbnailController { + @GetMapping("/{id}/thumbnail.png") + fun getThumbnail(@PathVariable id: String, response: HttpServletResponse) { + response.contentType = MediaType.IMAGE_PNG_VALUE + TODO() + } +} \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/PublicPastesController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/PublicPastesController.kt new file mode 100644 index 0000000..1492470 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/PublicPastesController.kt @@ -0,0 +1,25 @@ +package de.interaapps.pastefy.controller.public + +import de.interaapps.pastefy.dto.pastes.PasteResponse +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v2/public-pastes") +class PublicPastesController { + @GetMapping + fun getPublicPastes(): List { + TODO() + } + + @GetMapping("/trending") + fun getTrendingPastes(): List { + TODO() + } + + @GetMapping("/latest") + fun getLatestPastes(): List { + TODO() + } +} \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/PublicUserController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/PublicUserController.kt new file mode 100644 index 0000000..19c5391 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/PublicUserController.kt @@ -0,0 +1,15 @@ +package de.interaapps.pastefy.controller.public + +import de.interaapps.pastefy.dto.user.PublicUserDto +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v2/public/user") +class PublicUserController { + @GetMapping("/{name}") + fun getUser(): PublicUserDto { + TODO() + } +} \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/TagsController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/TagsController.kt new file mode 100644 index 0000000..4af73d5 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/TagsController.kt @@ -0,0 +1,18 @@ +package de.interaapps.pastefy.controller.public + +import de.interaapps.pastefy.dto.user.PublicUserDto +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v2/public/tags") +class TagsController { + @GetMapping + fun getTags( + @RequestParam("search") search: String? = null, + ): PublicUserDto { + TODO() + } +} \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/ActionResponse.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/ActionResponse.kt new file mode 100644 index 0000000..ee0019b --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/ActionResponse.kt @@ -0,0 +1,5 @@ +package de.interaapps.pastefy.dto + +data class ActionResponse( + val success: Boolean = false +) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/CreatePasteRequest.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/CreatePasteRequest.kt new file mode 100644 index 0000000..492fcf4 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/CreatePasteRequest.kt @@ -0,0 +1,19 @@ +package de.interaapps.pastefy.dto.pastes + +import de.interaapps.pastefy.enums.PasteType +import de.interaapps.pastefy.enums.PasteVisibility +import jakarta.validation.constraints.NotBlank + +data class CreatePasteRequest( + val title: String, + @field:NotBlank + val content: String, + + val encrypted: Boolean? = false, + val folder: String?, + val expireAt: String?, + val forkedFrom: String?, + val tags: List?, + val visibility: PasteVisibility? = PasteVisibility.UNLISTED, + val type: PasteType? = PasteType.PASTE, +) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/CreatePasteResponse.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/CreatePasteResponse.kt new file mode 100644 index 0000000..249c8dd --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/CreatePasteResponse.kt @@ -0,0 +1,6 @@ +package de.interaapps.pastefy.dto.pastes + +data class CreatePasteResponse( + val paste: PasteResponse, + val success: Boolean = true +) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/EditPasteRequest.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/EditPasteRequest.kt new file mode 100644 index 0000000..a1262c2 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/EditPasteRequest.kt @@ -0,0 +1,16 @@ +package de.interaapps.pastefy.dto.pastes + +import de.interaapps.pastefy.enums.PasteType +import de.interaapps.pastefy.enums.PasteVisibility + +data class EditPasteRequest( + val title: String, + val content: String, + + val encrypted: Boolean? = false, + val folder: String?, + val expireAt: String?, + val tags: List?, + val visibility: PasteVisibility? = PasteVisibility.UNLISTED, + val type: PasteType? = PasteType.PASTE, +) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/PasteResponse.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/PasteResponse.kt new file mode 100644 index 0000000..29e0375 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/PasteResponse.kt @@ -0,0 +1,33 @@ +package de.interaapps.pastefy.dto.pastes + +import de.interaapps.pastefy.dto.user.PublicUserDto +import de.interaapps.pastefy.enums.PasteType +import de.interaapps.pastefy.enums.PasteVisibility + +data class PasteResponse( + var id: String, + var content: String, + var title: String, + + var visibility: PasteVisibility, + var type: PasteType, + + var createdAt: String = "0000-00-00 00:00:00", + var encrypted: Boolean = false, + var rawUrl: String? = null, + + var folder: String? = null, + var userId: String? = null, + var forkedFrom: String? = null, + + var expireAt: String? = null, + var tags: MutableList? = null, + + var user: PublicUserDto? = null, + + var starred: Boolean? = null, + + var exists: Boolean = true, + var success: Boolean = true, +) { +} \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/user/PublicUserDto.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/user/PublicUserDto.kt new file mode 100644 index 0000000..0e395cd --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/user/PublicUserDto.kt @@ -0,0 +1,6 @@ +package de.interaapps.pastefy.dto.user + +data class PublicUserDto( + val name: String +) { +} \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/Paste.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/Paste.kt new file mode 100644 index 0000000..bbbb76d --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/Paste.kt @@ -0,0 +1,116 @@ +package de.interaapps.pastefy.model.database + +import de.interaapps.pastefy.enums.PasteType +import de.interaapps.pastefy.enums.PasteVisibility +import de.interaapps.pastefy.enums.StorageType +import jakarta.persistence.* +import java.time.Instant +import java.util.UUID + +@Entity +@Table(name = "pastes") +class Paste( + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Int? = null, + + @Column(length = 8, nullable = false, unique = true) + var key: String = randomKey(), + + @Column(length = 8) + var folder: String? = null, + + var expireAt: Instant? = null, + + @Column(nullable = false, updatable = false) + var createdAt: Instant? = null, + + @Column(nullable = false) + var updatedAt: Instant? = null, + + var title: String? = null, + + @Lob + @Column(columnDefinition = "MEDIUMTEXT") + var content: String? = null, + + @Column(length = 8) + var userId: String? = null, + + @Column(length = 8) + var forkedFrom: String? = null, + + @Column(nullable = false) + var encrypted: Boolean = false, + + @Enumerated(EnumType.STRING) + @Column(nullable = false, columnDefinition = "ENUM('PASTE','MULTI_PASTE')") + var type: PasteType = PasteType.PASTE, + + @Enumerated(EnumType.STRING) + @Column(nullable = false, columnDefinition = "ENUM('UNLISTED','PUBLIC','PRIVATE')") + var visibility: PasteVisibility = PasteVisibility.UNLISTED, + + @Enumerated(EnumType.STRING) + @Column(nullable = false, columnDefinition = "ENUM('S3','DATABASE','HTTP')") + var storageType: StorageType = StorageType.DATABASE, + + @Column(nullable = false) + var version: Int = 0, + + @Column(nullable = false) + var indexedInElastic: Boolean = false + +) { + + @Transient + var cachedContents: String? = null + + val rawContent: String? + get() = content + + val isPublic: Boolean + get() = visibility == PasteVisibility.PUBLIC + + val isPrivate: Boolean + get() = visibility == PasteVisibility.PRIVATE + + fun setDatabaseContent(content: String?) { + this.content = content + this.storageType = StorageType.DATABASE + } + + @PrePersist + fun prePersist() { + val now = Instant.now() + + if (createdAt == null) { + createdAt = now + } + + updatedAt = now + version += 1 + indexedInElastic = false + + if (key.isBlank()) { + key = randomKey() + } + } + + @PreUpdate + fun preUpdate() { + updatedAt = Instant.now() + version += 1 + indexedInElastic = false + } + + + companion object { + fun randomKey(): String = + UUID.randomUUID() + .toString() + .replace("-", "") + .substring(0, 8) + } +} \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/User.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/User.kt new file mode 100644 index 0000000..38ddf41 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/User.kt @@ -0,0 +1,106 @@ +package de.interaapps.pastefy.entities + +import de.interaapps.pastefy.auth.strategies.oauth2.OAuth2Provider +import de.interaapps.pastefy.auth.strategies.oauth2.providers.* +import jakarta.persistence.* +import java.time.Instant +import java.util.UUID + +@Entity +@Table(name = "users") +class User( + @Id + @Column(length = 8) + var id: String = randomId(), + + @Column + var name: String? = null, + + @Column(length = 33) + var uniqueName: String? = null, + + @Column(name = "email") + var email: String? = null, + + @Column + var avatar: String? = null, + + @Column(length = 455) + var authId: String? = null, + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + var authProvider: AuthenticationProvider? = null, + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + var type: Type = Type.USER, + + @Column(nullable = false, updatable = false) + var createdAt: Instant? = null, + + @Column(nullable = false) + var updatedAt: Instant? = null + +) { + + val isAdmin: Boolean + get() = type == Type.ADMIN + + fun roleCheck(): Boolean { + return type != Type.AWAITING_ACCESS && type != Type.BLOCKED + } + + @PrePersist + fun prePersist() { + val now = Instant.now() + + if (createdAt == null) { + createdAt = now + } + + updatedAt = now + } + + @PreUpdate + fun preUpdate() { + updatedAt = Instant.now() + } + + enum class AuthenticationProvider( + val oauth2ServiceClass: Class, + val providerName: String + ) { + INTERAAPPS(InteraAppsOAuth2Provider::class.java, "interaapps"), + GOOGLE(GoogleOAuth2Provider::class.java, "google"), + GITHUB(GitHubOAuth2Provider::class.java, "github"), + TWITCH(TwitchOAuth2Provider::class.java, "twitch"), + OIDC(CustomOAuth2Provider::class.java, "oidc"), + DISCORD(DiscordOAuth2Provider::class.java, "discord"); + + companion object { + fun getProviderByClass( + oauth2ServiceClass: Class + ): AuthenticationProvider? { + return entries.firstOrNull { + it.oauth2ServiceClass == oauth2ServiceClass + } + } + } + } + + enum class Type { + USER, + ADMIN, + BLOCKED, + AWAITING_ACCESS + } + + companion object { + fun randomId(): String = + UUID.randomUUID() + .toString() + .replace("-", "") + .substring(0, 8) + } +} \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/enums/PasteType.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/enums/PasteType.kt new file mode 100644 index 0000000..b9a8191 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/enums/PasteType.kt @@ -0,0 +1,5 @@ +package de.interaapps.pastefy.enums + +enum class PasteType { + PASTE, MULTI_PASTE +} \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/enums/PasteVisibility.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/enums/PasteVisibility.kt new file mode 100644 index 0000000..9ac730b --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/enums/PasteVisibility.kt @@ -0,0 +1,5 @@ +package de.interaapps.pastefy.enums + +enum class PasteVisibility { + PUBLIC, UNLISTED, PRIVATE +} \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/enums/StorageType.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/enums/StorageType.kt new file mode 100644 index 0000000..d39be4c --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/enums/StorageType.kt @@ -0,0 +1,5 @@ +package de.interaapps.pastefy.enums + +enum class StorageType { + DATABASE, S3, HTTP +} \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteRepository.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteRepository.kt new file mode 100644 index 0000000..4bd5950 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteRepository.kt @@ -0,0 +1,12 @@ +package de.interaapps.pastefy.repositories + +import de.interaapps.pastefy.model.database.Paste +import org.springframework.data.jpa.repository.JpaRepository + +interface PasteRepository : JpaRepository { + fun findByKey(key: String): Paste? + + fun existsByKey(key: String): Boolean + + fun findAllByUserId(userId: String): List +} \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteStarRepository.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteStarRepository.kt new file mode 100644 index 0000000..c85d2fd --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteStarRepository.kt @@ -0,0 +1,12 @@ +package de.interaapps.pastefy.repositories + +import org.springframework.data.jpa.repository.JpaRepository + +interface PasteStarRepository : JpaRepository { + + fun existsByPasteAndUserId(paste: String, userId: String): Boolean + + fun deleteByPasteAndUserId(paste: String, userId: String) + + fun countByPaste(paste: String): Int +} \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/UserRepository.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/UserRepository.kt new file mode 100644 index 0000000..ccd6fb3 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/UserRepository.kt @@ -0,0 +1,15 @@ +package de.interaapps.pastefy.repositories + +import de.interaapps.pastefy.entities.User +import org.springframework.data.jpa.repository.JpaRepository + +interface UserRepository : JpaRepository { + + fun findByUniqueName(uniqueName: String): User? + + fun findByAuthIdAndAuthProvider( + authId: String, + authProvider: User.AuthenticationProvider + ): User? + +} \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteService.kt new file mode 100644 index 0000000..7447f45 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteService.kt @@ -0,0 +1,95 @@ +package de.interaapps.pastefy.service + +import de.interaapps.pastefy.enums.StorageType +import de.interaapps.pastefy.model.database.Paste +import de.interaapps.pastefy.repositories.PasteRepository +import org.springframework.data.crossstore.ChangeSetPersister.NotFoundException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class PasteService( + private val pasteRepository: PasteRepository, + // private val minioPasteService: MinioPasteService, + // private val elasticPasteService: ElasticPasteService, + // private val pasteContentCache: PasteContentCache, + // private val pasteAccessCache: PasteAccessCache, +) { + + fun get(pasteKey: String): Paste? { + return pasteRepository.findByKey(pasteKey) + } + + fun getAccessiblePasteOrFail(pasteKey: String, user: User?): Paste { + val paste = pasteRepository.findByKey(pasteKey) + ?: throw NotFoundException() + + if (paste.isPrivate && (user == null || user.id != paste.userId)) { + throw PastePrivateException() + } + + return paste + } + + fun getContent(paste: Paste, withCache: Boolean = true): String? { + if (withCache) { + // pasteAccessCache.increaseAccessCount(paste) + + // if (pasteAccessCache.getAccessCount(paste) > 10) { + // pasteContentCache.setCachedContent(paste) + // } + + // pasteContentCache.getCachedContent(paste)?.let { + // return it + // } + } + + if (paste.storageType == StorageType.S3) { + if (paste.cachedContents == null) { + // paste.cachedContents = minioPasteService.getText(paste) + } + + return paste.cachedContents + } + + return paste.content + } + + @Transactional + fun save(paste: Paste): Paste { + val saved = pasteRepository.save(paste) + + // pasteContentCache.deleteCachedContent(saved) + + // val threshold = config.getInt("minio.pastesize.threshold", -1) + // val bigEnoughForS3 = saved.content != null && saved.content!!.length > threshold + + // if (minioEnabled && (saved.storageType == Paste.StorageType.S3 || bigEnoughForS3)) { + // minioPasteService.store(saved) + // } + + // if (elasticsearchEnabled) { + // elasticPasteService.store(saved) + // } + + return saved + } + + @Transactional + fun delete(paste: Paste) { + // pasteTagRepository.deleteByPaste(paste.key) + // publicPasteEngagementRepository.deleteByPasteId(paste.id!!) + + if (paste.storageType == StorageType.S3) { + // minioPasteService.delete(paste) + } + + if (paste.indexedInElastic) { + // elasticPasteService.delete(paste) + } + + pasteRepository.delete(paste) + + // pasteContentCache.deleteCachedContent(paste) + } +} \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/UserService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/UserService.kt new file mode 100644 index 0000000..4c73765 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/UserService.kt @@ -0,0 +1,122 @@ +package de.interaapps.pastefy.service + +import de.interaapps.pastefy.entities.User +import de.interaapps.pastefy.model.database.* +import de.interaapps.pastefy.repositories.PasteRepository +import de.interaapps.pastefy.repositories.PasteStarRepository +import de.interaapps.pastefy.repositories.UserRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class UserService( + private val userRepository: UserRepository, + private val pasteRepository: PasteRepository, + private val folderRepository: FolderRepository, + private val pasteStarRepository: PasteStarRepository, + private val authKeyRepository: AuthKeyRepository, + private val notificationRepository: NotificationRepository, + private val sharedPasteRepository: SharedPasteRepository, + // private val elasticStarsService: ElasticStarsService, + // private val publicPasteEngagementService: PublicPasteEngagementService, +) { + + fun get(id: String): User? { + return userRepository.findById(id).orElse(null) + } + + fun getByName(name: String): User? { + return userRepository.findByUniqueName(name) + } + + fun getPastes(user: User): List { + return pasteRepository.findAllByUserId(user.id) + } + + fun getFolders(user: User): List { + return folderRepository.findAllByUserId(user.id) + } + + fun getFolderTree( + user: User, + fetchChildren: Boolean, + fetchSubChildren: Boolean, + fetchPastes: Boolean + ): List { + return folderRepository.findAllByUserIdAndParentIsNull(user.id) + .map { folder -> + FolderResponse( + folder, + fetchChildren, + fetchSubChildren, + fetchPastes, + true + ) + } + } + + fun getFolderWithChildren(user: User): List { + return folderRepository.findAllByUserIdAndParentIsNull(user.id) + .map { folder -> + FolderResponse( + folder, + true, + true, + false, + true + ) + } + } + + @Transactional + fun sendNotification(user: User, notification: Notification): Notification { + notification.userId = user.id + return notificationRepository.save(notification) + } + + fun hasStarred(user: User, paste: Paste): Boolean { + return pasteStarRepository.existsByPasteAndUserId(paste.key, user.id) + } + + @Transactional + fun star(user: User, paste: Paste) { + if (hasStarred(user, paste)) { + return + } + + val pasteStar = PasteStar( + paste = paste.key, + userId = user.id + ) + + pasteStarRepository.save(pasteStar) + + // async { + // elasticStarsService.addStarCount(paste, user) + // publicPasteEngagementService.addInterestFromPaste(paste, 20) + // } + } + + @Transactional + fun unstar(user: User, paste: Paste) { + pasteStarRepository.deleteByPasteAndUserId(paste.key, user.id) + + // async { + // elasticStarsService.removeStarCount(paste, user) + // publicPasteEngagementService.addInterestFromPaste(paste, -20) + // } + } + + @Transactional + fun delete(user: User) { + pasteRepository.findAllByUserId(user.id) + .forEach { pasteRepository.delete(it) } + + folderRepository.deleteByUserId(user.id) + authKeyRepository.deleteByUserId(user.id) + notificationRepository.deleteByUserId(user.id) + sharedPasteRepository.deleteByTargetIdOrUserId(user.id, user.id) + + userRepository.delete(user) + } +} \ No newline at end of file diff --git a/backend-kt/src/resources/application.properties b/backend-kt/src/resources/application.properties new file mode 100644 index 0000000..5eb877b --- /dev/null +++ b/backend-kt/src/resources/application.properties @@ -0,0 +1,22 @@ +spring.application.name=api + +spring.devtools.restart.enabled=true + +spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/mydatabase?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME:myuser} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:secret} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +spring.jpa.hibernate.ddl-auto=${SPRING_JPA_HIBERNATE_DDL_AUTO:update} +spring.jpa.show-sql=${SPRING_JPA_SHOW_SQL:false} +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect + +spring.jackson.property-naming-strategy=SNAKE_CASE + +# Server port (optional) +server.port=8080 + +# Swagger/OpenAPI (if using springdoc) +springdoc.api-docs.path=/v3/api-docs +springdoc.swagger-ui.path=/swagger-ui.html \ No newline at end of file From deba505d1f47e7bb5ecc197e206fe60d408f3a78 Mon Sep 17 00:00:00 2001 From: juliangojani Date: Sun, 31 May 2026 23:00:36 +0200 Subject: [PATCH 02/22] Gradle.xml --- backend-kt/.idea/gradle.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/backend-kt/.idea/gradle.xml b/backend-kt/.idea/gradle.xml index 14746e7..2a65317 100644 --- a/backend-kt/.idea/gradle.xml +++ b/backend-kt/.idea/gradle.xml @@ -1,5 +1,6 @@ +

p_r)^;F^SB&oAwAUvat>AaH*WM-R9KsutQD*d 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%0D_|qoNNhGUnvG#&**I3n#=L$~UCJ(F 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 Z%h#_b*~^!E-WSq^XhsVQb2wuQ{{qd0QA+>- literal 0 HcmV?d00001 diff --git a/backend-kt/src/main/resources/fonts/PlusJakartaSans.ttf b/backend-kt/src/main/resources/fonts/PlusJakartaSans.ttf new file mode 100644 index 0000000000000000000000000000000000000000..bdd498505bc2000b995f4978fa539968a0ed5fbe GIT binary patch literal 176144 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

MP^Y_Q)CoB-M7>5> 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 WV=LeDtk&Ogd|9oJ!8PA8_WuIv4y0QE literal 0 HcmV?d00001 diff --git a/backend-kt/src/main/resources/static/index.html b/backend-kt/src/main/resources/static/index.html new file mode 100644 index 0000000..47dd132 --- /dev/null +++ b/backend-kt/src/main/resources/static/index.html @@ -0,0 +1,52 @@ + + + + + + + Pastefy + + + + + + + + + + + + + + + + + + + + + + + + + +

+ + + + + diff --git a/backend-kt/src/main/resources/templates/thumbnail-svg.svg b/backend-kt/src/main/resources/templates/thumbnail-svg.svg new file mode 100644 index 0000000..74dd869 --- /dev/null +++ b/backend-kt/src/main/resources/templates/thumbnail-svg.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + %title% + + 1 + %content_1% + + 2 + %content_2% + + 3 + %content_3% + + 4 + %content_4% + + 5 + %content_5% + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend-kt/src/test/http/controller-parity.http b/backend-kt/src/test/http/controller-parity.http new file mode 100644 index 0000000..3006f1d --- /dev/null +++ b/backend-kt/src/test/http/controller-parity.http @@ -0,0 +1,36 @@ +@host = http://localhost:8080 +@authKey = replace-me +@pasteId = replace-me +@folderId = replace-me +@tag = kotlin + +### Stats +GET {{host}}/api/v2/app/stats +Accept: application/json + +### Folder list +GET {{host}}/api/v2/folder?page=1&page_limit=10 +Accept: application/json +X-Auth-Key: {{authKey}} + +### Folder detail +GET {{host}}/api/v2/folder/{{folderId}} +Accept: application/json +X-Auth-Key: {{authKey}} + +### Paste comments +GET {{host}}/api/v2/paste/{{pasteId}}/comments?page=1&page_limit=10 +Accept: application/json +X-Auth-Key: {{authKey}} + +### Raw paste +GET {{host}}/{{pasteId}}/raw +Accept: text/plain + +### Thumbnail +GET {{host}}/{{pasteId}}/thumbnail.png +Accept: image/png + +### Tag SEO metadata +GET {{host}}/tags/{{tag}} +Accept: text/html diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/controller/ControllerHttpTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/controller/ControllerHttpTest.kt new file mode 100644 index 0000000..0167ce9 --- /dev/null +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/controller/ControllerHttpTest.kt @@ -0,0 +1,138 @@ +package de.interaapps.pastefy.controller + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import de.interaapps.pastefy.auth.annotations.CurrentAuthKey +import de.interaapps.pastefy.auth.annotations.CurrentUser +import de.interaapps.pastefy.config.PastefyProperties +import de.interaapps.pastefy.controller.public.PublicUserController +import de.interaapps.pastefy.controller.pastes.PasteRawController +import de.interaapps.pastefy.controller.stats.StatsController +import de.interaapps.pastefy.dto.app.StatsResponse +import de.interaapps.pastefy.entities.AuthKey +import de.interaapps.pastefy.entities.User +import de.interaapps.pastefy.infrastructure.ai.PasteAI +import de.interaapps.pastefy.infrastructure.analytics.AnalyticsService +import de.interaapps.pastefy.service.PasteService +import de.interaapps.pastefy.service.PublicPasteEngagementService +import de.interaapps.pastefy.service.StatsService +import de.interaapps.pastefy.service.UserService +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.springframework.beans.factory.support.StaticListableBeanFactory +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter +import org.springframework.http.converter.StringHttpMessageConverter +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer +import org.springframework.core.MethodParameter +import org.springframework.web.bind.support.WebDataBinderFactory + +class ControllerHttpTest { + private val objectMapper = ObjectMapper().setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + + @Test + fun `raw endpoint returns legacy plain text 404`() { + val pasteService = mock(PasteService::class.java) + `when`(pasteService.get("missing")).thenReturn(null) + val mvc = mockMvc( + PasteRawController( + pasteService, + mock(PublicPasteEngagementService::class.java), + StaticListableBeanFactory().getBeanProvider(AnalyticsService::class.java), + objectMapper, + ), + ) + + mvc.get("/missing/raw") + .andExpect { + status { isNotFound() } + content { contentType("text/plain;charset=UTF-8") } + content { string("404 - Paste not found") } + } + } + + @Test + fun `stats endpoint serializes snake case response`() { + val stats = mock(StatsService::class.java) + `when`(stats.get()).thenReturn(StatsResponse(createdPastes = 12, loggedInPastes = 5)) + val mvc = mockMvc(StatsController(PastefyProperties(publicStats = true), stats)) + + mvc.get("/api/v2/app/stats") + .andExpect { + status { isOk() } + jsonPath("$.created_pastes") { value(12) } + jsonPath("$.logged_in_pastes") { value(5) } + jsonPath("$.createdPastes") { doesNotExist() } + } + } + + @Test + fun `app info serializes configuration using snake case`() { + val beans = StaticListableBeanFactory() + val properties = PastefyProperties( + customName = "Pastefy", + encryptionDefault = true, + publicPastesEnabled = true, + ) + val mvc = mockMvc( + AppController( + properties, + beans.getBeanProvider(PasteAI::class.java), + beans.getBeanProvider(AnalyticsService::class.java), + ), + ) + + mvc.get("/api/v2/app/info") + .andExpect { + status { isOk() } + jsonPath("$.custom_name") { value("Pastefy") } + jsonPath("$.encryption_is_default") { value(true) } + jsonPath("$.public_pastes_enabled") { value(true) } + jsonPath("$.ai_enabled") { value(false) } + } + } + + @Test + fun `public user route returns legacy public shape`() { + val users = mock(UserService::class.java) + `when`(users.getByName("julian")).thenReturn( + User( + id = "user-01", + uniqueName = "julian", + name = "Julian", + ), + ) + val mvc = mockMvc(PublicUserController(users)) + + mvc.get("/api/v2/public/user/julian") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("julian") } + jsonPath("$.display_name") { value("Julian") } + } + } + + private fun mockMvc(controller: Any): MockMvc = + MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(NullableAuthArgumentResolver()) + .setMessageConverters(StringHttpMessageConverter(), MappingJackson2HttpMessageConverter(objectMapper)) + .build() +} + +private class NullableAuthArgumentResolver : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean = + parameter.hasParameterAnnotation(CurrentUser::class.java) && parameter.parameterType == User::class.java || + parameter.hasParameterAnnotation(CurrentAuthKey::class.java) && parameter.parameterType == AuthKey::class.java + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): Any? = null +} diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/repositories/PasteCommentRepositoryIntegrationTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/repositories/PasteCommentRepositoryIntegrationTest.kt new file mode 100644 index 0000000..55b2e91 --- /dev/null +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/repositories/PasteCommentRepositoryIntegrationTest.kt @@ -0,0 +1,97 @@ +package de.interaapps.pastefy.repositories + +import de.interaapps.pastefy.entities.AuthKey +import de.interaapps.pastefy.entities.Paste +import de.interaapps.pastefy.entities.PasteComment +import de.interaapps.pastefy.entities.PublicPasteEngagement +import de.interaapps.pastefy.enums.PasteVisibility +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.data.domain.PageRequest +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.testcontainers.containers.MySQLContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import java.time.Instant + +@DataJpaTest( + properties = [ + "spring.jpa.hibernate.ddl-auto=create", + "spring.liquibase.enabled=false", + ], +) +@Testcontainers(disabledWithoutDocker = true) +class PasteCommentRepositoryIntegrationTest( + @Autowired private val comments: PasteCommentRepository, + @Autowired private val pastes: PasteRepository, + @Autowired private val engagements: PublicPasteEngagementRepository, + @Autowired private val authKeys: AuthKeyRepository, +) { + @Test + fun `loads top level comments newest first and replies oldest first`() { + val root = comments.save(comment("paste-01", "root", createdAt = Instant.parse("2026-01-01T00:00:00Z"))) + comments.save(comment("paste-01", "reply-2", parentId = root.id, createdAt = Instant.parse("2026-01-03T00:00:00Z"))) + comments.save(comment("paste-01", "reply-1", parentId = root.id, createdAt = Instant.parse("2026-01-02T00:00:00Z"))) + + assertEquals( + listOf("root"), + comments.findAllByPasteAndParentIdIsNullOrderByCreatedAtDesc("paste-01", PageRequest.of(0, 10)).map { it.content }, + ) + assertEquals( + listOf("reply-1", "reply-2"), + comments.findAllByParentIdOrderByCreatedAtAsc(requireNotNull(root.id)).map { it.content }, + ) + } + + @Test + fun `orders trending public pastes by engagement score`() { + val low = pastes.save( + Paste(key = "trend001", title = "low", visibility = PasteVisibility.PUBLIC).apply { + setDatabaseContent("low") + }, + ) + val high = pastes.save( + Paste(key = "trend002", title = "high", visibility = PasteVisibility.PUBLIC).apply { + setDatabaseContent("high") + }, + ) + engagements.save(PublicPasteEngagement(pasteId = requireNotNull(low.id), score = 1)) + engagements.save(PublicPasteEngagement(pasteId = requireNotNull(high.id), score = 9)) + + assertEquals( + listOf("trend002", "trend001"), + pastes.findTrending(null, PageRequest.of(0, 10)).map(Paste::key), + ) + } + + @Test + fun `loads api keys by user and type`() { + authKeys.save(AuthKey(key = "api-key", userId = "user-001", type = AuthKey.Type.API)) + + assertEquals( + listOf("api-key"), + authKeys.findAllByUserIdAndType("user-001", AuthKey.Type.API).map(AuthKey::key), + ) + } + + private fun comment(paste: String, content: String, parentId: Int? = null, createdAt: Instant) = + PasteComment(paste = paste, userId = "user-001", content = content, parentId = parentId, createdAt = createdAt) + + companion object { + @Container + @JvmStatic + val mysql = MySQLContainer("mysql:8.4") + + @DynamicPropertySource + @JvmStatic + fun datasource(registry: DynamicPropertyRegistry) { + registry.add("spring.datasource.url", mysql::getJdbcUrl) + registry.add("spring.datasource.username", mysql::getUsername) + registry.add("spring.datasource.password", mysql::getPassword) + registry.add("spring.datasource.driver-class-name", mysql::getDriverClassName) + } + } +} diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/FolderServiceTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/FolderServiceTest.kt new file mode 100644 index 0000000..e66bef0 --- /dev/null +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/FolderServiceTest.kt @@ -0,0 +1,27 @@ +package de.interaapps.pastefy.service + +import de.interaapps.pastefy.config.PastefyProperties +import de.interaapps.pastefy.exceptions.PermissionsDeniedException +import de.interaapps.pastefy.repositories.FolderRepository +import de.interaapps.pastefy.repositories.PasteRepository +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.Mockito.mock +import org.springframework.mock.web.MockHttpServletRequest + +class FolderServiceTest { + @Test + fun `rejects anonymous folder listings unless public listing is enabled`() { + val service = FolderService( + mock(FolderRepository::class.java), + mock(PasteRepository::class.java), + mock(PasteService::class.java), + mock(PasteResponseMapper::class.java), + PastefyProperties(listPastes = false), + ) + + assertThrows { + service.list(MockHttpServletRequest(), null) + } + } +} diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/InteraAppsExternalAccessServiceTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/InteraAppsExternalAccessServiceTest.kt new file mode 100644 index 0000000..bbf42c0 --- /dev/null +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/InteraAppsExternalAccessServiceTest.kt @@ -0,0 +1,45 @@ +package de.interaapps.pastefy.service + +import de.interaapps.pastefy.auth.oauth.OAuth2Provider +import de.interaapps.pastefy.auth.oauth.OAuth2ProviderRegistry +import de.interaapps.pastefy.config.PastefyProperties +import de.interaapps.pastefy.dto.auth.InteraAppsExternalAccessRequest +import de.interaapps.pastefy.entities.AuthKey +import de.interaapps.pastefy.entities.User +import de.interaapps.pastefy.repositories.AuthKeyRepository +import de.interaapps.pastefy.repositories.UserRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` + +class InteraAppsExternalAccessServiceTest { + @Test + fun `creates access token with requested app scopes`() { + val properties = PastefyProperties().apply { + oauth.interaapps.clientId = "app-id" + oauth.interaapps.clientSecret = "app-secret" + } + val providers = mock(OAuth2ProviderRegistry::class.java) + val users = mock(UserRepository::class.java) + val keys = mock(AuthKeyRepository::class.java) + `when`(providers.get("interaapps")).thenReturn(mock(OAuth2Provider::class.java)) + `when`(users.findByAuthIdAndAuthProvider("external-user", User.AuthenticationProvider.INTERAAPPS)) + .thenReturn(User(id = "user-123", authId = "external-user", authProvider = User.AuthenticationProvider.INTERAAPPS)) + `when`(keys.save(any(AuthKey::class.java))).thenAnswer { invocation -> + invocation.getArgument(0).apply { key = "issued-key" } + } + + val key = InteraAppsExternalAccessService(properties, providers, users, keys).issue( + InteraAppsExternalAccessRequest( + appId = "app-id", + appSecret = "app-secret", + appScopeList = listOf("pastes:read", "comments:create"), + userId = "external-user", + ), + ) + + assertEquals("issued-key", key) + } +} diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/PasteCommentServiceTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/PasteCommentServiceTest.kt new file mode 100644 index 0000000..1d53171 --- /dev/null +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/PasteCommentServiceTest.kt @@ -0,0 +1,44 @@ +package de.interaapps.pastefy.service + +import de.interaapps.pastefy.dto.pastes.CreatePasteCommentRequest +import de.interaapps.pastefy.exceptions.HttpException +import de.interaapps.pastefy.repositories.PasteCommentRepository +import de.interaapps.pastefy.repositories.UserRepository +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.Mockito.mock + +class PasteCommentServiceTest { + private val service = PasteCommentService( + mock(PasteService::class.java), + mock(PasteCommentRepository::class.java), + mock(UserRepository::class.java), + ) + + @Test + fun `accepts a valid line range`() { + assertDoesNotThrow { + service.validate(CreatePasteCommentRequest(content = "Useful note", lineFrom = 3, lineTo = 8)) + } + } + + @Test + fun `rejects blank comments`() { + val exception = assertThrows { + service.validate(CreatePasteCommentRequest(content = " ")) + } + + assertEquals("Comment content is required", exception.message) + } + + @Test + fun `rejects line to without line from`() { + val exception = assertThrows { + service.validate(CreatePasteCommentRequest(content = "note", lineTo = 3)) + } + + assertEquals("line_to requires line_from", exception.message) + } +} diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/PasteThumbnailServiceTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/PasteThumbnailServiceTest.kt new file mode 100644 index 0000000..7735389 --- /dev/null +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/PasteThumbnailServiceTest.kt @@ -0,0 +1,21 @@ +package de.interaapps.pastefy.service + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class PasteThumbnailServiceTest { + @Test + fun `renders png for xml-sensitive paste content`() { + val png = PasteThumbnailService().render("", "first & second\n<xml>") + + assertTrue(png.size > PNG_SIGNATURE.size) + assertArrayEquals(PNG_SIGNATURE, png.copyOf(PNG_SIGNATURE.size)) + } + + companion object { + private val PNG_SIGNATURE = byteArrayOf( + 0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + ) + } +} diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/SeoRendererTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/SeoRendererTest.kt new file mode 100644 index 0000000..f35a3d4 --- /dev/null +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/SeoRendererTest.kt @@ -0,0 +1,26 @@ +package de.interaapps.pastefy.service + +import de.interaapps.pastefy.config.PastefyProperties +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SeoRendererTest { + @Test + fun `injects escaped metadata and canonical url`() { + val renderer = SeoRenderer( + PastefyProperties(metaTagsEnabled = true, serverName = "https://pastefy.example/"), + ) + + val html = renderer.render( + renderer.page("/tags/kotlin", "Kotlin & JVM | Pastefy", "Public <pastes>") + .content("<main>content</main>"), + ) + + assertNotNull(html) + assertTrue(html!!.contains("<title>Kotlin & JVM | Pastefy")) + assertTrue(html.contains("")) + assertTrue(html.contains("content=\"Public <pastes>\"")) + assertTrue(html.contains("
content
")) + } +} From 3512504b46131373b0fa74fa3f98008796a606c7 Mon Sep 17 00:00:00 2001 From: juliangojani Date: Thu, 4 Jun 2026 18:31:06 +0200 Subject: [PATCH 07/22] Initial spring-boot-kotlin --- .../pastefy/auth/AuthArgumentResolver.kt | 2 +- .../pastefy/auth/oauth/OAuth2LoginService.kt | 5 +- .../auth/oauth/OAuth2ProviderRegistry.kt | 70 ++++++++++- .../pastefy/auth/oauth/OAuthStateService.kt | 15 ++- .../oauth/providers/CustomOAuth2Provider.kt | 23 +++- .../oauth/providers/DiscordOAuth2Provider.kt | 23 +++- .../oauth/providers/GitHubOAuth2Provider.kt | 22 +++- .../oauth/providers/GoogleOAuth2Provider.kt | 35 +++++- .../providers/InteraAppsOAuth2Provider.kt | 19 ++- .../oauth/providers/OAuthProviderSupport.kt | 3 +- .../oauth/providers/TwitchOAuth2Provider.kt | 31 ++++- .../pastefy/controller/FolderController.kt | 25 +++- .../controller/FormDataPasteController.kt | 7 +- .../controller/admin/AdminController.kt | 11 +- .../analytics/AnalyticsController.kt | 16 ++- .../controller/auth/OAuth2Controller.kt | 5 +- .../pastes/PasteCommentsController.kt | 20 +++- .../controller/pastes/PasteController.kt | 34 +++++- .../pastes/PasteMetaSSRController.kt | 61 ++++++++-- .../controller/public/TagsController.kt | 6 +- .../controller/user/ApiKeyController.kt | 6 +- .../controller/user/NotificationController.kt | 12 +- .../pastefy/controller/user/UserController.kt | 48 ++++++-- .../interaapps/pastefy/dto/ActionResponse.kt | 2 +- .../pastefy/dto/analytics/AnalyticsDtos.kt | 3 +- .../pastefy/entities/BackgroundJob.kt | 9 +- .../pastefy/entities/Notification.kt | 9 +- .../de/interaapps/pastefy/entities/Paste.kt | 2 +- .../pastefy/entities/PasteAIInfo.kt | 15 ++- .../pastefy/entities/PasteComment.kt | 9 +- .../pastefy/entities/PublicPasteEngagement.kt | 9 +- .../pastefy/entities/SharedPaste.kt | 9 +- .../pastefy/exceptions/ApiExceptionHandler.kt | 3 +- .../infrastructure/ai/PasteAIInfoService.kt | 4 +- .../analytics/AnalyticsService.kt | 112 +++++++++++++++--- .../jobs/BackgroundJobService.kt | 3 +- .../infrastructure/s3/S3PasteService.kt | 3 +- .../repositories/NotificationRepository.kt | 7 +- .../repositories/PasteCommentRepository.kt | 7 +- .../pastefy/service/FolderService.kt | 10 +- .../pastefy/service/PasteCommandService.kt | 15 ++- .../pastefy/service/PasteCommentService.kt | 13 +- .../pastefy/service/PasteQueryService.kt | 17 ++- .../pastefy/service/PasteService.kt | 3 +- .../pastefy/service/PasteThumbnailService.kt | 3 +- .../interaapps/pastefy/service/SeoRenderer.kt | 11 +- 46 files changed, 638 insertions(+), 139 deletions(-) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/AuthArgumentResolver.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/AuthArgumentResolver.kt index a1ddf25..4a30591 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/AuthArgumentResolver.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/AuthArgumentResolver.kt @@ -14,7 +14,7 @@ import org.springframework.web.method.support.ModelAndViewContainer class AuthArgumentResolver : HandlerMethodArgumentResolver { override fun supportsParameter(parameter: MethodParameter): Boolean = parameter.hasParameterAnnotation(CurrentUser::class.java) && parameter.parameterType == User::class.java || - parameter.hasParameterAnnotation(CurrentAuthKey::class.java) && parameter.parameterType == AuthKey::class.java + parameter.hasParameterAnnotation(CurrentAuthKey::class.java) && parameter.parameterType == AuthKey::class.java override fun resolveArgument( parameter: MethodParameter, diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuth2LoginService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuth2LoginService.kt index 713e66c..7dad4ec 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuth2LoginService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuth2LoginService.kt @@ -18,8 +18,9 @@ class OAuth2LoginService( @Transactional fun login(provider: OAuth2Provider, tokens: OAuth2Tokens): AuthKey { val profile = provider.loadProfile(tokens) - val authenticationProvider = User.AuthenticationProvider.entries.firstOrNull { it.providerName == provider.name } - ?: throw OAuth2Exception("Unsupported OAuth2 provider") + val authenticationProvider = + User.AuthenticationProvider.entries.firstOrNull { it.providerName == provider.name } + ?: throw OAuth2Exception("Unsupported OAuth2 provider") val user = userRepository.findByAuthIdAndAuthProvider(profile.id, authenticationProvider) ?: User( authId = profile.id, diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuth2ProviderRegistry.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuth2ProviderRegistry.kt index e84e815..96453e2 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuth2ProviderRegistry.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuth2ProviderRegistry.kt @@ -10,13 +10,71 @@ class OAuth2ProviderRegistry( http: OAuthHttpClient, ) { private val providers: Map = buildList { - properties.oauth.interaapps.takeIf { it.enabled }?.let { add(InteraAppsOAuth2Provider(it.clientId, it.clientSecret, it.scopes.orDefault("user:read"), http)) } - properties.oauth.google.takeIf { it.enabled }?.let { add(GoogleOAuth2Provider(it.clientId, it.clientSecret, it.scopes.orDefault("https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.email"), http)) } - properties.oauth.github.takeIf { it.enabled }?.let { add(GitHubOAuth2Provider(it.clientId, it.clientSecret, it.scopes.orDefault("read:user", "user:email"), http)) } - properties.oauth.twitch.takeIf { it.enabled }?.let { add(TwitchOAuth2Provider(it.clientId, it.clientSecret, it.scopes.orDefault("user:read:email"), http)) } - properties.oauth.discord.takeIf { it.enabled }?.let { add(DiscordOAuth2Provider(it.clientId, it.clientSecret, it.scopes.orDefault("email", "identify"), http)) } + properties.oauth.interaapps.takeIf { it.enabled }?.let { + add( + InteraAppsOAuth2Provider( + it.clientId, + it.clientSecret, + it.scopes.orDefault("user:read"), + http + ) + ) + } + properties.oauth.google.takeIf { it.enabled }?.let { + add( + GoogleOAuth2Provider( + it.clientId, + it.clientSecret, + it.scopes.orDefault( + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/userinfo.email" + ), + http + ) + ) + } + properties.oauth.github.takeIf { it.enabled }?.let { + add( + GitHubOAuth2Provider( + it.clientId, + it.clientSecret, + it.scopes.orDefault("read:user", "user:email"), + http + ) + ) + } + properties.oauth.twitch.takeIf { it.enabled }?.let { + add( + TwitchOAuth2Provider( + it.clientId, + it.clientSecret, + it.scopes.orDefault("user:read:email"), + http + ) + ) + } + properties.oauth.discord.takeIf { it.enabled }?.let { + add( + DiscordOAuth2Provider( + it.clientId, + it.clientSecret, + it.scopes.orDefault("email", "identify"), + http + ) + ) + } properties.oauth.oidc.takeIf { it.fullyConfigured }?.let { - add(CustomOAuth2Provider(it.clientId, it.clientSecret, it.authorizationEndpoint, it.tokenEndpoint, it.userInfoEndpoint, it.scopes.orDefault("openid", "email", "profile"), http)) + add( + CustomOAuth2Provider( + it.clientId, + it.clientSecret, + it.authorizationEndpoint, + it.tokenEndpoint, + it.userInfoEndpoint, + it.scopes.orDefault("openid", "email", "profile"), + http + ) + ) } }.associateBy(OAuth2Provider::name) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuthStateService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuthStateService.kt index 92ac550..14b085f 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuthStateService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuthStateService.kt @@ -22,10 +22,18 @@ class OAuthStateService( fun validate(state: String?, cookieState: String?) { if (state.isNullOrBlank() || cookieState.isNullOrBlank()) throw OAuth2Exception("Missing OAuth2 state") - if (!MessageDigest.isEqual(state.toByteArray(), cookieState.toByteArray())) throw OAuth2Exception("Invalid OAuth2 state") + if (!MessageDigest.isEqual( + state.toByteArray(), + cookieState.toByteArray() + ) + ) throw OAuth2Exception("Invalid OAuth2 state") val payload = state.substringBeforeLast('.', "") val signature = state.substringAfterLast('.', "") - if (payload.isBlank() || !MessageDigest.isEqual(sign(payload).toByteArray(), signature.toByteArray())) throw OAuth2Exception("Invalid OAuth2 state") + if (payload.isBlank() || !MessageDigest.isEqual( + sign(payload).toByteArray(), + signature.toByteArray() + ) + ) throw OAuth2Exception("Invalid OAuth2 state") val issuedAt = payload.substringBefore('.').toLongOrNull() ?: throw OAuth2Exception("Invalid OAuth2 state") if (Instant.now().epochSecond - issuedAt !in 0..600) throw OAuth2Exception("Expired OAuth2 state") } @@ -35,6 +43,7 @@ class OAuthStateService( ?: throw OAuth2Exception("OAuth2 state secret is not configured") val mac = Mac.getInstance("HmacSHA256") mac.init(SecretKeySpec(secret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256")) - return Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal(payload.toByteArray(StandardCharsets.UTF_8))) + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(mac.doFinal(payload.toByteArray(StandardCharsets.UTF_8))) } } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/CustomOAuth2Provider.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/CustomOAuth2Provider.kt index 08c2012..77e4f70 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/CustomOAuth2Provider.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/CustomOAuth2Provider.kt @@ -15,17 +15,34 @@ class CustomOAuth2Provider( override fun authorizationUrl(callbackUrl: String, state: String): String = authorizationUrl( authorizationEndpoint, - mapOf("response_type" to "code", "client_id" to clientId, "redirect_uri" to callbackUrl, "scope" to scopes.joinToString(" "), "state" to state), + mapOf( + "response_type" to "code", + "client_id" to clientId, + "redirect_uri" to callbackUrl, + "scope" to scopes.joinToString(" "), + "state" to state + ), ) override fun exchangeCode(code: String, callbackUrl: String): OAuth2Tokens = requireHttp().postForm( tokenEndpoint, - mapOf("client_id" to clientId, "client_secret" to clientSecret, "code" to code, "grant_type" to "authorization_code", "redirect_uri" to callbackUrl), + mapOf( + "client_id" to clientId, + "client_secret" to clientSecret, + "code" to code, + "grant_type" to "authorization_code", + "redirect_uri" to callbackUrl + ), ).tokens() override fun loadProfile(tokens: OAuth2Tokens): OAuth2Profile { val profile = requireHttp().get(userInfoEndpoint, mapOf("Authorization" to "Bearer ${tokens.accessToken}")) - return OAuth2Profile(profile.requiredText("sub"), profile.requiredText("name"), profile.optionalText("email"), profile.optionalText("picture")) + return OAuth2Profile( + profile.requiredText("sub"), + profile.requiredText("name"), + profile.optionalText("email"), + profile.optionalText("picture") + ) } private fun requireHttp() = requireNotNull(http) { "OAuth HTTP client is required" } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/DiscordOAuth2Provider.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/DiscordOAuth2Provider.kt index 3548029..955b121 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/DiscordOAuth2Provider.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/DiscordOAuth2Provider.kt @@ -12,16 +12,33 @@ class DiscordOAuth2Provider( override fun authorizationUrl(callbackUrl: String, state: String): String = authorizationUrl( "https://discord.com/api/oauth2/authorize", - mapOf("response_type" to "code", "client_id" to clientId, "prompt" to "consent", "scope" to scopes.joinToString(" "), "redirect_uri" to callbackUrl, "state" to state), + mapOf( + "response_type" to "code", + "client_id" to clientId, + "prompt" to "consent", + "scope" to scopes.joinToString(" "), + "redirect_uri" to callbackUrl, + "state" to state + ), ) override fun exchangeCode(code: String, callbackUrl: String): OAuth2Tokens = requireHttp().postForm( "https://discord.com/api/oauth2/token", - mapOf("client_id" to clientId, "client_secret" to clientSecret, "code" to code, "grant_type" to "authorization_code", "redirect_uri" to callbackUrl, "scope" to scopes.joinToString(" ")), + mapOf( + "client_id" to clientId, + "client_secret" to clientSecret, + "code" to code, + "grant_type" to "authorization_code", + "redirect_uri" to callbackUrl, + "scope" to scopes.joinToString(" ") + ), ).tokens() override fun loadProfile(tokens: OAuth2Tokens): OAuth2Profile { - val data = requireHttp().get("https://discord.com/api/users/@me", mapOf("Authorization" to "Bearer ${tokens.accessToken}")) + val data = requireHttp().get( + "https://discord.com/api/users/@me", + mapOf("Authorization" to "Bearer ${tokens.accessToken}") + ) val id = data.requiredText("id") val avatar = data.optionalText("avatar")?.let { "https://cdn.discordapp.com/avatars/$id/$it.png" } return OAuth2Profile(id, data.requiredText("username"), data.optionalText("email"), avatar) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/GitHubOAuth2Provider.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/GitHubOAuth2Provider.kt index cba93a6..d729eea 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/GitHubOAuth2Provider.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/GitHubOAuth2Provider.kt @@ -12,7 +12,12 @@ class GitHubOAuth2Provider( override fun authorizationUrl(callbackUrl: String, state: String): String = authorizationUrl( "https://github.com/login/oauth/authorize", - mapOf("client_id" to clientId, "scope" to scopes.joinToString(" "), "redirect_uri" to callbackUrl, "state" to state), + mapOf( + "client_id" to clientId, + "scope" to scopes.joinToString(" "), + "redirect_uri" to callbackUrl, + "state" to state + ), ) override fun exchangeCode(code: String, callbackUrl: String): OAuth2Tokens = requireHttp().postForm( @@ -22,13 +27,22 @@ class GitHubOAuth2Provider( ).tokens() override fun loadProfile(tokens: OAuth2Tokens): OAuth2Profile { - val headers = mapOf("Authorization" to "Bearer ${tokens.accessToken}", "Accept" to "application/vnd.github+json") + val headers = + mapOf("Authorization" to "Bearer ${tokens.accessToken}", "Accept" to "application/vnd.github+json") val user = requireHttp().get("https://api.github.com/user", headers) - val emails = if (user.optionalText("email") == null) requireHttp().get("https://api.github.com/user/emails", headers) else null + val emails = if (user.optionalText("email") == null) requireHttp().get( + "https://api.github.com/user/emails", + headers + ) else null val email = user.optionalText("email") ?: emails?.firstOrNull { it.path("primary").asBoolean(false) }?.optionalText("email") ?: emails?.firstOrNull()?.optionalText("email") - return OAuth2Profile(user.requiredText("id"), user.optionalText("name") ?: user.requiredText("login"), email, user.optionalText("avatar_url")) + return OAuth2Profile( + user.requiredText("id"), + user.optionalText("name") ?: user.requiredText("login"), + email, + user.optionalText("avatar_url") + ) } private fun requireHttp() = requireNotNull(http) { "OAuth HTTP client is required" } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/GoogleOAuth2Provider.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/GoogleOAuth2Provider.kt index d249aae..ce50926 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/GoogleOAuth2Provider.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/GoogleOAuth2Provider.kt @@ -5,24 +5,49 @@ import de.interaapps.pastefy.auth.oauth.* class GoogleOAuth2Provider( private val clientId: String = "", private val clientSecret: String = "", - private val scopes: List = listOf("https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.email"), + private val scopes: List = listOf( + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/userinfo.email" + ), private val http: OAuthHttpClient? = null, ) : OAuth2Provider { override val name = "google" override fun authorizationUrl(callbackUrl: String, state: String): String = authorizationUrl( "https://accounts.google.com/o/oauth2/v2/auth", - mapOf("response_type" to "code", "client_id" to clientId, "redirect_uri" to callbackUrl, "scope" to scopes.joinToString(" "), "access_type" to "offline", "prompt" to "consent", "state" to state), + mapOf( + "response_type" to "code", + "client_id" to clientId, + "redirect_uri" to callbackUrl, + "scope" to scopes.joinToString(" "), + "access_type" to "offline", + "prompt" to "consent", + "state" to state + ), ) override fun exchangeCode(code: String, callbackUrl: String): OAuth2Tokens = requireHttp().postForm( "https://oauth2.googleapis.com/token", - mapOf("client_id" to clientId, "client_secret" to clientSecret, "code" to code, "grant_type" to "authorization_code", "redirect_uri" to callbackUrl), + mapOf( + "client_id" to clientId, + "client_secret" to clientSecret, + "code" to code, + "grant_type" to "authorization_code", + "redirect_uri" to callbackUrl + ), ).tokens() override fun loadProfile(tokens: OAuth2Tokens): OAuth2Profile { - val data = requireHttp().get("https://www.googleapis.com/oauth2/v2/userinfo", mapOf("Authorization" to "Bearer ${tokens.accessToken}")) - return OAuth2Profile(data.requiredText("id"), data.requiredText("name"), data.optionalText("email"), data.optionalText("picture")) + val data = requireHttp().get( + "https://www.googleapis.com/oauth2/v2/userinfo", + mapOf("Authorization" to "Bearer ${tokens.accessToken}") + ) + return OAuth2Profile( + data.requiredText("id"), + data.requiredText("name"), + data.optionalText("email"), + data.optionalText("picture") + ) } private fun requireHttp() = requireNotNull(http) { "OAuth HTTP client is required" } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/InteraAppsOAuth2Provider.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/InteraAppsOAuth2Provider.kt index 78db8d6..d9e3d6b 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/InteraAppsOAuth2Provider.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/InteraAppsOAuth2Provider.kt @@ -13,7 +13,12 @@ class InteraAppsOAuth2Provider( override fun authorizationUrl(callbackUrl: String, state: String): String = authorizationUrl( "https://accounts.interaapps.de/auth/oauth2", - mapOf("client_id" to clientId, "scope" to scopes.joinToString(" "), "redirect_uri" to callbackUrl, "state" to state), + mapOf( + "client_id" to clientId, + "scope" to scopes.joinToString(" "), + "redirect_uri" to callbackUrl, + "state" to state + ), ) override fun exchangeCode(code: String, callbackUrl: String): OAuth2Tokens { @@ -28,8 +33,16 @@ class InteraAppsOAuth2Provider( } override fun loadProfile(tokens: OAuth2Tokens): OAuth2Profile { - val user = requireHttp().get("https://accounts.interaapps.de/api/v2/user", mapOf("Authorization" to "Bearer ${tokens.accessToken}")) - return OAuth2Profile(user.requiredText("id"), user.requiredText("name"), user.optionalText("mail"), user.optionalText("profile_picture")) + val user = requireHttp().get( + "https://accounts.interaapps.de/api/v2/user", + mapOf("Authorization" to "Bearer ${tokens.accessToken}") + ) + return OAuth2Profile( + user.requiredText("id"), + user.requiredText("name"), + user.optionalText("mail"), + user.optionalText("profile_picture") + ) } private fun requireHttp() = requireNotNull(http) { "OAuth HTTP client is required" } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/OAuthProviderSupport.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/OAuthProviderSupport.kt index a1faeeb..4ae7585 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/OAuthProviderSupport.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/OAuthProviderSupport.kt @@ -6,7 +6,8 @@ import de.interaapps.pastefy.exceptions.OAuth2Exception import okhttp3.HttpUrl.Companion.toHttpUrl internal fun authorizationUrl(endpoint: String, fields: Map): String = - endpoint.toHttpUrl().newBuilder().apply { fields.forEach { (name, value) -> addQueryParameter(name, value) } }.build().toString() + endpoint.toHttpUrl().newBuilder().apply { fields.forEach { (name, value) -> addQueryParameter(name, value) } } + .build().toString() internal fun JsonNode.requiredText(name: String): String = path(name).takeUnless { it.isMissingNode || it.isNull }?.asText()?.takeIf { it.isNotBlank() } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/TwitchOAuth2Provider.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/TwitchOAuth2Provider.kt index 2355927..0e9f928 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/TwitchOAuth2Provider.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/TwitchOAuth2Provider.kt @@ -12,18 +12,39 @@ class TwitchOAuth2Provider( override fun authorizationUrl(callbackUrl: String, state: String): String = authorizationUrl( "https://id.twitch.tv/oauth2/authorize", - mapOf("client_id" to clientId, "response_type" to "code", "scope" to scopes.joinToString(" "), "redirect_uri" to callbackUrl, "state" to state), + mapOf( + "client_id" to clientId, + "response_type" to "code", + "scope" to scopes.joinToString(" "), + "redirect_uri" to callbackUrl, + "state" to state + ), ) override fun exchangeCode(code: String, callbackUrl: String): OAuth2Tokens = requireHttp().postForm( "https://id.twitch.tv/oauth2/token", - mapOf("client_id" to clientId, "client_secret" to clientSecret, "code" to code, "grant_type" to "authorization_code", "redirect_uri" to callbackUrl), + mapOf( + "client_id" to clientId, + "client_secret" to clientSecret, + "code" to code, + "grant_type" to "authorization_code", + "redirect_uri" to callbackUrl + ), ).tokens() override fun loadProfile(tokens: OAuth2Tokens): OAuth2Profile { - val data = requireHttp().get("https://api.twitch.tv/helix/users", mapOf("Authorization" to "Bearer ${tokens.accessToken}", "Client-Id" to clientId)) - .path("data").firstOrNull() ?: throw de.interaapps.pastefy.exceptions.OAuth2Exception("Twitch returned no user") - return OAuth2Profile(data.requiredText("id"), data.requiredText("login"), data.optionalText("email"), data.optionalText("profile_image_url")) + val data = requireHttp().get( + "https://api.twitch.tv/helix/users", + mapOf("Authorization" to "Bearer ${tokens.accessToken}", "Client-Id" to clientId) + ) + .path("data").firstOrNull() + ?: throw de.interaapps.pastefy.exceptions.OAuth2Exception("Twitch returned no user") + return OAuth2Profile( + data.requiredText("id"), + data.requiredText("login"), + data.optionalText("email"), + data.optionalText("profile_image_url") + ) } private fun requireHttp() = requireNotNull(http) { "OAuth HTTP client is required" } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/FolderController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/FolderController.kt index 364a2ec..94b5bc5 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/FolderController.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/FolderController.kt @@ -22,14 +22,22 @@ class FolderController( @RejectAwaitingAccess @RejectBlocked @RequiresPermission("folders:create") - fun createFolder(@Valid @RequestBody request: CreateFolderRequest, @CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): CreateFolderResponse { + fun createFolder( + @Valid @RequestBody request: CreateFolderRequest, + @CurrentUser user: User, + @CurrentAuthKey authKey: AuthKey + ): CreateFolderResponse { val folder = folders.create(request, user) return CreateFolderResponse(folder = folders.map(folder), success = true) } @GetMapping @RequiresPermission("folders:read") - fun getFolders(request: HttpServletRequest, @CurrentUser user: User?, @CurrentAuthKey authKey: AuthKey?): List = + fun getFolders( + request: HttpServletRequest, + @CurrentUser user: User?, + @CurrentAuthKey authKey: AuthKey? + ): List = folders.list(request, user) @GetMapping("/{id}") @@ -37,7 +45,12 @@ class FolderController( @RejectAwaitingAccess @RejectBlocked @RequiresPermission("folders:read") - fun getFolder(@PathVariable id: String, request: HttpServletRequest, @CurrentUser user: User?, @CurrentAuthKey authKey: AuthKey?): FolderResponse { + fun getFolder( + @PathVariable id: String, + request: HttpServletRequest, + @CurrentUser user: User?, + @CurrentAuthKey authKey: AuthKey? + ): FolderResponse { val folder = folders.get(id) return folders.map( folder, @@ -53,7 +66,11 @@ class FolderController( @RejectAwaitingAccess @RejectBlocked @RequiresPermission("folders:delete") - fun deleteFolder(@PathVariable id: String, @CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): ActionResponse { + fun deleteFolder( + @PathVariable id: String, + @CurrentUser user: User, + @CurrentAuthKey authKey: AuthKey + ): ActionResponse { val folder = folders.get(id) if (folder.userId == user.id || user.isAdmin) { folders.delete(folder) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/FormDataPasteController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/FormDataPasteController.kt index 3f50c99..6f1a913 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/FormDataPasteController.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/FormDataPasteController.kt @@ -47,7 +47,12 @@ class FormDataPasteController( val host = request.getHeader("Host") return if (asciicast) { ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body( - objectMapper.writeValueAsString(mapOf("url" to "$host/${paste.key}", "message" to "Created paste on https://$host/${paste.key}")), + objectMapper.writeValueAsString( + mapOf( + "url" to "$host/${paste.key}", + "message" to "Created paste on https://$host/${paste.key}" + ) + ), ) } else { ResponseEntity.ok("https://$host/${paste.key}\n") diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/admin/AdminController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/admin/AdminController.kt index eec4b7b..a0ff2ef 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/admin/AdminController.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/admin/AdminController.kt @@ -38,7 +38,10 @@ class AdminController( ) } ?: builder.conjunction() } - return repository.findAll(specification, PageRequest.of(page - 1, limit, Sort.by(Sort.Direction.DESC, "createdAt"))).content + return repository.findAll( + specification, + PageRequest.of(page - 1, limit, Sort.by(Sort.Direction.DESC, "createdAt")) + ).content } @GetMapping("/users/{id}") @@ -55,7 +58,11 @@ class AdminController( @PutMapping("/users/{id}") @RequiresPermission("admin.users:edit") - fun editUser(@PathVariable id: String, @Valid @RequestBody request: EditUserRequest, @CurrentAuthKey authKey: AuthKey): ActionResponse { + fun editUser( + @PathVariable id: String, + @Valid @RequestBody request: EditUserRequest, + @CurrentAuthKey authKey: AuthKey + ): ActionResponse { val user = repository.findById(id).orElseThrow(::NotFoundException) request.name?.let { user.name = it } request.uniqueName?.let { user.uniqueName = it } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/analytics/AnalyticsController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/analytics/AnalyticsController.kt index 6e855cf..8b74c80 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/analytics/AnalyticsController.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/analytics/AnalyticsController.kt @@ -26,11 +26,17 @@ class AnalyticsController( @GetMapping("/admin") @AdminRoute @RequiresPermission("analytics:read") - fun admin(request: HttpServletRequest, @CurrentAuthKey authKey: AuthKey): AnalyticsResponse = query(AnalyticsQuery.from(request)) + fun admin(request: HttpServletRequest, @CurrentAuthKey authKey: AuthKey): AnalyticsResponse = + query(AnalyticsQuery.from(request)) @GetMapping("/pastes/{id}") @RequiresPermission("analytics:read") - fun paste(@PathVariable id: String, request: HttpServletRequest, @CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): AnalyticsResponse { + fun paste( + @PathVariable id: String, + request: HttpServletRequest, + @CurrentUser user: User, + @CurrentAuthKey authKey: AuthKey + ): AnalyticsResponse { val paste = pasteService.get(id) if (paste == null || (!user.isAdmin && user.id != paste.userId)) throw PermissionsDeniedException() return query(AnalyticsQuery.from(request).apply { filters["paste_key"] = paste.key }) @@ -38,7 +44,11 @@ class AnalyticsController( @GetMapping("/user") @RequiresPermission("analytics:read") - fun user(request: HttpServletRequest, @CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): AnalyticsResponse = + fun user( + request: HttpServletRequest, + @CurrentUser user: User, + @CurrentAuthKey authKey: AuthKey + ): AnalyticsResponse = query(AnalyticsQuery.from(request).apply { filters["paste_user_id"] = user.id }) private fun query(query: AnalyticsQuery): AnalyticsResponse = diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/auth/OAuth2Controller.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/auth/OAuth2Controller.kt index 7b6109b..d1a5dcb 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/auth/OAuth2Controller.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/auth/OAuth2Controller.kt @@ -48,7 +48,10 @@ class OAuth2Controller( if (!error.isNullOrBlank()) throw OAuth2Exception("OAuth2 provider rejected authentication") stateService.validate(state, request.cookies?.firstOrNull { it.name == STATE_COOKIE }?.value) val oauth = registry.get(provider) ?: throw NotFoundException("OAuth2 provider not found") - val tokens = oauth.exchangeCode(code ?: throw OAuth2Exception("Missing OAuth2 authorization code"), callbackUrl(provider)) + val tokens = oauth.exchangeCode( + code ?: throw OAuth2Exception("Missing OAuth2 authorization code"), + callbackUrl(provider) + ) val authKey = loginService.login(oauth, tokens) val encodedKey = URLEncoder.encode(authKey.key, StandardCharsets.UTF_8) return ResponseEntity.status(HttpStatus.FOUND) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteCommentsController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteCommentsController.kt index 95abca4..4687877 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteCommentsController.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteCommentsController.kt @@ -20,7 +20,11 @@ class PasteCommentsController( @LoginRequiredForRead @RejectAwaitingAccess @RejectBlocked - fun getComments(@PathVariable pasteId: String, request: HttpServletRequest, @CurrentUser user: User?): List = + fun getComments( + @PathVariable pasteId: String, + request: HttpServletRequest, + @CurrentUser user: User? + ): List = comments.list( pasteId, user, @@ -42,7 +46,12 @@ class PasteCommentsController( @RejectAwaitingAccess @RejectBlocked @RequiresPermission("comments:create") - fun createComment(@PathVariable pasteId: String, @RequestBody request: CreatePasteCommentRequest, @CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): PasteCommentResponse = + fun createComment( + @PathVariable pasteId: String, + @RequestBody request: CreatePasteCommentRequest, + @CurrentUser user: User, + @CurrentAuthKey authKey: AuthKey + ): PasteCommentResponse = comments.create(pasteId, request, user) @DeleteMapping("/{pasteId}/comments/{commentId}") @@ -51,7 +60,12 @@ class PasteCommentsController( @RejectAwaitingAccess @RejectBlocked @RequiresPermission("comments:delete") - fun deleteComment(@PathVariable pasteId: String, @PathVariable commentId: Int, @CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): ActionResponse { + fun deleteComment( + @PathVariable pasteId: String, + @PathVariable commentId: Int, + @CurrentUser user: User, + @CurrentAuthKey authKey: AuthKey + ): ActionResponse { comments.delete(pasteId, commentId, user) return ActionResponse(success = true) } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteController.kt index 0e0f9bd..3451011 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteController.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteController.kt @@ -45,7 +45,8 @@ class PasteController( @CurrentUser user: User?, @CurrentAuthKey authKey: AuthKey?, servletRequest: HttpServletRequest, - ): CreatePasteResponse = CreatePasteResponse(paste = queries.map(commands.create(request, user), servletRequest, user), success = true) + ): CreatePasteResponse = + CreatePasteResponse(paste = queries.map(commands.create(request, user), servletRequest, user), success = true) @GetMapping @RequiresPermission("pastes:read") @@ -94,7 +95,11 @@ class PasteController( @RejectAwaitingAccess @RejectBlocked @RequiresPermission("pastes:delete") - fun deletePaste(@PathVariable id: String, @CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): ActionResponse { + fun deletePaste( + @PathVariable id: String, + @CurrentUser user: User, + @CurrentAuthKey authKey: AuthKey + ): ActionResponse { commands.delete(id, user) return ActionResponse(success = true) } @@ -105,7 +110,11 @@ class PasteController( @RejectAwaitingAccess @RejectBlocked @RequiresPermission("stars:create") - fun addStarToPaste(@PathVariable id: String, @CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): ActionResponse { + fun addStarToPaste( + @PathVariable id: String, + @CurrentUser user: User, + @CurrentAuthKey authKey: AuthKey + ): ActionResponse { users.star(user, pasteService.getAccessiblePasteOrFail(id, user)) return ActionResponse(success = true) } @@ -116,7 +125,11 @@ class PasteController( @RejectAwaitingAccess @RejectBlocked @RequiresPermission("stars:delete") - fun removeStarFromPaste(@PathVariable id: String, @CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): ActionResponse { + fun removeStarFromPaste( + @PathVariable id: String, + @CurrentUser user: User, + @CurrentAuthKey authKey: AuthKey + ): ActionResponse { users.unstar(user, pasteService.getAccessiblePasteOrFail(id, user)) return ActionResponse(success = true) } @@ -125,13 +138,22 @@ class PasteController( @Authenticated @RejectAwaitingAccess @RejectBlocked - fun addFriend(@PathVariable id: String, @RequestBody request: AddFriendToPasteRequest, @CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): ActionResponse = + fun addFriend( + @PathVariable id: String, + @RequestBody request: AddFriendToPasteRequest, + @CurrentUser user: User, + @CurrentAuthKey authKey: AuthKey + ): ActionResponse = throw UnsupportedOperationException("NOT IMPLEMENTED") @PostMapping("/{id}/ai-analysis") @AdminRoute @RequiresPermission("pastes.ai_analysis:create") - fun createAiAnalysisJob(@PathVariable id: String, @CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): ActionResponse { + fun createAiAnalysisJob( + @PathVariable id: String, + @CurrentUser user: User, + @CurrentAuthKey authKey: AuthKey + ): ActionResponse { val ai = aiProvider.ifAvailable ?: throw FeatureDisabledException("AI features are disabled") ai.enqueueIfEligible(pasteService.getAccessiblePasteOrFail(id, user), force = true) return ActionResponse(success = true) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteMetaSSRController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteMetaSSRController.kt index c95d36e..f3df9c8 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteMetaSSRController.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteMetaSSRController.kt @@ -24,52 +24,89 @@ class PasteMetaSSRController( @GetMapping("/{id}") fun getPasteMetaSSR(@PathVariable id: String): ResponseEntity { if (!id.matches(Regex("^[A-Za-z0-9_-]{8}$"))) return ResponseEntity.notFound().build() + val paste = pasteService.get(id) ?: return ResponseEntity.notFound().build() + if (paste.isPrivate || paste.encrypted) return ResponseEntity.notFound().build() + val aiInfo = paste.id?.let(aiInfoRepository::findById)?.orElse(null) - val title = paste.title?.trim()?.replace(Regex("\\s+"), " ")?.takeIf(String::isNotEmpty)?.let { seo.truncate(it, 120) } ?: "Paste" + + val title = + paste.title?.trim()?.replace(Regex("\\s+"), " ")?.takeIf(String::isNotEmpty)?.let { seo.truncate(it, 120) } + ?: "Paste" + val author = if (paste.isPublic) { - paste.userId?.let(userRepository::findById)?.orElse(null)?.uniqueName?.trim()?.takeIf(String::isNotEmpty)?.let { username -> - Author( - username = username, - displayName = paste.userId?.let(userRepository::findById)?.orElse(null)?.name?.trim()?.takeIf(String::isNotEmpty) ?: username, - profileUrl = seo.absoluteUrl("/@${seo.pathSegment(username)}"), - ) - } + paste.userId?.let(userRepository::findById)?.orElse(null)?.uniqueName?.trim()?.takeIf(String::isNotEmpty) + ?.let { username -> + Author( + username = username, + displayName = paste.userId?.let(userRepository::findById)?.orElse(null)?.name?.trim() + ?.takeIf(String::isNotEmpty) ?: username, + profileUrl = seo.absoluteUrl("/@${seo.pathSegment(username)}"), + ) + } } else null + val descriptiveTitle = title + aiInfo?.description?.takeIf(String::isNotBlank)?.let { " | $it" }.orEmpty() + val description = when { title == "Paste" && author == null -> "View this paste on Pastefy." title == "Paste" -> "View this paste by @${author?.username} on Pastefy." author != null -> seo.truncate("View \"$descriptiveTitle\" by @${author.username} on Pastefy.", 180) else -> seo.truncate("View \"$descriptiveTitle\" on Pastefy.", 180) } - val content = if (paste.isPublic) seoContent(pasteService.getContent(paste, withCache = false).orEmpty(), paste.key, title, author, aiInfo?.description) else "" + + val content = if (paste.isPublic) seoContent( + pasteService.getContent(paste, withCache = false).orEmpty(), + paste.key, + title, + author, + aiInfo?.description + ) else "" + val page = seo.page("/$id", title, description) .type("article") .image("/$id/thumbnail.png") .content(content) + author?.let { page.meta("author", "${it.displayName} (@${it.username})") .openGraph("article:author", it.profileUrl) .twitter("twitter:creator", "@${it.username}") } + return seo.render(page)?.let { ResponseEntity.ok().contentType(MediaType("text", "html", Charsets.UTF_8)).body(it) } ?: ResponseEntity.notFound().build() } - private fun seoContent(content: String, pasteKey: String, title: String, author: Author?, aiDescription: String?): String { + private fun seoContent( + content: String, + pasteKey: String, + title: String, + author: Author?, + aiDescription: String? + ): String { val preview = seo.truncateWithoutEllipsis(content, properties.metaTagsPreviewLength.coerceIn(0, 16_384)) + val authorHtml = author?.let { "

By ${seo.escapeHtml(it.displayName)} (@${seo.escapeHtml(it.username)})

" }.orEmpty() + val tags = tagRepository.findAllByPaste(pasteKey).joinToString(" ") { val url = seo.absoluteUrl("/tags/${seo.pathSegment(it.tag)}") "${seo.escapeHtml(it.tag)}" } - val aiHtml = aiDescription?.takeIf(String::isNotBlank)?.let { "

Description

${seo.escapeHtml(it)}

" }.orEmpty() - return "

${seo.escapeHtml(title)}

$authorHtml

View and share code snippets on Pastefy.

${seo.escapeHtml(preview)}

Tags

$tags$aiHtml
" + + val aiHtml = + aiDescription?.takeIf(String::isNotBlank)?.let { "

Description

${seo.escapeHtml(it)}

" } + .orEmpty() + + return "

${seo.escapeHtml(title)}

$authorHtml

View and share code snippets on Pastefy.

${
+            seo.escapeHtml(
+                preview
+            )
+        }

Tags

$tags$aiHtml
" } private data class Author(val displayName: String, val username: String, val profileUrl: String) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/TagsController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/TagsController.kt index e888713..de41bbd 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/TagsController.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/TagsController.kt @@ -26,7 +26,11 @@ class TagsController( ): List { val pageable = PageRequest.of(page.coerceAtLeast(1) - 1, pageLimit.coerceIn(1, 100)) return search?.trim()?.takeIf(String::isNotEmpty)?.let { - repository.findAllByTagContainingIgnoreCaseOrDisplayNameContainingIgnoreCaseOrderByPasteCountDesc(it, it, pageable) + repository.findAllByTagContainingIgnoreCaseOrDisplayNameContainingIgnoreCaseOrderByPasteCountDesc( + it, + it, + pageable + ) } ?: repository.findAllByOrderByPasteCountDesc(pageable) } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/user/ApiKeyController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/user/ApiKeyController.kt index a6c5619..6d820d4 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/user/ApiKeyController.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/user/ApiKeyController.kt @@ -28,7 +28,11 @@ class ApiKeyController( @DeleteMapping("/{key}") @RequiresPermission("authkeys:delete") - fun deleteKey(@PathVariable key: String, @CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): ActionResponse { + fun deleteKey( + @PathVariable key: String, + @CurrentUser user: User, + @CurrentAuthKey authKey: AuthKey + ): ActionResponse { repository.deleteByKeyAndUserId(key, user.id) return ActionResponse(success = true) } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/user/NotificationController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/user/NotificationController.kt index 2e7d6ef..7e255fa 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/user/NotificationController.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/user/NotificationController.kt @@ -21,8 +21,16 @@ class NotificationController( @GetMapping @RequiresPermission("notifications:read") - fun getNotifications(request: HttpServletRequest, @CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): List = - notifications.list(user, request.parameterMap.containsKey("not_received"), request.parameterMap.containsKey("not_read")) + fun getNotifications( + request: HttpServletRequest, + @CurrentUser user: User, + @CurrentAuthKey authKey: AuthKey + ): List = + notifications.list( + user, + request.parameterMap.containsKey("not_received"), + request.parameterMap.containsKey("not_read") + ) @GetMapping("/readall") @RequiresPermission("notifications:edit") diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/user/UserController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/user/UserController.kt index 39fab28..bca9ca7 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/user/UserController.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/user/UserController.kt @@ -48,10 +48,17 @@ class UserController( @RejectAwaitingAccess @RejectBlocked @RequiresPermission(allOf = ["pastes:read", "folders:read"]) - fun overview(request: HttpServletRequest, @CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): UserPastesResponse { + fun overview( + request: HttpServletRequest, + @CurrentUser user: User, + @CurrentAuthKey authKey: AuthKey + ): UserPastesResponse { val page = request.getParameter("page")?.toIntOrNull()?.coerceAtLeast(1) ?: 1 return UserPastesResponse( - pastes = pasteRepository.findAllByUserIdAndFolderIsNullOrderByUpdatedAtDesc(user.id, PageRequest.of(page - 1, 10)) + pastes = pasteRepository.findAllByUserIdAndFolderIsNullOrderByUpdatedAtDesc( + user.id, + PageRequest.of(page - 1, 10) + ) .map { queries.map(it, request, user) }, folder = foldersForUser(request, user), ) @@ -62,7 +69,11 @@ class UserController( @RejectAwaitingAccess @RejectBlocked @RequiresPermission("folders:read") - fun getFolders(request: HttpServletRequest, @CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): List = + fun getFolders( + request: HttpServletRequest, + @CurrentUser user: User, + @CurrentAuthKey authKey: AuthKey + ): List = foldersForUser(request, user) @GetMapping("/pastes") @@ -70,7 +81,12 @@ class UserController( @RejectAwaitingAccess @RejectBlocked @RequiresPermission("pastes:read") - fun getPastes(request: HttpServletRequest, response: HttpServletResponse, @CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): List = + fun getPastes( + request: HttpServletRequest, + response: HttpServletResponse, + @CurrentUser user: User, + @CurrentAuthKey authKey: AuthKey + ): List = queries.list(request, response, user, guarded = false, userId = user.id) @GetMapping("/sharedpastes") @@ -78,14 +94,19 @@ class UserController( @RejectAwaitingAccess @RejectBlocked @RequiresPermission("sharedpastes:read") - fun getSharedPastes(request: HttpServletRequest, @CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): List { + fun getSharedPastes( + request: HttpServletRequest, + @CurrentUser user: User, + @CurrentAuthKey authKey: AuthKey + ): List { val page = request.getParameter("page")?.toIntOrNull()?.coerceAtLeast(1) ?: 1 - return sharedPasteRepository.findAllByTargetIdOrderByUpdatedAtDesc(user.id, PageRequest.of(page - 1, 10)).mapNotNull { shared -> - pasteRepository.findByKey(shared.paste)?.let { queries.map(it, request, user) } ?: run { - sharedPasteRepository.delete(shared) - null + return sharedPasteRepository.findAllByTargetIdOrderByUpdatedAtDesc(user.id, PageRequest.of(page - 1, 10)) + .mapNotNull { shared -> + pasteRepository.findByKey(shared.paste)?.let { queries.map(it, request, user) } ?: run { + sharedPasteRepository.delete(shared) + null + } } - } } @GetMapping("/starred-pastes") @@ -93,7 +114,12 @@ class UserController( @RejectAwaitingAccess @RejectBlocked @RequiresPermission("stars:read") - fun getStarredPastes(request: HttpServletRequest, response: HttpServletResponse, @CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): List = + fun getStarredPastes( + request: HttpServletRequest, + response: HttpServletResponse, + @CurrentUser user: User, + @CurrentAuthKey authKey: AuthKey + ): List = queries.list(request, response, user, guarded = false, starredBy = user.id) private fun foldersForUser(request: HttpServletRequest, user: User): List = diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/ActionResponse.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/ActionResponse.kt index ee0019b..963c8ea 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/ActionResponse.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/ActionResponse.kt @@ -1,5 +1,5 @@ package de.interaapps.pastefy.dto data class ActionResponse( - val success: Boolean = false + val success: Boolean = false ) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/analytics/AnalyticsDtos.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/analytics/AnalyticsDtos.kt index b882319..8146aa8 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/analytics/AnalyticsDtos.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/analytics/AnalyticsDtos.kt @@ -40,7 +40,8 @@ data class AnalyticsQuery( result.from = result.to result.to = from } - result.interval = request.getParameter("interval")?.takeIf { it in setOf("hour", "day", "week", "month") } ?: "day" + result.interval = + request.getParameter("interval")?.takeIf { it in setOf("hour", "day", "week", "month") } ?: "day" result.groupBy = request.getParameter("group_by")?.takeIf { it in FILTERS } ?: "country" result.includeSummary = !request.getParameter("include_summary").equals("false", ignoreCase = true) result.includeBreakdown = !request.getParameter("include_breakdown").equals("false", ignoreCase = true) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/BackgroundJob.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/BackgroundJob.kt index 9a8620a..33fb270 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/BackgroundJob.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/BackgroundJob.kt @@ -27,12 +27,17 @@ class BackgroundJob( @Column(nullable = false, updatable = false) var createdAt: Instant? = null, @Column(nullable = false) var updatedAt: Instant? = null, ) { - @PrePersist fun prePersist() { + @PrePersist + fun prePersist() { val now = Instant.now() if (createdAt == null) createdAt = now updatedAt = now } - @PreUpdate fun preUpdate() { updatedAt = Instant.now() } + + @PreUpdate + fun preUpdate() { + updatedAt = Instant.now() + } enum class Type { PASTE_AI_INFO } enum class Status { PENDING, RUNNING, DONE, FAILED } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/Notification.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/Notification.kt index 3932e44..a86b8b3 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/Notification.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/Notification.kt @@ -22,10 +22,15 @@ class Notification( @Column(nullable = false, updatable = false) var createdAt: Instant? = null, @Column(nullable = false) var updatedAt: Instant? = null, ) { - @PrePersist fun prePersist() { + @PrePersist + fun prePersist() { val now = Instant.now() if (createdAt == null) createdAt = now updatedAt = now } - @PreUpdate fun preUpdate() { updatedAt = Instant.now() } + + @PreUpdate + fun preUpdate() { + updatedAt = Instant.now() + } } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/Paste.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/Paste.kt index 12cecac..a5acd30 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/Paste.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/Paste.kt @@ -86,7 +86,7 @@ class Paste( @Column(length = 64) var hash: String? = null, -) { + ) { @Transient var cachedContents: String? = null diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/PasteAIInfo.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/PasteAIInfo.kt index 1ec95b8..b7d4538 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/PasteAIInfo.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/PasteAIInfo.kt @@ -25,12 +25,17 @@ class PasteAIInfo( @Column(nullable = false, updatable = false) var createdAt: Instant? = null, @Column(nullable = false) var updatedAt: Instant? = null, ) { - @PrePersist fun prePersist() { + @PrePersist + fun prePersist() { val now = Instant.now() if (createdAt == null) createdAt = now updatedAt = now } - @PreUpdate fun preUpdate() { updatedAt = Instant.now() } + + @PreUpdate + fun preUpdate() { + updatedAt = Instant.now() + } } data class AIWarning( @@ -44,7 +49,8 @@ class StringListJsonConverter : AttributeConverter?, String? attribute?.let(MAPPER::writeValueAsString) override fun convertToEntityAttribute(dbData: String?): MutableList? = - dbData?.takeIf(String::isNotBlank)?.let { MAPPER.readValue(it, object : TypeReference>() {}) } + dbData?.takeIf(String::isNotBlank) + ?.let { MAPPER.readValue(it, object : TypeReference>() {}) } } @Converter @@ -53,7 +59,8 @@ class AIWarningListJsonConverter : AttributeConverter?, S attribute?.let(MAPPER::writeValueAsString) override fun convertToEntityAttribute(dbData: String?): MutableList? = - dbData?.takeIf(String::isNotBlank)?.let { MAPPER.readValue(it, object : TypeReference>() {}) } + dbData?.takeIf(String::isNotBlank) + ?.let { MAPPER.readValue(it, object : TypeReference>() {}) } } private val MAPPER = jacksonObjectMapper() diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/PasteComment.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/PasteComment.kt index 0659a4f..bf349a5 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/PasteComment.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/PasteComment.kt @@ -24,10 +24,15 @@ class PasteComment( @Column(nullable = false, updatable = false) var createdAt: Instant? = null, @Column(nullable = false) var updatedAt: Instant? = null, ) { - @PrePersist fun prePersist() { + @PrePersist + fun prePersist() { val now = Instant.now() if (createdAt == null) createdAt = now updatedAt = now } - @PreUpdate fun preUpdate() { updatedAt = Instant.now() } + + @PreUpdate + fun preUpdate() { + updatedAt = Instant.now() + } } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/PublicPasteEngagement.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/PublicPasteEngagement.kt index 7aecbea..d92586d 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/PublicPasteEngagement.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/PublicPasteEngagement.kt @@ -18,10 +18,15 @@ class PublicPasteEngagement( @Column(nullable = false, updatable = false) var createdAt: Instant? = null, @Column(nullable = false) var updatedAt: Instant? = null, ) { - @PrePersist fun prePersist() { + @PrePersist + fun prePersist() { val now = Instant.now() if (createdAt == null) createdAt = now updatedAt = now } - @PreUpdate fun preUpdate() { updatedAt = Instant.now() } + + @PreUpdate + fun preUpdate() { + updatedAt = Instant.now() + } } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/SharedPaste.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/SharedPaste.kt index 983c40c..1904ad3 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/SharedPaste.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/SharedPaste.kt @@ -19,10 +19,15 @@ class SharedPaste( @Column(nullable = false, updatable = false) var createdAt: Instant? = null, @Column(nullable = false) var updatedAt: Instant? = null, ) { - @PrePersist fun prePersist() { + @PrePersist + fun prePersist() { val now = Instant.now() if (createdAt == null) createdAt = now updatedAt = now } - @PreUpdate fun preUpdate() { updatedAt = Instant.now() } + + @PreUpdate + fun preUpdate() { + updatedAt = Instant.now() + } } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/exceptions/ApiExceptionHandler.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/exceptions/ApiExceptionHandler.kt index 1a47cba..d707c54 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/exceptions/ApiExceptionHandler.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/exceptions/ApiExceptionHandler.kt @@ -19,7 +19,8 @@ class ApiExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException::class) fun handleValidationException(exception: MethodArgumentNotValidException): ResponseEntity { val message = exception.bindingResult.fieldErrors.joinToString("; ") { "${it.field}: ${it.defaultMessage}" } - return ResponseEntity.badRequest().body(ExceptionResponse(exception::class.simpleName ?: "ValidationException", message)) + return ResponseEntity.badRequest() + .body(ExceptionResponse(exception::class.simpleName ?: "ValidationException", message)) } @ExceptionHandler(Exception::class) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/PasteAIInfoService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/PasteAIInfoService.kt index e899484..c362e52 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/PasteAIInfoService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/PasteAIInfoService.kt @@ -90,11 +90,11 @@ class PasteAIInfoService( private fun isEligible(paste: Paste): Boolean = paste.isPublic && !paste.encrypted && - engagementRepository.findByPasteId(requireNotNull(paste.id))?.score.orZero() >= threshold() + engagementRepository.findByPasteId(requireNotNull(paste.id))?.score.orZero() >= threshold() private fun isCurrent(info: PasteAIInfo?, sourceVersion: Int): Boolean = info != null && info.sourcePasteVersion == sourceVersion && info.promptVersion == PROMPT_VERSION && - info.provider == pasteAI.provider.take(30) && info.model == pasteAI.model.take(100) + info.provider == pasteAI.provider.take(30) && info.model == pasteAI.model.take(100) private fun threshold() = properties.ai.engagementThreshold.coerceAtLeast(1) private fun Int?.orZero() = this ?: 0 diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsService.kt index 954730f..2f08e79 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsService.kt @@ -34,7 +34,8 @@ class AnalyticsService( ) { private val config = properties.analytics private val table = "${identifier(config.database)}.${identifier(config.table)}" - private val queue = ArrayBlockingQueue(config.queueCapacity.coerceAtLeast(config.batchSize.coerceAtLeast(1))) + private val queue = + ArrayBlockingQueue(config.queueCapacity.coerceAtLeast(config.batchSize.coerceAtLeast(1))) private val droppedEvents = AtomicLong() private val writerFailures = AtomicLong() private val geoIpReader = createGeoIpReader(config.geoIpMmdbPath) @@ -92,17 +93,25 @@ class AnalyticsService( } jdbc.queryForList( "SELECT formatDateTime($bucket, '%FT%TZ', 'UTC') AS bucket, count() AS visits, " + - "uniqExact(ip_hash) AS unique_visitors FROM $table$where GROUP BY bucket ORDER BY bucket", + "uniqExact(ip_hash) AS unique_visitors FROM $table$where GROUP BY bucket ORDER BY bucket", ).forEach { - response.series += SeriesPoint(it["bucket"].toString(), (it["visits"] as Number).toLong(), (it["unique_visitors"] as Number).toLong()) + response.series += SeriesPoint( + it["bucket"].toString(), + (it["visits"] as Number).toLong(), + (it["unique_visitors"] as Number).toLong() + ) } } if (query.includeBreakdown) { jdbc.queryForList( "SELECT toString(${query.groupBy}) AS value, count() AS visits, uniqExact(ip_hash) AS unique_visitors " + - "FROM $table$where GROUP BY value ORDER BY visits DESC LIMIT 25", + "FROM $table$where GROUP BY value ORDER BY visits DESC LIMIT 25", ).forEach { - response.breakdown += BreakdownPoint(it["value"].toString(), (it["visits"] as Number).toLong(), (it["unique_visitors"] as Number).toLong()) + response.breakdown += BreakdownPoint( + it["value"].toString(), + (it["visits"] as Number).toLong(), + (it["unique_visitors"] as Number).toLong() + ) } } return response @@ -117,7 +126,7 @@ class AnalyticsService( try { jdbc.batchUpdate( "INSERT INTO $table (paste_key, paste_visibility, paste_user_id, visit_type, visited_at, country, region, city, " + - "visitor_user_id, browser, device_type, os, ip_hash, referer_host, acquisition, is_bot) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "visitor_user_id, browser, device_type, os, ip_hash, referer_host, acquisition, is_bot) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", batch, batch.size, ) { statement, event -> @@ -141,7 +150,11 @@ class AnalyticsService( writerFailures.set(0) } catch (exception: RuntimeException) { val failures = writerFailures.incrementAndGet() - if (failures == 1L || failures % 60L == 0L) LOGGER.warn("Could not write analytics batch ({} consecutive failures)", failures, exception) + if (failures == 1L || failures % 60L == 0L) LOGGER.warn( + "Could not write analytics batch ({} consecutive failures)", + failures, + exception + ) batch.forEach { if (!queue.offer(it)) logDroppedEvent() } return } @@ -162,7 +175,11 @@ class AnalyticsService( if (!config.trackBots) clauses += "is_bot = 0" query.filters.forEach { (field, value) -> require(field in AnalyticsQuery.FILTERS) { "Unsupported analytics filter" } - clauses += if (field == "is_bot") "is_bot = ${if (value == "1" || value.toBoolean()) 1 else 0}" else "$field = '${escape(value)}'" + clauses += if (field == "is_bot") "is_bot = ${if (value == "1" || value.toBoolean()) 1 else 0}" else "$field = '${ + escape( + value + ) + }'" } return " WHERE ${clauses.joinToString(" AND ")}" } @@ -171,7 +188,11 @@ class AnalyticsService( val value = when { config.ipHeader.isNotBlank() -> request.getHeader(config.ipHeader) config.ipSource.lowercase() in setOf("x-forwarded-for", "xff") -> request.getHeader("X-Forwarded-For") - config.ipSource.lowercase() in setOf("cloudflare", "cf-connecting-ip") -> request.getHeader("CF-Connecting-IP") + config.ipSource.lowercase() in setOf( + "cloudflare", + "cf-connecting-ip" + ) -> request.getHeader("CF-Connecting-IP") + else -> request.remoteAddr }?.substringBefore(',')?.trim().orEmpty() if (value.startsWith("[") && value.contains(']')) return value.substring(1, value.indexOf(']')) @@ -179,7 +200,11 @@ class AnalyticsService( } private fun hashIp(ip: String): BigInteger? = ip.takeIf(String::isNotBlank)?.let { - BigInteger(1, MessageDigest.getInstance("SHA-256").digest("${config.ipHashSalt}:$it".toByteArray(StandardCharsets.UTF_8)).copyOf(8)) + BigInteger( + 1, + MessageDigest.getInstance("SHA-256").digest("${config.ipHashSalt}:$it".toByteArray(StandardCharsets.UTF_8)) + .copyOf(8) + ) } private fun lookupGeo(ip: String): GeoLocation { @@ -187,20 +212,32 @@ class AnalyticsService( if (ip.isBlank()) return GeoLocation() return runCatching { val city = reader.tryCity(InetAddress.getByName(ip)).orElse(null) ?: return GeoLocation() - GeoLocation(city.country.isoCode.orEmpty(), city.mostSpecificSubdivision.isoCode.orEmpty(), city.city.name.orEmpty()) + GeoLocation( + city.country.isoCode.orEmpty(), + city.mostSpecificSubdivision.isoCode.orEmpty(), + city.city.name.orEmpty() + ) }.getOrDefault(GeoLocation()) } private fun createGeoIpReader(path: String): DatabaseReader? = path.takeIf(String::isNotBlank)?.let { runCatching { DatabaseReader.Builder(File(it)).withCache(CHMCache()).build() } - .onFailure { error -> LOGGER.warn("GeoIP database could not be loaded; location fields stay empty", error) } + .onFailure { error -> + LOGGER.warn( + "GeoIP database could not be loaded; location fields stay empty", + error + ) + } .getOrNull() } private fun logDroppedEvent() { val dropped = droppedEvents.incrementAndGet() - if (dropped == 1L || dropped % 1_000L == 0L) LOGGER.warn("Analytics queue is full; dropped {} visit event(s)", dropped) + if (dropped == 1L || dropped % 1_000L == 0L) LOGGER.warn( + "Analytics queue is full; dropped {} visit event(s)", + dropped + ) } private fun refererHost(value: String?): String = runCatching { @@ -210,14 +247,32 @@ class AnalyticsService( private fun acquisition(referer: String, requestHost: String?): String { if (referer.isBlank()) return "DIRECT" if (referer == requestHost.orEmpty().substringBefore(':').lowercase()) return "INTERNAL" - if (referer.containsAny("google.", "bing.", "duckduckgo.", "yahoo.", "ecosia.", "brave.")) return "ORGANIC_SEARCH" + if (referer.containsAny( + "google.", + "bing.", + "duckduckgo.", + "yahoo.", + "ecosia.", + "brave." + ) + ) return "ORGANIC_SEARCH" if (referer.containsAny("github.com", "gitlab.com")) return "DEVELOPER_REFERRAL" - if (referer.containsAny("twitter.com", "x.com", "facebook.com", "linkedin.com", "reddit.com", "mastodon.", "bsky.app")) return "SOCIAL" + if (referer.containsAny( + "twitter.com", + "x.com", + "facebook.com", + "linkedin.com", + "reddit.com", + "mastodon.", + "bsky.app" + ) + ) return "SOCIAL" return "REFERRAL" } private fun identifier(value: String): String = value.takeIf { it.matches(Regex("[A-Za-z_][A-Za-z0-9_]*")) } ?: throw IllegalArgumentException("Invalid ClickHouse identifier: $value") + private fun escape(value: String) = value.replace("\\", "\\\\").replace("'", "\\'") private data class VisitEvent( @@ -226,12 +281,25 @@ class AnalyticsService( val browser: String, val deviceType: String, val os: String, val ipHash: BigInteger?, val refererHost: String, val acquisition: String, val isBot: Boolean, ) + private data class GeoLocation(val country: String = "", val region: String = "", val city: String = "") private data class UserAgentInfo(val browser: String, val deviceType: String, val os: String, val bot: Boolean) { companion object { fun parse(raw: String?): UserAgentInfo { val ua = raw.orEmpty().lowercase() - val bot = ua.containsAny("bot", "crawler", "spider", "slurp", "preview", "wget", "curl/", "httpclient", "python-requests", "uptime", "monitoring") + val bot = ua.containsAny( + "bot", + "crawler", + "spider", + "slurp", + "preview", + "wget", + "curl/", + "httpclient", + "python-requests", + "uptime", + "monitoring" + ) val device = when { ua.containsAny("ipad", "tablet") -> "TABLET" ua.containsAny("mobile", "iphone", "android") -> "MOBILE" @@ -239,11 +307,19 @@ class AnalyticsService( else -> "DESKTOP" } val browser = when { - "edg/" in ua -> "EDGE"; "firefox/" in ua -> "FIREFOX"; ua.containsAny("chrome/", "crios/") -> "CHROME" + "edg/" in ua -> "EDGE"; "firefox/" in ua -> "FIREFOX"; ua.containsAny( + "chrome/", + "crios/" + ) -> "CHROME" + "safari/" in ua -> "SAFARI"; "curl/" in ua -> "CURL"; bot -> "BOT"; else -> "UNKNOWN" } val os = when { - "windows" in ua -> "WINDOWS"; ua.containsAny("iphone", "ipad", "ios") -> "IOS"; "android" in ua -> "ANDROID" + "windows" in ua -> "WINDOWS"; ua.containsAny( + "iphone", + "ipad", + "ios" + ) -> "IOS"; "android" in ua -> "ANDROID" ua.containsAny("mac os", "macintosh") -> "MACOS"; "linux" in ua -> "LINUX"; else -> "UNKNOWN" } return UserAgentInfo(browser, device, os, bot) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/jobs/BackgroundJobService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/jobs/BackgroundJobService.kt index f2ea957..29ca8e0 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/jobs/BackgroundJobService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/jobs/BackgroundJobService.kt @@ -110,7 +110,8 @@ class BackgroundJobService( } else { status = BackgroundJob.Status.PENDING val exponent = (attempts - 1).coerceIn(0, 10) - availableAt = Instant.now().plusSeconds(properties.ai.jobs.retryDelaySeconds.coerceAtLeast(1) * (1L shl exponent)) + availableAt = + Instant.now().plusSeconds(properties.ai.jobs.retryDelaySeconds.coerceAtLeast(1) * (1L shl exponent)) } } LOGGER.warn("Background job failed: {}", job.key, exception) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/s3/S3PasteService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/s3/S3PasteService.kt index 93c9a66..af38025 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/s3/S3PasteService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/s3/S3PasteService.kt @@ -75,7 +75,8 @@ class S3PasteService( } private fun objectName(paste: Paste): String { - val prefix = paste.key.takeIf { it.length >= 4 }?.let { "${it.substring(0, 2)}/${it.substring(2, 4)}/" }.orEmpty() + val prefix = + paste.key.takeIf { it.length >= 4 }?.let { "${it.substring(0, 2)}/${it.substring(2, 4)}/" }.orEmpty() return "pastes/${paste.userId ?: "anonymous"}/$prefix${paste.key}/contents.txt" } } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/NotificationRepository.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/NotificationRepository.kt index f91fdee..b8693cd 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/NotificationRepository.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/NotificationRepository.kt @@ -7,6 +7,11 @@ interface NotificationRepository : JpaRepository { fun findAllByUserId(userId: String): List fun findAllByUserIdAndReceived(userId: String, received: Boolean): List fun findAllByUserIdAndAlreadyRead(userId: String, alreadyRead: Boolean): List - fun findAllByUserIdAndReceivedAndAlreadyRead(userId: String, received: Boolean, alreadyRead: Boolean): List + fun findAllByUserIdAndReceivedAndAlreadyRead( + userId: String, + received: Boolean, + alreadyRead: Boolean + ): List + fun deleteByUserId(userId: String) } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteCommentRepository.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteCommentRepository.kt index 6722c3b..8f148c5 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteCommentRepository.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteCommentRepository.kt @@ -7,7 +7,12 @@ import org.springframework.data.domain.Pageable interface PasteCommentRepository : JpaRepository { fun findAllByPaste(paste: String): List fun findAllByPasteAndParentIdIsNullOrderByCreatedAtDesc(paste: String, pageable: Pageable): List - fun findAllByPasteAndParentIdIsNullAndLineFromOrderByCreatedAtDesc(paste: String, lineFrom: Int, pageable: Pageable): List + fun findAllByPasteAndParentIdIsNullAndLineFromOrderByCreatedAtDesc( + paste: String, + lineFrom: Int, + pageable: Pageable + ): List + fun findAllByPasteAndLineFromIsNotNullOrderByCreatedAtAsc(paste: String): List fun findAllByParentIdOrderByCreatedAtAsc(parentId: Int): List fun deleteByParentId(parentId: Int) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/FolderService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/FolderService.kt index 7aad330..7939888 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/FolderService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/FolderService.kt @@ -54,7 +54,15 @@ class FolderService( return folderRepository.findAll( specification, PageRequest.of(page - 1, pageLimit, Sort.by(Sort.Direction.DESC, "createdAt")), - ).content.map { map(it, fetchChildren = false, fetchSubChildren = false, fetchPastes = false, showPrivate = false) } + ).content.map { + map( + it, + fetchChildren = false, + fetchSubChildren = false, + fetchPastes = false, + showPrivate = false + ) + } } fun get(id: String): Folder = folderRepository.findByKey(id) ?: throw NotFoundException() diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteCommandService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteCommandService.kt index 3371fd7..8b5af98 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteCommandService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteCommandService.kt @@ -44,13 +44,20 @@ class PasteCommandService( ).apply { setDatabaseContent(request.content) } val saved = pasteService.save(paste) syncTags(saved, request.tags.orEmpty()) - request.forkedFrom?.let(pasteRepository::findByKey)?.takeIf(Paste::isPublic)?.let { engagement.addInterest(it, 10) } + request.forkedFrom?.let(pasteRepository::findByKey)?.takeIf(Paste::isPublic) + ?.let { engagement.addInterest(it, 10) } if (request.ai && !request.encrypted) aiProvider.ifAvailable?.enqueueIfEligible(saved, force = true) return saved } @Transactional - fun createUploaded(title: String?, content: String, type: PasteType, user: User?, tags: List = emptyList()): Paste { + fun createUploaded( + title: String?, + content: String, + type: PasteType, + user: User?, + tags: List = emptyList() + ): Paste { val paste = Paste(title = title, userId = user?.id, type = type).apply { setDatabaseContent(content) } val saved = pasteService.save(paste) syncTags(saved, tags) @@ -62,7 +69,9 @@ class PasteCommandService( val paste = requireOwned(key, user) request.title?.let { paste.title = it } request.content?.let(paste::setDatabaseContent) - request.folder?.let { paste.folder = folderRepository.findByKey(it)?.takeIf { folder -> folder.userId == user.id }?.key } + request.folder?.let { + paste.folder = folderRepository.findByKey(it)?.takeIf { folder -> folder.userId == user.id }?.key + } request.type?.let { paste.type = it } request.encrypted?.let { paste.encrypted = it } request.visibility?.let { paste.visibility = it } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteCommentService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteCommentService.kt index 5f1b9c5..4d2f29c 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteCommentService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteCommentService.kt @@ -27,7 +27,11 @@ class PasteCommentService( val comments = if (line == null) { commentRepository.findAllByPasteAndParentIdIsNullOrderByCreatedAtDesc(pasteId, pageable) } else { - commentRepository.findAllByPasteAndParentIdIsNullAndLineFromOrderByCreatedAtDesc(pasteId, line.coerceAtLeast(1), pageable) + commentRepository.findAllByPasteAndParentIdIsNullAndLineFromOrderByCreatedAtDesc( + pasteId, + line.coerceAtLeast(1), + pageable + ) } return comments.map { map(it, fetchReplies = true) } } @@ -99,9 +103,10 @@ class PasteCommentService( lineTo = comment.lineTo, createdAt = comment.createdAt?.toString() ?: "0000-00-00 00:00:00", user = userRepository.findById(comment.userId).orElse(null)?.toPublicDto(), - replies = if (fetchReplies) commentRepository.findAllByParentIdOrderByCreatedAtAsc(requireNotNull(comment.id)).map { - map(it, fetchReplies = false) - } else emptyList(), + replies = if (fetchReplies) commentRepository.findAllByParentIdOrderByCreatedAtAsc(requireNotNull(comment.id)) + .map { + map(it, fetchReplies = false) + } else emptyList(), ) private fun badRequest(message: String): Nothing = throw HttpException(HttpStatus.BAD_REQUEST, message) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteQueryService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteQueryService.kt index a5d61db..36bbb99 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteQueryService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteQueryService.kt @@ -39,12 +39,22 @@ class PasteQueryService( val specification = specification(request, user, guarded, visibility, encrypted, userId, starredBy) val sort = Sort.by(Sort.Direction.DESC, sortable(request.getParameter("sort"))) return pasteRepository.findAll(specification, PageRequest.of(paging.page - 1, paging.limit, sort)).content - .map { mapper.map(it, user, fetchStar = user != null, fetchUser = true, request.withAiInfo(), request.shortenContent()) } + .map { + mapper.map( + it, + user, + fetchStar = user != null, + fetchUser = true, + request.withAiInfo(), + request.shortenContent() + ) + } } fun trending(request: HttpServletRequest, response: HttpServletResponse): List { val paging = paging(request, response) - val createdAfter = if (request.parameterMap.containsKey("trending")) Instant.now().minus(4, ChronoUnit.DAYS) else null + val createdAfter = + if (request.parameterMap.containsKey("trending")) Instant.now().minus(4, ChronoUnit.DAYS) else null return pasteRepository.findTrending(createdAfter, PageRequest.of(paging.page - 1, paging.limit)) .map { mapper.map(it, shortenContent = request.shortenContent(), withAiInfo = request.withAiInfo()) } } @@ -81,7 +91,8 @@ class PasteQueryService( } starredBy?.let { predicates += starredPredicate(criteriaQuery, builder, root.get("key"), it) } if (guarded && user?.isAdmin != true) { - val visible = mutableListOf(builder.equal(root.get("visibility"), PasteVisibility.PUBLIC)) + val visible = + mutableListOf(builder.equal(root.get("visibility"), PasteVisibility.PUBLIC)) user?.let { visible += builder.equal(root.get("userId"), it.id) visible += starredPredicate(criteriaQuery, builder, root.get("key"), it.id) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteService.kt index 68304e2..575c50d 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteService.kt @@ -100,5 +100,6 @@ class PasteService( } private fun requireS3(): S3PasteService = - s3Provider.ifAvailable ?: error("S3 storage is required to access paste content but pastefy.s3.enabled is false") + s3Provider.ifAvailable + ?: error("S3 storage is required to access paste content but pastefy.s3.enabled is false") } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteThumbnailService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteThumbnailService.kt index fb53208..59cb2a5 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteThumbnailService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteThumbnailService.kt @@ -14,7 +14,8 @@ import java.nio.charset.StandardCharsets @Service class PasteThumbnailService { - private val template = ClassPathResource("templates/thumbnail-svg.svg").inputStream.bufferedReader().use { it.readText() } + private val template = + ClassPathResource("templates/thumbnail-svg.svg").inputStream.bufferedReader().use { it.readText() } init { registerFont("fonts/PlusJakartaSans.ttf") diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/SeoRenderer.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/SeoRenderer.kt index 410d144..424b3f1 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/SeoRenderer.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/SeoRenderer.kt @@ -47,10 +47,15 @@ class SeoRenderer( private fun prepareHtml(source: String): String { val start = source.indexOf(META_START_TAG) val end = source.indexOf(META_END_TAG, start + META_START_TAG.length) + require(start >= 0 && end >= 0) { "SEO metadata placeholders are missing in static/index.html" } + var prepared = source.substring(0, start) + META_REPLACEMENT + source.substring(end + META_END_TAG.length) + prepared = APP_MOUNT.replace(prepared) { result -> result.groupValues[1] + SEO_CONTENT_REPLACEMENT + "" } + require(SEO_CONTENT_REPLACEMENT in prepared) { "Vue app mount is missing in static/index.html" } + return if (TITLE.containsMatchIn(prepared)) { TITLE.replaceFirst(prepared, "$TITLE_REPLACEMENT") } else { @@ -72,7 +77,11 @@ class SeoRenderer( fun absoluteUrl(pathOrUrl: String): String { if (pathOrUrl.startsWith("http://", true) || pathOrUrl.startsWith("https://", true)) return pathOrUrl val configured = properties.serverName.trim().ifBlank { "http://localhost" } - val base = if (configured.startsWith("http://", true) || configured.startsWith("https://", true)) configured else "https://$configured" + val base = if (configured.startsWith("http://", true) || configured.startsWith( + "https://", + true + ) + ) configured else "https://$configured" return base.trimEnd('/') + "/" + pathOrUrl.trimStart('/') } From e4935495691fee2e6595644729398c7afe75fab4 Mon Sep 17 00:00:00 2001 From: juliangojani Date: Thu, 4 Jun 2026 21:11:31 +0200 Subject: [PATCH 08/22] Initial spring-boot-kotlin --- backend-kt/.gitignore | 2 + backend-kt/TODOS.md | 18 +- backend-kt/docker-compose.yaml | 88 +- .../pastefy/config/PastefyProperties.kt | 8 + .../pastefy/config/WebConfiguration.kt | 12 + .../pastefy/controller/FrontendController.kt | 45 + .../pastes/PasteMetaSSRController.kt | 15 +- .../pastefy/controller/user/UserController.kt | 6 +- .../de/interaapps/pastefy/entities/Paste.kt | 42 +- .../infrastructure/ai/PasteAIInfoService.kt | 6 +- .../analytics/AnalyticsService.kt | 3 +- .../elastic/ElasticPasteQueryAdapter.kt | 190 ++++ .../elastic/ElasticPasteService.kt | 11 +- .../jobs/BackgroundJobService.kt | 4 +- .../infrastructure/s3/S3Configuration.kt | 22 + .../infrastructure/s3/S3PasteService.kt | 13 +- .../seeding/LocalTestingSeeder.kt | 567 +++++++++++ .../pastefy/service/FrontendIndexService.kt | 27 + .../pastefy/service/PasteQueryService.kt | 150 ++- .../pastefy/service/PasteResponseMapper.kt | 6 +- .../pastefy/service/PasteService.kt | 3 + .../interaapps/pastefy/service/SeoRenderer.kt | 48 +- .../service/query/JpaPasteQueryAdapter.kt | 210 ++++ .../pastefy/service/query/LegacyPasteQuery.kt | 52 + .../service/query/LegacyPasteQueryParser.kt | 174 ++++ .../resources/application-local.properties | 34 + .../src/main/resources/application.properties | 8 +- .../resources/db/changelog/001-baseline.yaml | 910 ++++++++++++++++++ .../changelog/002-json-transformations.yaml | 18 + .../db/changelog/db.changelog-master.yaml | 8 +- .../src/main/resources/static/index.html | 4 +- .../pastefy/controller/ControllerHttpTest.kt | 117 +++ .../PasteCommentRepositoryIntegrationTest.kt | 50 + .../pastefy/service/SeoRendererTest.kt | 4 +- .../query/LegacyPasteQueryParserTest.kt | 52 + .../pastefy/model/database/Paste.java | 1 + frontend/vite.config.ts | 2 +- 37 files changed, 2764 insertions(+), 166 deletions(-) create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/FrontendController.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteQueryAdapter.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/seeding/LocalTestingSeeder.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/service/FrontendIndexService.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/JpaPasteQueryAdapter.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQuery.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQueryParser.kt create mode 100644 backend-kt/src/main/resources/application-local.properties create mode 100644 backend-kt/src/main/resources/db/changelog/001-baseline.yaml create mode 100644 backend-kt/src/main/resources/db/changelog/002-json-transformations.yaml create mode 100644 backend-kt/src/test/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQueryParserTest.kt diff --git a/backend-kt/.gitignore b/backend-kt/.gitignore index b1dff0d..cb4d618 100644 --- a/backend-kt/.gitignore +++ b/backend-kt/.gitignore @@ -16,6 +16,8 @@ out/ !**/src/main/**/out/ !**/src/test/**/out/ +src/main/resources/static + ### Kotlin ### .kotlin diff --git a/backend-kt/TODOS.md b/backend-kt/TODOS.md index 0ebde7b..12e310f 100644 --- a/backend-kt/TODOS.md +++ b/backend-kt/TODOS.md @@ -19,16 +19,16 @@ Ziel: `backend-kt` ersetzt `backend` ohne Verhaltensänderungen für bestehende ## P0: Listenabfragen und Response-Mapping - [x] Einen gemeinsamen Mapper `Paste -> PasteResponse` implementieren, inklusive `raw_url`, Tags, User, Starred-Status, optionaler AI-Analyse und Inhaltskürzung. -- [ ] `shorten_content=true` exakt nachbauen: Inhalte über 303 Zeichen auf 300 Zeichen plus `...` kürzen. -- [ ] `with_ai_analysis=true` exakt nachbauen und `PasteAiInfoResponse` nur dann einbetten. -- [ ] Einen gemeinsamen Listen-Query-Service für SQL und Elasticsearch implementieren. -- [ ] Legacy-Query-Parameter unterstützen: `page`, `page_limit`, `search`, `sort`, `filter`, `filters`, `filter_tags`, `shorten_content`. -- [ ] Das konfigurierte Maximum `PASTEFY_PAGINATION_PAGE_LIMIT` nachbauen. -- [ ] Legacy-Sichtbarkeitsregeln nachbauen: Nicht-Admins sehen öffentliche Pastes sowie eigene und selbst markierte Pastes; Admins dürfen ungefiltert lesen. +- [x] `shorten_content=true` exakt nachbauen: Inhalte über 303 Zeichen auf 300 Zeichen plus `...` kürzen. +- [x] `with_ai_analysis=true` exakt nachbauen und `PasteAiInfoResponse` nur dann einbetten. +- [x] Einen gemeinsamen Listen-Query-Service für SQL und Elasticsearch implementieren. +- [x] Legacy-Query-Parameter unterstützen: `page`, `page_limit`, `search`, `sort`, `filter`, `filters`, `filter_tags`, `shorten_content`. +- [x] Das konfigurierte Maximum `PASTEFY_PAGINATION_PAGE_LIMIT` nachbauen. +- [x] Legacy-Sichtbarkeitsregeln nachbauen: Nicht-Admins sehen öffentliche Pastes sowie eigene und selbst markierte Pastes; Admins dürfen ungefiltert lesen. - [ ] SQL- und Elasticsearch-Ergebnisse auf identische Reihenfolge, Filterung und DTOs bringen. -- [ ] Elasticsearch-Volltextsuche mit Legacy-Gewichtung nachbauen: `title^3`, `content`, `user.name`, `user.uniqueName`. -- [ ] Elasticsearch-Filter für Tags, Sichtbarkeit, Verschlüsselung, User, Stars, Folder und Zeitwerte nachbauen. -- [ ] SQL-Fallback für `starredBy`, Tagfilter und Sortierung nach `engagementScore` nachbauen. +- [x] Elasticsearch-Volltextsuche mit Legacy-Gewichtung nachbauen: `title^3`, `content`, `user.name`, `user.uniqueName`. +- [x] Elasticsearch-Filter für Tags, Sichtbarkeit, Verschlüsselung, User, Stars, Folder und Zeitwerte nachbauen. +- [x] SQL-Fallback für `starredBy`, Tagfilter und Sortierung nach `engagementScore` nachbauen. - [ ] Prüfen, ob `PASTEFY_LIST_PASTES` weiterhin benötigt wird, und das Legacy-Verhalten für anonyme Listenabfragen übernehmen. ## Paste-API diff --git a/backend-kt/docker-compose.yaml b/backend-kt/docker-compose.yaml index 93ae5fe..2925469 100644 --- a/backend-kt/docker-compose.yaml +++ b/backend-kt/docker-compose.yaml @@ -1,6 +1,6 @@ services: mysql: - image: 'mysql:latest' + image: 'mysql:8.4' environment: - 'MYSQL_DATABASE=mydatabase' - 'MYSQL_PASSWORD=secret' @@ -10,6 +10,90 @@ services: - '3306:3306' volumes: - 'pastefy-test-mysql-data:/var/lib/mysql' + healthcheck: + test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'myuser', '-psecret'] + interval: 10s + timeout: 5s + retries: 10 + + redis: + image: 'redis:7.4-alpine' + ports: + - '6379:6379' + volumes: + - 'pastefy-test-redis-data:/data' + command: ['redis-server', '--appendonly', 'yes'] + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 10s + timeout: 5s + retries: 10 + + minio: + image: 'minio/minio:RELEASE.2025-04-22T22-12-26Z' + environment: + - 'MINIO_ROOT_USER=minioadmin' + - 'MINIO_ROOT_PASSWORD=minioadmin' + ports: + - '9000:9000' + - '9001:9001' + volumes: + - 'pastefy-test-minio-data:/data' + command: ['server', '/data', '--console-address', ':9001'] + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live'] + interval: 10s + timeout: 5s + retries: 10 + + elasticsearch: + image: 'docker.elastic.co/elasticsearch/elasticsearch:8.17.4' + environment: + - 'discovery.type=single-node' + - 'xpack.security.enabled=false' + - 'xpack.security.http.ssl.enabled=false' + - 'ES_JAVA_OPTS=-Xms1g -Xmx1g' + ports: + - '9200:9200' + volumes: + - 'pastefy-test-elasticsearch-data:/usr/share/elasticsearch/data' + healthcheck: + test: ['CMD-SHELL', 'curl -fsS http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=1s >/dev/null'] + interval: 10s + timeout: 5s + retries: 30 + + clickhouse: + image: 'clickhouse/clickhouse-server:24.12-alpine' + labels: + org.springframework.boot.ignore: true + environment: + - 'CLICKHOUSE_DB=pastefy' + - 'CLICKHOUSE_USER=pastefy' + - 'CLICKHOUSE_PASSWORD=pastefy' + - 'CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1' + ports: + - '8123:8123' + - '9002:9000' + volumes: + - 'pastefy-test-clickhouse-data:/var/lib/clickhouse' + - 'pastefy-test-clickhouse-logs:/var/log/clickhouse-server' + healthcheck: + test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:8123/ping'] + interval: 10s + timeout: 5s + retries: 30 + volumes: pastefy-test-mysql-data: - driver: local \ No newline at end of file + driver: local + pastefy-test-redis-data: + driver: local + pastefy-test-minio-data: + driver: local + pastefy-test-elasticsearch-data: + driver: local + pastefy-test-clickhouse-data: + driver: local + pastefy-test-clickhouse-logs: + driver: local diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/PastefyProperties.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/PastefyProperties.kt index 5594ebb..4370a2e 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/PastefyProperties.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/PastefyProperties.kt @@ -17,6 +17,8 @@ data class PastefyProperties( var customLogo: String? = null, var customName: String? = null, var customFooter: String = "", + var customHeader: String = "", + var customBody: String = "", var grantAccessRequired: Boolean = false, var oauthStateSecret: String = "", var oauth: OAuth = OAuth(), @@ -26,7 +28,12 @@ data class PastefyProperties( var elasticsearch: Elasticsearch = Elasticsearch(), var ai: AI = AI(), var analytics: Analytics = Analytics(), + var seeding: Seeding = Seeding(), ) { + data class Seeding( + var enabled: Boolean = false, + ) + data class RateLimiter( var enabled: Boolean = true, var windowMillis: Long = 5_000, @@ -48,6 +55,7 @@ data class PastefyProperties( var bucket: String = "pastefy", var region: String? = null, var pasteSizeThreshold: Int = -1, + var createBucket: Boolean = false, ) data class Elasticsearch( diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/WebConfiguration.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/WebConfiguration.kt index 9f8dc48..00075d9 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/WebConfiguration.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/WebConfiguration.kt @@ -5,7 +5,9 @@ import de.interaapps.pastefy.auth.AuthInterceptor import org.springframework.context.annotation.Configuration import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.servlet.config.annotation.InterceptorRegistry +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry import org.springframework.web.servlet.config.annotation.WebMvcConfigurer +import java.time.Duration @Configuration class WebConfiguration( @@ -18,4 +20,14 @@ class WebConfiguration( override fun addArgumentResolvers(resolvers: MutableList) { resolvers += AuthArgumentResolver() } + + override fun addResourceHandlers(registry: ResourceHandlerRegistry) { + registry.addResourceHandler("/assets/**") + .addResourceLocations("classpath:/static/assets/") + .setCacheControl( + org.springframework.http.CacheControl.maxAge(Duration.ofDays(7)) + .cachePublic() + .immutable(), + ) + } } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/FrontendController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/FrontendController.kt new file mode 100644 index 0000000..1eeda6b --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/FrontendController.kt @@ -0,0 +1,45 @@ +package de.interaapps.pastefy.controller + +import de.interaapps.pastefy.service.FrontendIndexService +import jakarta.servlet.RequestDispatcher +import jakarta.servlet.http.HttpServletRequest +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +import org.springframework.boot.web.servlet.error.ErrorController +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping + +@Controller +@Order(Ordered.LOWEST_PRECEDENCE) +class FrontendController( + private val frontendIndex: FrontendIndexService, +) : ErrorController { + @GetMapping("/") + fun index(): ResponseEntity = frontend() + + @RequestMapping("/error") + fun error(request: HttpServletRequest): ResponseEntity { + val status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE) as? Int + val path = (request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI) as? String).orEmpty().trimStart('/') + + if (status != HttpStatus.NOT_FOUND.value() || path.isBackendPath()) { + return ResponseEntity.notFound().build() + } + + return frontend() + } + + private fun frontend(): ResponseEntity { + val html = frontendIndex.html ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok() + .contentType(MediaType("text", "html", Charsets.UTF_8)) + .body(html) + } + + private fun String.isBackendPath(): Boolean = + this == "api" || startsWith("api/") +} diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteMetaSSRController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteMetaSSRController.kt index f3df9c8..73d30d2 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteMetaSSRController.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteMetaSSRController.kt @@ -4,6 +4,7 @@ import de.interaapps.pastefy.config.PastefyProperties import de.interaapps.pastefy.repositories.PasteAIInfoRepository import de.interaapps.pastefy.repositories.PasteTagRepository import de.interaapps.pastefy.repositories.UserRepository +import de.interaapps.pastefy.service.FrontendIndexService import de.interaapps.pastefy.service.PasteService import de.interaapps.pastefy.service.SeoRenderer import org.springframework.http.MediaType @@ -20,14 +21,15 @@ class PasteMetaSSRController( private val aiInfoRepository: PasteAIInfoRepository, private val properties: PastefyProperties, private val seo: SeoRenderer, + private val frontendIndex: FrontendIndexService, ) { @GetMapping("/{id}") fun getPasteMetaSSR(@PathVariable id: String): ResponseEntity { - if (!id.matches(Regex("^[A-Za-z0-9_-]{8}$"))) return ResponseEntity.notFound().build() + if (!id.matches(Regex("^[A-Za-z0-9_-]{8}$"))) return frontend() - val paste = pasteService.get(id) ?: return ResponseEntity.notFound().build() + val paste = pasteService.get(id) ?: return frontend() - if (paste.isPrivate || paste.encrypted) return ResponseEntity.notFound().build() + if (paste.isPrivate || paste.encrypted) return frontend() val aiInfo = paste.id?.let(aiInfoRepository::findById)?.orElse(null) @@ -77,7 +79,12 @@ class PasteMetaSSRController( return seo.render(page)?.let { ResponseEntity.ok().contentType(MediaType("text", "html", Charsets.UTF_8)).body(it) - } ?: ResponseEntity.notFound().build() + } ?: frontend() + } + + private fun frontend(): ResponseEntity { + val html = frontendIndex.html ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok().contentType(MediaType("text", "html", Charsets.UTF_8)).body(html) } private fun seoContent( diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/user/UserController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/user/UserController.kt index bca9ca7..6fbce09 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/user/UserController.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/user/UserController.kt @@ -1,6 +1,7 @@ package de.interaapps.pastefy.controller.user import de.interaapps.pastefy.auth.annotations.* +import de.interaapps.pastefy.auth.oauth.OAuth2ProviderRegistry import de.interaapps.pastefy.dto.folder.FolderResponse import de.interaapps.pastefy.dto.pastes.PasteResponse import de.interaapps.pastefy.dto.user.UserPastesResponse @@ -27,6 +28,7 @@ class UserController( private val queries: PasteQueryService, private val pasteRepository: PasteRepository, private val sharedPasteRepository: SharedPasteRepository, + private val oauth2Providers: OAuth2ProviderRegistry, ) { @GetMapping fun getUser(@CurrentUser user: User?): UserResponse = user?.let { @@ -38,10 +40,10 @@ class UserController( color = "#f52966", profilePicture = it.avatar, authType = it.authProvider?.providerName, - authTypes = listOfNotNull(it.authProvider?.providerName), + authTypes = oauth2Providers.names().toList(), type = it.type, ) - } ?: UserResponse() + } ?: UserResponse(authTypes = oauth2Providers.names().toList()) @GetMapping("/overview") @Authenticated diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/Paste.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/Paste.kt index a5acd30..2d96729 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/Paste.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/Paste.kt @@ -28,7 +28,6 @@ import java.util.HexFormat ], ) class Paste( - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Int? = null, @@ -47,10 +46,11 @@ class Paste( @Column(nullable = false) var updatedAt: Instant? = null, + @Column(columnDefinition = "TEXT") var title: String? = null, @Lob - @Column(columnDefinition = "MEDIUMTEXT") + @Column(columnDefinition = "LONGTEXT") var content: String? = null, @Column(length = 8) @@ -59,29 +59,29 @@ class Paste( @Column(length = 8) var forkedFrom: String? = null, - @Column(nullable = false) + @Column(nullable = true) var encrypted: Boolean = false, @Enumerated(EnumType.STRING) - @Column(nullable = false, columnDefinition = "ENUM('PASTE','MULTI_PASTE')") - var type: PasteType = PasteType.PASTE, + @Column(nullable = true, columnDefinition = "ENUM('PASTE','MULTI_PASTE')") + var type: PasteType? = PasteType.PASTE, @Enumerated(EnumType.STRING) - @Column(nullable = false, columnDefinition = "ENUM('UNLISTED','PUBLIC','PRIVATE')") - var visibility: PasteVisibility = PasteVisibility.UNLISTED, + @Column(nullable = true, columnDefinition = "ENUM('UNLISTED','PUBLIC','PRIVATE')") + var visibility: PasteVisibility? = PasteVisibility.UNLISTED, @Enumerated(EnumType.STRING) - @Column(nullable = false, columnDefinition = "ENUM('S3','DATABASE','HTTP')") - var storageType: StorageType = StorageType.DATABASE, + @Column(nullable = true, columnDefinition = "ENUM('S3','DATABASE','HTTP')") + var storageType: StorageType? = StorageType.DATABASE, - @Column(nullable = false) - var version: Int = 0, + @Column(nullable = true) + var version: Int? = 0, - @Column(nullable = false) + @Column(nullable = true) var indexedInElastic: Boolean = false, - @Column(nullable = false) - var length: Int = 0, + @Column(nullable = true) + var length: Int? = 0, @Column(length = 64) var hash: String? = null, @@ -120,21 +120,27 @@ class Paste( } updatedAt = now - version += 1 + version = version?.plus(1) ?: 1 indexedInElastic = false if (key.isBlank()) { key = RandomStrings.alphanumeric(8) } - if (storageType == StorageType.DATABASE) updateContentMetadata(content) + + if (storageType == StorageType.DATABASE) { + updateContentMetadata(content) + } } @PreUpdate fun preUpdate() { updatedAt = Instant.now() - version += 1 + version = version?.plus(1) ?: 1 indexedInElastic = false - if (storageType == StorageType.DATABASE) updateContentMetadata(content) + + if (storageType == StorageType.DATABASE) { + updateContentMetadata(content) + } } private fun updateContentMetadata(content: String?) { diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/PasteAIInfoService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/PasteAIInfoService.kt index c362e52..5644232 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/PasteAIInfoService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/PasteAIInfoService.kt @@ -32,7 +32,7 @@ class PasteAIInfoService( fun enqueueIfEligible(paste: Paste, force: Boolean = false) { if (!force && !isEligible(paste)) return val pasteId = requireNotNull(paste.id) - if (!force && isCurrent(infoRepository.findById(pasteId).orElse(null), paste.version)) return + if (!force && isCurrent(infoRepository.findById(pasteId).orElse(null), paste.version ?: 1)) return jobs.enqueue(type, pasteId, paste.version, PROMPT_VERSION) } @@ -54,7 +54,7 @@ class PasteAIInfoService( enqueueIfEligible(paste) return } - if (isCurrent(infoRepository.findById(job.entityId).orElse(null), paste.version)) return + if (isCurrent(infoRepository.findById(job.entityId).orElse(null), paste.version ?: 1)) return val generated = pasteAI.generateInfo(paste, pasteService.getContent(paste, withCache = false).orEmpty()) val freshPaste = pasteRepository.findById(job.entityId).orElse(null) ?: return @@ -69,7 +69,7 @@ class PasteAIInfoService( .take(10) .toMutableList() val info = infoRepository.findById(job.entityId).orElse(PasteAIInfo(pasteId = job.entityId)) - info.sourcePasteVersion = freshPaste.version + info.sourcePasteVersion = freshPaste.version ?: 1 info.promptVersion = PROMPT_VERSION info.provider = pasteAI.provider.take(30) info.model = pasteAI.model.take(100) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsService.kt index 2f08e79..2611b20 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsService.kt @@ -6,6 +6,7 @@ import de.interaapps.pastefy.config.PastefyProperties import de.interaapps.pastefy.dto.analytics.* import de.interaapps.pastefy.entities.Paste import de.interaapps.pastefy.entities.User +import de.interaapps.pastefy.enums.PasteVisibility import jakarta.annotation.PreDestroy import jakarta.servlet.http.HttpServletRequest import org.slf4j.LoggerFactory @@ -50,7 +51,7 @@ class AnalyticsService( val refererHost = refererHost(request.getHeader("Referer")) val event = VisitEvent( pasteKey = paste.key, - pasteVisibility = paste.visibility.name, + pasteVisibility = paste.visibility?.name ?: PasteVisibility.UNLISTED.name, pasteUserId = paste.userId, visitType = visitType.name, visitedAt = Instant.now(), diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteQueryAdapter.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteQueryAdapter.kt new file mode 100644 index 0000000..e183c71 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteQueryAdapter.kt @@ -0,0 +1,190 @@ +package de.interaapps.pastefy.infrastructure.elastic + +import co.elastic.clients.elasticsearch._types.FieldValue +import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery +import co.elastic.clients.elasticsearch._types.query_dsl.TermsQueryField +import co.elastic.clients.elasticsearch._types.query_dsl.Query as ElasticQuery +import de.interaapps.pastefy.config.PastefyProperties +import de.interaapps.pastefy.dto.pastes.PasteResponse +import de.interaapps.pastefy.dto.user.PublicUserDto +import de.interaapps.pastefy.service.query.LegacyFieldFilter +import de.interaapps.pastefy.service.query.LegacyFilter +import de.interaapps.pastefy.service.query.LegacyFilterGroup +import de.interaapps.pastefy.service.query.LegacyFilterOperator +import de.interaapps.pastefy.service.query.LegacyGroupOperator +import de.interaapps.pastefy.service.query.LegacyPasteQuery +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Sort +import org.springframework.data.elasticsearch.client.elc.NativeQuery +import org.springframework.data.elasticsearch.core.ElasticsearchOperations +import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates +import org.springframework.stereotype.Component + +@Component +@ConditionalOnProperty(prefix = "pastefy.elasticsearch", name = ["enabled"], havingValue = "true") +class ElasticPasteQueryAdapter( + private val operations: ElasticsearchOperations, + private val properties: PastefyProperties, +) { + private val indexCoordinates: IndexCoordinates + get() = IndexCoordinates.of(properties.elasticsearch.indexName) + + fun find(query: LegacyPasteQuery): List { + val nativeQuery = NativeQuery.builder() + .withQuery(elasticQuery(query)) + .withPageable(PageRequest.of(query.page - 1, query.pageLimit)) + .withSort(toSort(query)) + .build() + + return operations.search(nativeQuery, ElasticPasteDocument::class.java, indexCoordinates) + .searchHits + .map { map(it.content, query) } + } + + private fun elasticQuery(query: LegacyPasteQuery): ElasticQuery = ElasticQuery.of { root -> + root.bool { bool -> + query.search?.takeIf(String::isNotBlank)?.let { search -> + bool.must { must -> + must.multiMatch { multi -> + multi.query(search) + .fields("title^3", "content", "user.name", "user.uniqueName") + .fuzziness("2") + .prefixLength(0) + .maxExpansions(30) + .minimumShouldMatch("1") + } + } + } + if (query.filterTags.isNotEmpty()) { + bool.must(termsQuery("tags", query.filterTags)) + } + query.filter?.let { filter -> + bool.filter(filterQuery(filter)) + } + bool + } + } + + private fun filterQuery(filter: LegacyFilter): ElasticQuery = when (filter) { + is LegacyFilterGroup -> ElasticQuery.of { query -> + query.bool { bool -> + when (filter.operator) { + LegacyGroupOperator.AND -> filter.children.forEach { child -> + bool.must(filterQuery(child)) + } + LegacyGroupOperator.OR -> { + filter.children.forEach { child -> bool.should(filterQuery(child)) } + if (filter.children.isNotEmpty()) bool.minimumShouldMatch("1") + } + } + bool + } + } + is LegacyFieldFilter -> fieldQuery(filter) + } + + private fun fieldQuery(filter: LegacyFieldFilter): ElasticQuery { + val field = normalizeField(filter.field) + return when (filter.operator) { + LegacyFilterOperator.EQ -> filter.value?.let { termQuery(field, it) } ?: existsQuery(field, mustExist = false) + LegacyFilterOperator.NE -> ElasticQuery.of { query -> + query.bool { bool -> bool.mustNot(termQuery(field, filter.value.orEmpty())) } + } + LegacyFilterOperator.NULL -> existsQuery(field, mustExist = false) + LegacyFilterOperator.NOT_NULL -> existsQuery(field, mustExist = true) + LegacyFilterOperator.GT -> rangeQuery(field, filter.value, "gt") + LegacyFilterOperator.GTE -> rangeQuery(field, filter.value, "gte") + LegacyFilterOperator.LT -> rangeQuery(field, filter.value, "lt") + LegacyFilterOperator.LTE -> rangeQuery(field, filter.value, "lte") + } + } + + private fun termQuery(field: String, value: String): ElasticQuery = + if (field == "tags") { + termsQuery(field, listOf(value)) + } else { + ElasticQuery.of { query -> query.term { term -> term.field(field).value(value) } } + } + + private fun termsQuery(field: String, values: List): ElasticQuery = + ElasticQuery.of { query -> query.terms { terms -> + terms.field(field) + .terms(TermsQueryField.of { termsField -> + termsField.value(values.map(FieldValue::of)) + }) + } } + + private fun existsQuery(field: String, mustExist: Boolean): ElasticQuery = + if (mustExist) { + ElasticQuery.of { query -> query.exists { exists -> exists.field(field) } } + } else { + ElasticQuery.of { query -> + query.bool { bool -> bool.mustNot { mustNot -> mustNot.exists { exists -> exists.field(field) } } } + } + } + + private fun rangeQuery(field: String, value: String?, operator: String): ElasticQuery = ElasticQuery.of { query -> + query.range { range -> + range.term { term -> + term.field(field) + when (operator) { + "gt" -> term.gt(value) + "gte" -> term.gte(value) + "lt" -> term.lt(value) + else -> term.lte(value) + } + } + } + } + + private fun toSort(query: LegacyPasteQuery): Sort { + val orders = query.sorts.map { + Sort.Order( + if (it.ascending) Sort.Direction.ASC else Sort.Direction.DESC, + normalizeField(it.field), + ) + } + return if (orders.isEmpty()) Sort.unsorted() else Sort.by(orders) + } + + private fun map(document: ElasticPasteDocument, query: LegacyPasteQuery): PasteResponse = + PasteResponse( + exists = true, + id = document.key, + rawUrl = "${properties.serverName.trimEnd('/')}/${document.key}/raw", + title = document.title, + content = document.content.let { if (query.shortenContent) it.shorten() else it }, + createdAt = document.createdAt?.toString() ?: "0000-00-00 00:00:00", + expireAt = document.expireAt?.toString(), + encrypted = document.encrypted, + userId = document.userId, + forkedFrom = document.forkedFrom, + visibility = document.visibility, + folder = document.folder, + type = document.type, + user = document.user?.let { + PublicUserDto(id = it.id, name = it.uniqueName, displayName = it.name, avatar = it.avatar) + }, + ) + + private fun String?.shorten(): String? = + if (this != null && length > 303) take(300) + "..." else this + + private fun normalizeField(field: String): String = FIELD_ALIASES[field] ?: field + + companion object { + private val FIELD_ALIASES = mapOf( + "created_at" to "createdAt", + "updated_at" to "updatedAt", + "expire_at" to "expireAt", + "user_id" to "userId", + "forked_from" to "forkedFrom", + "storage_type" to "storageType", + "indexed_in_elastic" to "indexedInElastic", + "engagement_score" to "engagementScore", + "starred_by" to "starredBy", + "star_count" to "starCount", + ) + } +} diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteService.kt index fb1b37a..1949de8 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteService.kt @@ -3,6 +3,9 @@ package de.interaapps.pastefy.infrastructure.elastic import de.interaapps.pastefy.config.PastefyProperties import de.interaapps.pastefy.entities.Paste import de.interaapps.pastefy.entities.User +import de.interaapps.pastefy.enums.PasteType +import de.interaapps.pastefy.enums.PasteVisibility +import de.interaapps.pastefy.enums.StorageType import de.interaapps.pastefy.repositories.PasteStarRepository import de.interaapps.pastefy.repositories.PasteTagRepository import de.interaapps.pastefy.repositories.PublicPasteEngagementRepository @@ -84,13 +87,13 @@ class ElasticPasteService( forkedFrom = paste.forkedFrom, encrypted = paste.encrypted, folder = paste.folder, - type = paste.type, - visibility = paste.visibility, + type = paste.type ?: PasteType.PASTE, + visibility = paste.visibility ?: PasteVisibility.UNLISTED, expireAt = paste.expireAt, createdAt = paste.createdAt, updatedAt = paste.updatedAt, - storageType = paste.storageType, - version = paste.version, + storageType = paste.storageType ?: StorageType.DATABASE, + version = paste.version ?: 1, engagementScore = engagement(paste), tags = tags(paste), starCount = starredBy(paste).size, diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/jobs/BackgroundJobService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/jobs/BackgroundJobService.kt index 29ca8e0..5b2b6ed 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/jobs/BackgroundJobService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/jobs/BackgroundJobService.kt @@ -28,7 +28,7 @@ class BackgroundJobService( private val transaction = TransactionTemplate(transactionManager) private val activeWorkers = AtomicInteger() - fun enqueue(type: BackgroundJob.Type, entityId: Int, sourceVersion: Int, promptVersion: Int) { + fun enqueue(type: BackgroundJob.Type, entityId: Int, sourceVersion: Int?, promptVersion: Int) { transaction.executeWithoutResult { val key = "$type:$entityId:$sourceVersion:$promptVersion" val existing = repository.findById(key).orElse(null) @@ -49,7 +49,7 @@ class BackgroundJobService( key = key, type = type, entityId = entityId, - sourceVersion = sourceVersion, + sourceVersion = sourceVersion ?: 1, promptVersion = promptVersion, availableAt = Instant.now(), ), diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/s3/S3Configuration.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/s3/S3Configuration.kt index 2bec246..f1029b9 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/s3/S3Configuration.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/s3/S3Configuration.kt @@ -1,6 +1,8 @@ package de.interaapps.pastefy.infrastructure.s3 import de.interaapps.pastefy.config.PastefyProperties +import io.minio.BucketExistsArgs +import io.minio.MakeBucketArgs import io.minio.MinioClient import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.context.annotation.Bean @@ -23,5 +25,25 @@ class S3Configuration { s3.region?.takeIf { it.isNotBlank() }?.let(::region) } .build() + .also { client -> + if (s3.createBucket) { + ensureBucketExists(client, s3) + } + } + } + + private fun ensureBucketExists(client: MinioClient, s3: PastefyProperties.S3) { + val exists = client.bucketExists( + BucketExistsArgs.builder() + .bucket(s3.bucket) + .build() + ) + if (!exists) { + client.makeBucket( + MakeBucketArgs.builder() + .bucket(s3.bucket) + .build() + ) + } } } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/s3/S3PasteService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/s3/S3PasteService.kt index af38025..be56e8c 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/s3/S3PasteService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/s3/S3PasteService.kt @@ -58,6 +58,7 @@ class S3PasteService( fun delete(paste: Paste) { val reference = reference(paste) + client.removeObject( RemoveObjectArgs.builder() .bucket(reference.bucket) @@ -70,13 +71,21 @@ class S3PasteService( fun encode(reference: S3PasteReference): String = objectMapper.writeValueAsString(reference) private fun reference(paste: Paste): S3PasteReference { - require(paste.storageType == StorageType.S3) { "Paste ${paste.id} is not stored in S3" } + require(paste.storageType == StorageType.S3) { + "Paste ${paste.id} is not stored in S3" + } + return objectMapper.readValue(requireNotNull(paste.rawContent), S3PasteReference::class.java) } private fun objectName(paste: Paste): String { val prefix = - paste.key.takeIf { it.length >= 4 }?.let { "${it.substring(0, 2)}/${it.substring(2, 4)}/" }.orEmpty() + paste.key.takeIf { + it.length >= 4 + }?.let { + "${it.substring(0, 2)}/${it.substring(2, 4)}/" + }.orEmpty() + return "pastes/${paste.userId ?: "anonymous"}/$prefix${paste.key}/contents.txt" } } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/seeding/LocalTestingSeeder.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/seeding/LocalTestingSeeder.kt new file mode 100644 index 0000000..67540ff --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/seeding/LocalTestingSeeder.kt @@ -0,0 +1,567 @@ +package de.interaapps.pastefy.infrastructure.seeding + +import de.interaapps.pastefy.entities.AIWarning +import de.interaapps.pastefy.entities.AuthKey +import de.interaapps.pastefy.entities.BackgroundJob +import de.interaapps.pastefy.entities.Folder +import de.interaapps.pastefy.entities.Notification +import de.interaapps.pastefy.entities.Paste +import de.interaapps.pastefy.entities.PasteAIInfo +import de.interaapps.pastefy.entities.PasteComment +import de.interaapps.pastefy.entities.PasteStar +import de.interaapps.pastefy.entities.PasteTag +import de.interaapps.pastefy.entities.PublicPasteEngagement +import de.interaapps.pastefy.entities.SharedPaste +import de.interaapps.pastefy.entities.TagListing +import de.interaapps.pastefy.entities.User +import de.interaapps.pastefy.enums.PasteType +import de.interaapps.pastefy.enums.PasteVisibility +import de.interaapps.pastefy.enums.StorageType +import de.interaapps.pastefy.repositories.AuthKeyRepository +import de.interaapps.pastefy.repositories.BackgroundJobRepository +import de.interaapps.pastefy.repositories.FolderRepository +import de.interaapps.pastefy.repositories.NotificationRepository +import de.interaapps.pastefy.repositories.PasteAIInfoRepository +import de.interaapps.pastefy.repositories.PasteCommentRepository +import de.interaapps.pastefy.repositories.PasteRepository +import de.interaapps.pastefy.repositories.PasteStarRepository +import de.interaapps.pastefy.repositories.PasteTagRepository +import de.interaapps.pastefy.repositories.PublicPasteEngagementRepository +import de.interaapps.pastefy.repositories.SharedPasteRepository +import de.interaapps.pastefy.repositories.TagListingRepository +import de.interaapps.pastefy.repositories.UserRepository +import org.slf4j.LoggerFactory +import org.springframework.boot.ApplicationArguments +import org.springframework.boot.ApplicationRunner +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Profile +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import java.time.Instant +import java.time.temporal.ChronoUnit + +@Component +@Profile("!prod") +@ConditionalOnProperty(prefix = "pastefy.seeding", name = ["enabled"], havingValue = "true") +class LocalTestingSeeder( + private val users: UserRepository, + private val authKeys: AuthKeyRepository, + private val folders: FolderRepository, + private val pastes: PasteRepository, + private val pasteTags: PasteTagRepository, + private val pasteStars: PasteStarRepository, + private val comments: PasteCommentRepository, + private val notifications: NotificationRepository, + private val sharedPastes: SharedPasteRepository, + private val tagListings: TagListingRepository, + private val engagements: PublicPasteEngagementRepository, + private val aiInfos: PasteAIInfoRepository, + private val backgroundJobs: BackgroundJobRepository, +) : ApplicationRunner { + private val log = LoggerFactory.getLogger(LocalTestingSeeder::class.java) + + @Transactional + override fun run(args: ApplicationArguments) { + val state = SeedState() + seedUsers(state) + seedGeneratedUsers() + seedAuthKeys() + seedFolders() + seedGeneratedFolders() + seedPastes(state) + seedGeneratedPastes() + seedPasteMetadata(state) + seedUserFacingRecords() + seedOperationalRecords(state) + log.info("Local Pastefy seed data is ready") + } + + private fun seedUsers(state: SeedState) { + state.admin = user( + id = ADMIN_ID, + uniqueName = "seed-admin", + name = "Seed Admin", + email = "seed-admin@pastefy.local", + type = User.Type.ADMIN, + provider = User.AuthenticationProvider.INTERAAPPS, + authId = "seed-interaapps-admin", + avatar = "https://pastefy.local/avatars/admin.png", + ) + state.user = user( + id = USER_ID, + uniqueName = "seed-user", + name = "Seed User", + email = "seed-user@pastefy.local", + type = User.Type.USER, + provider = User.AuthenticationProvider.GITHUB, + authId = "seed-github-user", + avatar = "https://pastefy.local/avatars/user.png", + ) + state.blocked = user( + id = BLOCKED_ID, + uniqueName = "seed-blocked", + name = "Seed Blocked", + email = "seed-blocked@pastefy.local", + type = User.Type.BLOCKED, + provider = User.AuthenticationProvider.GOOGLE, + authId = "seed-google-blocked", + ) + state.awaiting = user( + id = AWAITING_ID, + uniqueName = "seed-awaiting", + name = "Seed Awaiting", + email = "seed-awaiting@pastefy.local", + type = User.Type.AWAITING_ACCESS, + provider = User.AuthenticationProvider.DISCORD, + authId = "seed-discord-awaiting", + ) + } + + private fun seedGeneratedUsers() { + val providers = listOf( + User.AuthenticationProvider.INTERAAPPS, + User.AuthenticationProvider.GITHUB, + User.AuthenticationProvider.GOOGLE, + User.AuthenticationProvider.TWITCH, + User.AuthenticationProvider.DISCORD, + ) + + repeat(TARGET_USER_COUNT - FIXED_USER_COUNT) { index -> + val number = index + 1 + user( + id = generatedUserId(number), + uniqueName = "seed-user-$number", + name = "Seed User $number", + email = "seed-user-$number@pastefy.local", + type = User.Type.USER, + provider = providers[index % providers.size], + authId = "seed-generated-user-$number", + avatar = "https://pastefy.local/avatars/user-$number.png", + ) + } + } + + private fun seedAuthKeys() { + authKey( + key = "seed-admin-user-session-key-000000000000000000000000000001", + userId = ADMIN_ID, + type = AuthKey.Type.USER, + ) + authKey( + key = "seed-user-api-key-000000000000000000000000000000000000001", + userId = USER_ID, + type = AuthKey.Type.API, + scopes = mutableListOf("pastes", "folders", "comments"), + ) + authKey( + key = "seed-user-access-token-000000000000000000000000000000001", + userId = USER_ID, + type = AuthKey.Type.ACCESS_TOKEN, + scopes = mutableListOf("pastes:read", "comments:create"), + accessToken = "seed-access-token", + refreshToken = "seed-refresh-token", + ) + } + + private fun seedFolders() { + folder(ROOT_FOLDER_KEY, "Seed Root Folder", USER_ID) + folder(CHILD_FOLDER_KEY, "Seed Child Folder", USER_ID, ROOT_FOLDER_KEY) + folder(ADMIN_FOLDER_KEY, "Admin Examples", ADMIN_ID) + } + + private fun seedGeneratedFolders() { + repeat(TARGET_FOLDER_COUNT - FIXED_FOLDER_COUNT) { index -> + val number = index + 1 + val owner = if (number % 3 == 0) ADMIN_ID else generatedUserId(number) + folder( + key = generatedFolderKey(number), + name = "Generated Folder $number", + userId = owner, + parent = if (number % 2 == 0) ROOT_FOLDER_KEY else null, + ) + } + } + + private fun seedPastes(state: SeedState) { + state.publicPaste = paste( + key = PUBLIC_PASTE_KEY, + title = "Public Kotlin example", + content = """ + fun main() { + println("Hello from Pastefy seed data") + } + """.trimIndent(), + userId = USER_ID, + folder = ROOT_FOLDER_KEY, + visibility = PasteVisibility.PUBLIC, + ) + state.privatePaste = paste( + key = PRIVATE_PASTE_KEY, + title = "Private deployment notes", + content = "This private paste is useful for testing auth-gated responses.", + userId = USER_ID, + folder = CHILD_FOLDER_KEY, + visibility = PasteVisibility.PRIVATE, + ) + state.unlistedPaste = paste( + key = UNLISTED_PASTE_KEY, + title = "Unlisted JSON fixture", + content = """{"environment":"local","seeded":true,"items":[1,2,3]}""", + userId = ADMIN_ID, + folder = ADMIN_FOLDER_KEY, + visibility = PasteVisibility.UNLISTED, + ) + state.encryptedPaste = paste( + key = ENCRYPTED_PASTE_KEY, + title = "Encrypted sample", + content = "ciphertext-placeholder", + userId = USER_ID, + visibility = PasteVisibility.PRIVATE, + encrypted = true, + ) + state.multiPaste = paste( + key = MULTI_PASTE_KEY, + title = "Multi paste manifest", + content = """[{"name":"README.md","paste":"$PUBLIC_PASTE_KEY"},{"name":"config.json","paste":"$UNLISTED_PASTE_KEY"}]""", + userId = USER_ID, + visibility = PasteVisibility.PUBLIC, + type = PasteType.MULTI_PASTE, + ) + state.expiringPaste = paste( + key = EXPIRING_PASTE_KEY, + title = "Expiring paste", + content = "This paste has an expiry timestamp for local filtering tests.", + userId = USER_ID, + visibility = PasteVisibility.PUBLIC, + expireAt = Instant.now().plus(7, ChronoUnit.DAYS), + ) + } + + private fun seedGeneratedPastes() { + val visibilities = listOf(PasteVisibility.PUBLIC, PasteVisibility.UNLISTED, PasteVisibility.PRIVATE) + val languages = listOf("kotlin", "json", "markdown", "yaml", "shell", "sql") + + repeat(TARGET_PASTE_COUNT - FIXED_PASTE_COUNT) { index -> + val number = index + 1 + val language = languages[index % languages.size] + val key = generatedPasteKey(number) + val owner = if (number % 5 == 0) ADMIN_ID else generatedUserId(((number - 1) % (TARGET_USER_COUNT - FIXED_USER_COUNT)) + 1) + val folder = if (number % 4 == 0) null else generatedFolderKey(((number - 1) % (TARGET_FOLDER_COUNT - FIXED_FOLDER_COUNT)) + 1) + val visibility = visibilities[index % visibilities.size] + + paste( + key = key, + title = "Generated $language paste $number", + content = generatedPasteContent(number, language, visibility), + userId = owner, + folder = folder, + visibility = visibility, + encrypted = number % 37 == 0, + type = if (number % 29 == 0) PasteType.MULTI_PASTE else PasteType.PASTE, + expireAt = if (number % 23 == 0) Instant.now().plus(number.toLong(), ChronoUnit.DAYS) else null, + ) + tags(key, language, "generated", visibility.name.lowercase()) + } + } + + private fun seedPasteMetadata(state: SeedState) { + tags(PUBLIC_PASTE_KEY, "kotlin", "spring", "demo") + tags(UNLISTED_PASTE_KEY, "json", "fixture") + tags(MULTI_PASTE_KEY, "multipaste", "demo") + + tagListing("kotlin", "Kotlin", "Spring Boot Kotlin examples", "https://kotlinlang.org", 1) + tagListing("spring", "Spring", "Spring Boot API examples", "https://spring.io", 1) + tagListing("json", "JSON", "JSON fixtures for frontend testing", null, 1) + tagListing("demo", "Demo", "Local seed data", null, 2) + + star(PUBLIC_PASTE_KEY, ADMIN_ID) + star(PUBLIC_PASTE_KEY, USER_ID) + star(UNLISTED_PASTE_KEY, USER_ID) + + comment( + paste = PUBLIC_PASTE_KEY, + userId = ADMIN_ID, + content = "Top-level seeded comment for UI tests.", + ) + comment( + paste = PUBLIC_PASTE_KEY, + userId = USER_ID, + content = "Line-specific seeded comment.", + lineFrom = 2, + lineTo = 2, + ) + + state.publicPaste.id?.let { pasteId -> + engagement(pasteId, 42) + aiInfo( + pasteId = pasteId, + sourceVersion = state.publicPaste.version ?: 1, + description = "A small Kotlin hello-world example generated for local testing.", + tags = mutableListOf("kotlin", "spring", "demo"), + ) + } + } + + private fun seedUserFacingRecords() { + notification(USER_ID, "Welcome to the local Pastefy seed dataset.", "/$PUBLIC_PASTE_KEY", received = false) + notification(USER_ID, "A seeded paste was shared with you.", "/$UNLISTED_PASTE_KEY", received = true) + notification(ADMIN_ID, "Admin seed account is ready.", "/admin", received = true, alreadyRead = true) + + sharedPaste(userId = ADMIN_ID, targetId = USER_ID, paste = UNLISTED_PASTE_KEY) + } + + private fun seedOperationalRecords(state: SeedState) { + state.publicPaste.id?.let { pasteId -> + backgroundJob( + key = "seed-paste-ai-info-$pasteId-v${state.publicPaste.version ?: 1}", + entityId = pasteId, + sourceVersion = state.publicPaste.version ?: 1, + promptVersion = 1, + status = BackgroundJob.Status.DONE, + ) + } + } + + private fun user( + id: String, + uniqueName: String, + name: String, + email: String, + type: User.Type, + provider: User.AuthenticationProvider, + authId: String, + avatar: String? = null, + ): User = + users.findById(id).orElseGet { + users.save( + User( + id = id, + name = name, + uniqueName = uniqueName, + email = email, + avatar = avatar, + authId = authId, + authProvider = provider, + type = type, + ) + ) + } + + private fun authKey( + key: String, + userId: String, + type: AuthKey.Type, + scopes: MutableList? = null, + accessToken: String? = null, + refreshToken: String? = null, + ) { + if (authKeys.findByKey(key) != null) return + authKeys.save( + AuthKey( + key = key, + userId = userId, + type = type, + scopes = scopes, + accessToken = accessToken, + refreshToken = refreshToken, + ) + ) + } + + private fun folder(key: String, name: String, userId: String, parent: String? = null): Folder = + folders.findByKey(key) ?: folders.save(Folder(key = key, name = name, userId = userId, parent = parent)) + + private fun paste( + key: String, + title: String, + content: String, + userId: String, + visibility: PasteVisibility, + folder: String? = null, + encrypted: Boolean = false, + type: PasteType = PasteType.PASTE, + expireAt: Instant? = null, + ): Paste { + val existing = pastes.findByKey(key) + if (existing != null) return existing + return pastes.save( + Paste( + key = key, + title = title, + userId = userId, + folder = folder, + encrypted = encrypted, + type = type, + visibility = visibility, + storageType = StorageType.DATABASE, + expireAt = expireAt, + ).apply { setDatabaseContent(content) } + ) + } + + private fun tags(paste: String, vararg tags: String) { + val existing = pasteTags.findAllByPaste(paste).mapTo(mutableSetOf()) { it.tag } + tags.filterNot(existing::contains).forEach { tag -> + pasteTags.save(PasteTag(paste = paste, tag = tag)) + } + } + + private fun tagListing(tag: String, displayName: String, description: String, website: String?, pasteCount: Int) { + if (tagListings.existsById(tag)) return + tagListings.save( + TagListing( + tag = tag, + displayName = displayName, + description = description, + website = website, + icon = "tag", + pasteCount = pasteCount, + ) + ) + } + + private fun star(paste: String, userId: String) { + if (pasteStars.existsByPasteAndUserId(paste, userId)) return + pasteStars.save(PasteStar(paste = paste, userId = userId)) + } + + private fun comment(paste: String, userId: String, content: String, lineFrom: Int? = null, lineTo: Int? = null) { + if (comments.findAllByPaste(paste).any { it.content == content && it.userId == userId }) return + comments.save(PasteComment(paste = paste, userId = userId, content = content, lineFrom = lineFrom, lineTo = lineTo)) + } + + private fun notification( + userId: String, + message: String, + url: String, + received: Boolean, + alreadyRead: Boolean = false, + ) { + if (notifications.findAllByUserId(userId).any { it.message == message }) return + notifications.save( + Notification( + userId = userId, + message = message, + url = url, + received = received, + alreadyRead = alreadyRead, + ) + ) + } + + private fun sharedPaste(userId: String, targetId: String, paste: String) { + if (sharedPastes.findAllByTargetId(targetId).any { it.userId == userId && it.paste == paste }) return + sharedPastes.save(SharedPaste(userId = userId, targetId = targetId, paste = paste)) + } + + private fun engagement(pasteId: Int, score: Int) { + if (engagements.findByPasteId(pasteId) != null) return + engagements.save(PublicPasteEngagement(pasteId = pasteId, score = score)) + } + + private fun aiInfo( + pasteId: Int, + sourceVersion: Int, + description: String, + tags: MutableList, + ) { + if (aiInfos.existsById(pasteId)) return + aiInfos.save( + PasteAIInfo( + pasteId = pasteId, + sourcePasteVersion = sourceVersion, + promptVersion = 1, + provider = "seed", + model = "local-fixture", + description = description, + tagsJson = tags, + warningsJson = mutableListOf(AIWarning("No issue detected in seed data.", 1)), + dangerous = false, + maxSeverity = 1, + suggestedFilename = "hello.kt", + generatedAt = Instant.now(), + ) + ) + } + + private fun generatedPasteContent(number: Int, language: String, visibility: PasteVisibility): String = + when (language) { + "kotlin" -> """ + data class SeedPaste$number( + val id: Int = $number, + val visibility: String = "${visibility.name}" + ) + """.trimIndent() + "json" -> """{"id":$number,"kind":"generated","visibility":"${visibility.name.lowercase()}"}""" + "markdown" -> "# Generated Paste $number\n\nThis is seeded markdown content for local testing." + "yaml" -> "id: $number\nkind: generated\nvisibility: ${visibility.name.lowercase()}\n" + "shell" -> "echo \"Generated Paste $number\"\n" + "sql" -> "select $number as generated_paste_id;\n" + else -> "Generated paste $number" + } + + private fun generatedUserId(number: Int): String = "seedu%03d".format(number) + + private fun generatedFolderKey(number: Int): String = "seedf%03d".format(number) + + private fun generatedPasteKey(number: Int): String = "seedp%03d".format(number) + + private fun backgroundJob( + key: String, + entityId: Int, + sourceVersion: Int, + promptVersion: Int, + status: BackgroundJob.Status, + ) { + if (backgroundJobs.existsById(key)) return + backgroundJobs.save( + BackgroundJob( + key = key, + entityId = entityId, + sourceVersion = sourceVersion, + promptVersion = promptVersion, + status = status, + attempts = 1, + availableAt = Instant.now(), + ) + ) + } + + private class SeedState { + lateinit var admin: User + lateinit var user: User + lateinit var blocked: User + lateinit var awaiting: User + lateinit var publicPaste: Paste + lateinit var privatePaste: Paste + lateinit var unlistedPaste: Paste + lateinit var encryptedPaste: Paste + lateinit var multiPaste: Paste + lateinit var expiringPaste: Paste + } + + companion object { + private const val ADMIN_ID = "seedadm1" + private const val USER_ID = "seedusr1" + private const val BLOCKED_ID = "seedblk1" + private const val AWAITING_ID = "seedawt1" + + private const val ROOT_FOLDER_KEY = "seedroot" + private const val CHILD_FOLDER_KEY = "seedchld" + private const val ADMIN_FOLDER_KEY = "seedadmf" + + private const val PUBLIC_PASTE_KEY = "seedpub1" + private const val PRIVATE_PASTE_KEY = "seedpriv" + private const val UNLISTED_PASTE_KEY = "seedunls" + private const val ENCRYPTED_PASTE_KEY = "seedencr" + private const val MULTI_PASTE_KEY = "seedmult" + private const val EXPIRING_PASTE_KEY = "seedexpr" + + private const val TARGET_USER_COUNT = 50 + private const val FIXED_USER_COUNT = 4 + private const val TARGET_FOLDER_COUNT = 10 + private const val FIXED_FOLDER_COUNT = 3 + private const val TARGET_PASTE_COUNT = 200 + private const val FIXED_PASTE_COUNT = 6 + } +} diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/FrontendIndexService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/FrontendIndexService.kt new file mode 100644 index 0000000..fb1c09e --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/FrontendIndexService.kt @@ -0,0 +1,27 @@ +package de.interaapps.pastefy.service + +import de.interaapps.pastefy.config.PastefyProperties +import org.slf4j.LoggerFactory +import org.springframework.core.io.ClassPathResource +import org.springframework.stereotype.Service + +@Service +class FrontendIndexService( + private val properties: PastefyProperties, +) { + val html: String? = load() + + private fun load(): String? = runCatching { + val source = ClassPathResource("static/index.html").inputStream.bufferedReader().use { it.readText() } + source + .replace("/*PASTEFY_PLUGINS*/", "") + .replace("", properties.customHeader) + .replace("", properties.customBody) + }.onFailure { + LOGGER.warn("Unable to load static/index.html for frontend serving", it) + }.getOrNull() + + companion object { + private val LOGGER = LoggerFactory.getLogger(FrontendIndexService::class.java) + } +} diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteQueryService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteQueryService.kt index 36bbb99..94e82bf 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteQueryService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteQueryService.kt @@ -1,29 +1,27 @@ package de.interaapps.pastefy.service -import de.interaapps.pastefy.config.PastefyProperties import de.interaapps.pastefy.dto.pastes.PasteResponse import de.interaapps.pastefy.entities.Paste -import de.interaapps.pastefy.entities.PasteStar -import de.interaapps.pastefy.entities.PasteTag import de.interaapps.pastefy.entities.User import de.interaapps.pastefy.enums.PasteVisibility -import de.interaapps.pastefy.exceptions.PermissionsDeniedException -import de.interaapps.pastefy.repositories.PasteRepository -import jakarta.persistence.criteria.Predicate +import de.interaapps.pastefy.infrastructure.elastic.ElasticPasteQueryAdapter +import de.interaapps.pastefy.service.query.JpaPasteQueryAdapter +import de.interaapps.pastefy.service.query.LegacyFieldFilter +import de.interaapps.pastefy.service.query.LegacyFilterOperator +import de.interaapps.pastefy.service.query.LegacyPasteQueryParser import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse -import org.springframework.data.domain.PageRequest -import org.springframework.data.domain.Sort -import org.springframework.data.jpa.domain.Specification +import org.springframework.beans.factory.ObjectProvider import org.springframework.stereotype.Service import java.time.Instant import java.time.temporal.ChronoUnit @Service class PasteQueryService( - private val pasteRepository: PasteRepository, + private val parser: LegacyPasteQueryParser, + private val jpa: JpaPasteQueryAdapter, private val mapper: PasteResponseMapper, - private val properties: PastefyProperties, + private val elasticProvider: ObjectProvider, ) { fun list( request: HttpServletRequest, @@ -35,102 +33,64 @@ class PasteQueryService( userId: String? = null, starredBy: String? = null, ): List { - val paging = paging(request, response) - val specification = specification(request, user, guarded, visibility, encrypted, userId, starredBy) - val sort = Sort.by(Sort.Direction.DESC, sortable(request.getParameter("sort"))) - return pasteRepository.findAll(specification, PageRequest.of(paging.page - 1, paging.limit, sort)).content - .map { + val query = parser.parse( + request = request, + response = response, + user = user, + guarded = guarded, + visibility = visibility, + encrypted = encrypted, + userId = userId, + starredBy = starredBy, + ) + return elasticProvider.ifAvailable?.find(query) + ?: jpa.find(query).map { mapper.map( it, user, fetchStar = user != null, fetchUser = true, - request.withAiInfo(), - request.shortenContent() + withAiInfo = query.withAiInfo, + shortenContent = query.shortenContent, ) } } fun trending(request: HttpServletRequest, response: HttpServletResponse): List { - val paging = paging(request, response) - val createdAfter = - if (request.parameterMap.containsKey("trending")) Instant.now().minus(4, ChronoUnit.DAYS) else null - return pasteRepository.findTrending(createdAfter, PageRequest.of(paging.page - 1, paging.limit)) - .map { mapper.map(it, shortenContent = request.shortenContent(), withAiInfo = request.withAiInfo()) } - } - - fun map(paste: Paste, request: HttpServletRequest, user: User?) = - mapper.map(paste, user, fetchStar = true, fetchUser = true, request.withAiInfo(), request.shortenContent()) - - private fun specification( - request: HttpServletRequest, - user: User?, - guarded: Boolean, - visibility: PasteVisibility?, - encrypted: Boolean?, - userId: String?, - starredBy: String?, - ) = Specification { root, query, builder -> - val criteriaQuery = requireNotNull(query) - val predicates = mutableListOf() - visibility?.let { predicates += builder.equal(root.get("visibility"), it) } - encrypted?.let { predicates += builder.equal(root.get("encrypted"), it) } - userId?.let { predicates += builder.equal(root.get("userId"), it) } - request.getParameter("folder")?.let { predicates += builder.equal(root.get("folder"), it) } - request.getParameter("search")?.trim()?.takeIf(String::isNotEmpty)?.lowercase()?.let { - predicates += builder.or( - builder.like(builder.lower(root.get("title")), "%$it%"), - builder.like(builder.lower(root.get("content")), "%$it%"), - ) - } - request.getParameter("filter_tags")?.split(',')?.map(String::trim)?.filter(String::isNotEmpty)?.forEach { tag -> - val subquery = criteriaQuery.subquery(String::class.java) - val pasteTag = subquery.from(PasteTag::class.java) - subquery.select(pasteTag.get("paste")).where(builder.equal(pasteTag.get("tag"), tag)) - predicates += root.get("key").`in`(subquery) - } - starredBy?.let { predicates += starredPredicate(criteriaQuery, builder, root.get("key"), it) } - if (guarded && user?.isAdmin != true) { - val visible = - mutableListOf(builder.equal(root.get("visibility"), PasteVisibility.PUBLIC)) - user?.let { - visible += builder.equal(root.get("userId"), it.id) - visible += starredPredicate(criteriaQuery, builder, root.get("key"), it.id) + val additionalFilters = buildList { + if (request.parameterMap.containsKey("trending")) { + add( + LegacyFieldFilter( + field = "createdAt", + operator = LegacyFilterOperator.GT, + value = Instant.now().minus(4, ChronoUnit.DAYS).toString(), + ), + ) } - predicates += builder.or(*visible.toTypedArray()) } - builder.and(*predicates.toTypedArray()) - } - - private fun starredPredicate( - query: jakarta.persistence.criteria.CriteriaQuery<*>, - builder: jakarta.persistence.criteria.CriteriaBuilder, - pasteKey: jakarta.persistence.criteria.Path, - userId: String, - ): Predicate { - val subquery = query.subquery(String::class.java) - val star = subquery.from(PasteStar::class.java) - subquery.select(star.get("paste")).where(builder.equal(star.get("userId"), userId)) - return pasteKey.`in`(subquery) - } - - private fun paging(request: HttpServletRequest, response: HttpServletResponse): Paging { - val page = request.getParameter("page")?.toIntOrNull()?.coerceAtLeast(1) ?: 1 - val limit = request.getParameter("page_limit")?.toIntOrNull()?.coerceAtLeast(1) - ?.coerceAtMost(properties.paginationPageLimit.coerceAtLeast(1)) - ?: properties.paginationPageLimit.coerceAtLeast(1) - response.setHeader("PAGINATION_LIMIT", limit.toString()) - response.setHeader("PAGINATION_PAGE", (page - 1).toString()) - return Paging(page, limit) - } - - private fun sortable(value: String?) = when (value) { - "updatedAt", "updated_at" -> "updatedAt" - "title" -> "title" - else -> "createdAt" + val query = parser.parse( + request = request, + response = response, + user = null, + guarded = false, + visibility = PasteVisibility.PUBLIC, + encrypted = false, + defaultSort = "engagementScore", + additionalFilters = additionalFilters, + ) + return elasticProvider.ifAvailable?.find(query) + ?: jpa.find(query).map { + mapper.map(it, shortenContent = query.shortenContent, withAiInfo = query.withAiInfo) + } } - private fun HttpServletRequest.withAiInfo() = getParameter("with_ai_analysis").equals("true", true) - private fun HttpServletRequest.shortenContent() = getParameter("shorten_content").equals("true", true) - private data class Paging(val page: Int, val limit: Int) + fun map(paste: Paste, request: HttpServletRequest, user: User?) = + mapper.map( + paste, + user, + fetchStar = true, + fetchUser = true, + withAiInfo = request.getParameter("with_ai_analysis").equals("true", true), + shortenContent = request.getParameter("shorten_content").equals("true", true), + ) } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteResponseMapper.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteResponseMapper.kt index 4ee09c2..43cb4ed 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteResponseMapper.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteResponseMapper.kt @@ -6,6 +6,8 @@ import de.interaapps.pastefy.dto.pastes.PasteResponse import de.interaapps.pastefy.dto.user.PublicUserDto import de.interaapps.pastefy.entities.Paste import de.interaapps.pastefy.entities.User +import de.interaapps.pastefy.enums.PasteType +import de.interaapps.pastefy.enums.PasteVisibility import de.interaapps.pastefy.repositories.PasteAIInfoRepository import de.interaapps.pastefy.repositories.PasteTagRepository import de.interaapps.pastefy.repositories.UserRepository @@ -41,10 +43,10 @@ class PasteResponseMapper( encrypted = paste.encrypted, folder = paste.folder, userId = paste.userId, - visibility = paste.visibility, + visibility = paste.visibility ?: PasteVisibility.UNLISTED, forkedFrom = paste.forkedFrom, rawUrl = "$serverName/${paste.key}/raw", - type = paste.type, + type = paste.type ?: PasteType.PASTE, createdAt = paste.createdAt?.toString() ?: "0000-00-00 00:00:00", expireAt = paste.expireAt?.toString(), tags = pasteTagRepository.findAllByPaste(paste.key).map { it.tag }, diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteService.kt index 575c50d..305e0d6 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteService.kt @@ -68,11 +68,14 @@ class PasteService( redisCacheProvider.ifAvailable?.evictContent(saved) val s3 = s3Provider.ifAvailable + if (s3 != null && s3.shouldStore(saved)) { val content = getContent(saved, withCache = false).orEmpty() val reference = s3.store(saved, content) + saved.cachedContents = content saved.setStorageReference(s3.encode(reference), StorageType.S3) + saved = pasteRepository.saveAndFlush(saved) } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/SeoRenderer.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/SeoRenderer.kt index 424b3f1..941ba61 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/SeoRenderer.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/SeoRenderer.kt @@ -2,7 +2,6 @@ package de.interaapps.pastefy.service import de.interaapps.pastefy.config.PastefyProperties import org.slf4j.LoggerFactory -import org.springframework.core.io.ClassPathResource import org.springframework.stereotype.Service import org.springframework.web.util.HtmlUtils import java.net.URLEncoder @@ -11,8 +10,13 @@ import java.nio.charset.StandardCharsets @Service class SeoRenderer( private val properties: PastefyProperties, + frontendIndex: FrontendIndexService, ) { - private val html = loadHtml() + private val html = frontendIndex.html?.let { source -> + runCatching { prepareHtml(source) } + .onFailure { LOGGER.warn("Unable to prepare frontend index for SEO metadata", it) } + .getOrNull() + } fun render(page: SeoPage?): String? { if (!properties.metaTagsEnabled || page == null) return null @@ -28,9 +32,12 @@ class SeoRenderer( fun pathSegment(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20") - fun truncate(value: String, maxLength: Int): String = - if (value.codePointCount(0, value.length) <= maxLength) value - else value.substring(0, value.offsetByCodePoints(0, maxLength - 3)) + "..." + fun truncate(value: String, maxLength: Int): String { + if (value.codePointCount(0, value.length) <= maxLength) { + return value + } + return "${value.substring(0, value.offsetByCodePoints(0, maxLength - 3))}..." + } fun truncateWithoutEllipsis(value: String, maxLength: Int): String = if (value.codePointCount(0, value.length) <= maxLength) value @@ -38,12 +45,6 @@ class SeoRenderer( fun escapeHtml(value: String?) = HtmlUtils.htmlEscape(value.orEmpty()) - private fun loadHtml(): String? = runCatching { - prepareHtml(ClassPathResource("static/index.html").inputStream.bufferedReader().use { it.readText() }) - }.onFailure { - LOGGER.warn("Unable to prepare static/index.html for SEO metadata", it) - }.getOrNull() - private fun prepareHtml(source: String): String { val start = source.indexOf(META_START_TAG) val end = source.indexOf(META_END_TAG, start + META_START_TAG.length) @@ -52,9 +53,13 @@ class SeoRenderer( var prepared = source.substring(0, start) + META_REPLACEMENT + source.substring(end + META_END_TAG.length) - prepared = APP_MOUNT.replace(prepared) { result -> result.groupValues[1] + SEO_CONTENT_REPLACEMENT + "" } + prepared = APP_MOUNT.replace(prepared) { + result -> result.groupValues[1] + SEO_CONTENT_REPLACEMENT + "" + } - require(SEO_CONTENT_REPLACEMENT in prepared) { "Vue app mount is missing in static/index.html" } + require(SEO_CONTENT_REPLACEMENT in prepared) { + "Frontend mount is missing in static/index.html" + } return if (TITLE.containsMatchIn(prepared)) { TITLE.replaceFirst(prepared, "$TITLE_REPLACEMENT") @@ -77,11 +82,13 @@ class SeoRenderer( fun absoluteUrl(pathOrUrl: String): String { if (pathOrUrl.startsWith("http://", true) || pathOrUrl.startsWith("https://", true)) return pathOrUrl val configured = properties.serverName.trim().ifBlank { "http://localhost" } + val base = if (configured.startsWith("http://", true) || configured.startsWith( "https://", true ) ) configured else "https://$configured" + return base.trimEnd('/') + "/" + pathOrUrl.trimStart('/') } @@ -91,6 +98,7 @@ class SeoRenderer( description: String, ) { val seoTags = linkedMapOf("description" to description) + val openGraphTags = linkedMapOf( "og:site_name" to "Pastefy", "og:type" to "website", @@ -98,22 +106,30 @@ class SeoRenderer( "og:description" to description, "og:url" to canonicalUrl, ) + val twitterTags = linkedMapOf( "twitter:card" to "summary", "twitter:title" to title, "twitter:description" to description, "twitter:url" to canonicalUrl, ) + var content: String = "" private set fun content(value: String) = apply { content = value } - fun meta(name: String, value: String?) = apply { value?.let { seoTags[name] = it } } + fun meta(name: String, value: String?) = apply { + value?.let { seoTags[name] = it } + } - fun openGraph(name: String, value: String?) = apply { value?.let { openGraphTags[name] = it } } + fun openGraph(name: String, value: String?) = apply { + value?.let { openGraphTags[name] = it } + } - fun twitter(name: String, value: String?) = apply { value?.let { twitterTags[name] = it } } + fun twitter(name: String, value: String?) = apply { + value?.let { twitterTags[name] = it } + } fun type(value: String) = openGraph("og:type", value) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/JpaPasteQueryAdapter.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/JpaPasteQueryAdapter.kt new file mode 100644 index 0000000..881c2a2 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/JpaPasteQueryAdapter.kt @@ -0,0 +1,210 @@ +package de.interaapps.pastefy.service.query + +import de.interaapps.pastefy.entities.Paste +import de.interaapps.pastefy.entities.PasteStar +import de.interaapps.pastefy.entities.PasteTag +import de.interaapps.pastefy.entities.PublicPasteEngagement +import de.interaapps.pastefy.enums.PasteType +import de.interaapps.pastefy.enums.PasteVisibility +import de.interaapps.pastefy.enums.StorageType +import jakarta.persistence.EntityManager +import jakarta.persistence.criteria.CriteriaBuilder +import jakarta.persistence.criteria.CriteriaQuery +import jakarta.persistence.criteria.Expression +import jakarta.persistence.criteria.Predicate +import jakarta.persistence.criteria.Root +import org.springframework.stereotype.Component +import java.sql.Timestamp +import java.time.Instant + +@Component +class JpaPasteQueryAdapter( + private val entityManager: EntityManager, +) { + fun find(query: LegacyPasteQuery): List { + val builder = entityManager.criteriaBuilder + val criteria = builder.createQuery(Paste::class.java) + val root = criteria.from(Paste::class.java) + + val predicates = mutableListOf() + query.filter?.let { predicates += predicate(it, root, criteria, builder) } + query.search?.let { predicates += searchPredicate(it, root, builder) } + if (query.filterTags.isNotEmpty()) predicates += tagsPredicate(query.filterTags, root, criteria, builder) + + if (predicates.isNotEmpty()) criteria.where(builder.and(*predicates.toTypedArray())) + criteria.orderBy(query.sorts.map { order(it, root, criteria, builder) }) + + return entityManager.createQuery(criteria) + .setFirstResult(query.offset) + .setMaxResults(query.pageLimit) + .resultList + } + + private fun predicate( + filter: LegacyFilter, + root: Root, + query: CriteriaQuery, + builder: CriteriaBuilder, + ): Predicate = when (filter) { + is LegacyFilterGroup -> { + val children = filter.children.map { predicate(it, root, query, builder) }.toTypedArray() + when (filter.operator) { + LegacyGroupOperator.AND -> if (children.isEmpty()) builder.conjunction() else builder.and(*children) + LegacyGroupOperator.OR -> if (children.isEmpty()) builder.conjunction() else builder.or(*children) + } + } + is LegacyFieldFilter -> fieldPredicate(filter, root, query, builder) + } + + private fun fieldPredicate( + filter: LegacyFieldFilter, + root: Root, + query: CriteriaQuery, + builder: CriteriaBuilder, + ): Predicate { + val field = normalizeField(filter.field) + if (field == "starredBy") return starredPredicate(filter, root, query, builder) + if (field == "tags") return tagsPredicate(listOfNotNull(filter.value), root, query, builder, filter.operator) + if (field == "engagementScore") return engagementPredicate(filter, root, query, builder) + + val path = root.get(field) + return when (filter.operator) { + LegacyFilterOperator.EQ -> filter.value?.let { builder.equal(path, convert(field, it)) } ?: builder.isNull(path) + LegacyFilterOperator.NE -> filter.value?.let { builder.notEqual(path, convert(field, it)) } ?: builder.isNotNull(path) + LegacyFilterOperator.NULL -> builder.isNull(path) + LegacyFilterOperator.NOT_NULL -> builder.isNotNull(path) + LegacyFilterOperator.GT -> compare(field, filter.value, root, builder) { expression, value -> builder.greaterThan(expression, value) } + LegacyFilterOperator.GTE -> compare(field, filter.value, root, builder) { expression, value -> builder.greaterThanOrEqualTo(expression, value) } + LegacyFilterOperator.LT -> compare(field, filter.value, root, builder) { expression, value -> builder.lessThan(expression, value) } + LegacyFilterOperator.LTE -> compare(field, filter.value, root, builder) { expression, value -> builder.lessThanOrEqualTo(expression, value) } + } + } + + private fun starredPredicate( + filter: LegacyFieldFilter, + root: Root, + query: CriteriaQuery, + builder: CriteriaBuilder, + ): Predicate { + val subquery = query.subquery(String::class.java) + val star = subquery.from(PasteStar::class.java) + subquery.select(star.get("paste")).where(builder.equal(star.get("userId"), filter.value)) + val predicate = root.get("key").`in`(subquery) + return if (filter.operator == LegacyFilterOperator.NE) builder.not(predicate) else predicate + } + + private fun tagsPredicate( + tags: List, + root: Root, + query: CriteriaQuery, + builder: CriteriaBuilder, + operator: LegacyFilterOperator = LegacyFilterOperator.EQ, + ): Predicate { + if (tags.isEmpty()) return builder.conjunction() + val subquery = query.subquery(String::class.java) + val pasteTag = subquery.from(PasteTag::class.java) + subquery.select(pasteTag.get("paste")).where(pasteTag.get("tag").`in`(tags)) + val predicate = root.get("key").`in`(subquery) + return if (operator == LegacyFilterOperator.NE) builder.not(predicate) else predicate + } + + private fun engagementPredicate( + filter: LegacyFieldFilter, + root: Root, + query: CriteriaQuery, + builder: CriteriaBuilder, + ): Predicate { + val score = engagementExpression(root, query, builder) + val value = filter.value?.toIntOrNull() ?: 0 + return when (filter.operator) { + LegacyFilterOperator.EQ -> builder.equal(score, value) + LegacyFilterOperator.NE -> builder.notEqual(score, value) + LegacyFilterOperator.NULL -> builder.equal(score, 0) + LegacyFilterOperator.NOT_NULL -> builder.notEqual(score, 0) + LegacyFilterOperator.GT -> builder.greaterThan(score, value) + LegacyFilterOperator.GTE -> builder.greaterThanOrEqualTo(score, value) + LegacyFilterOperator.LT -> builder.lessThan(score, value) + LegacyFilterOperator.LTE -> builder.lessThanOrEqualTo(score, value) + } + } + + private fun searchPredicate(search: String, root: Root, builder: CriteriaBuilder): Predicate { + val pattern = "%${search.lowercase()}%" + return builder.or( + builder.like(builder.lower(root.get("title")), pattern), + builder.like(root.get("content"), "%$search%"), + ) + } + + private fun order( + sort: LegacySort, + root: Root, + query: CriteriaQuery, + builder: CriteriaBuilder, + ): jakarta.persistence.criteria.Order { + val field = normalizeField(sort.field) + val expression: Expression> = if (field == "engagementScore") { + engagementExpression(root, query, builder) + } else { + root.get(field) + } + return if (sort.ascending) builder.asc(expression) else builder.desc(expression) + } + + private fun engagementExpression( + root: Root, + query: CriteriaQuery, + builder: CriteriaBuilder, + ): Expression { + val subquery = query.subquery(Int::class.java) + val engagement = subquery.from(PublicPasteEngagement::class.java) + subquery.select(engagement.get("score")) + .where(builder.equal(engagement.get("pasteId"), root.get("id"))) + return builder.coalesce(subquery, 0) + } + + private fun compare( + field: String, + rawValue: String?, + root: Root, + builder: CriteriaBuilder, + operation: (Expression>, Comparable) -> Predicate, + ): Predicate { + val value = rawValue?.let { convert(field, it) as? Comparable } ?: return builder.disjunction() + @Suppress("UNCHECKED_CAST") + val expression = root.get>(field) + return operation(expression, value) + } + + private fun normalizeField(field: String): String = FIELD_ALIASES[field] ?: field + + private fun convert(field: String, value: String): Any = when (field) { + "id", "version", "length" -> value.toIntOrNull() ?: value + "encrypted", "indexedInElastic" -> value.toBooleanStrictOrNull() ?: value.equals("true", true) + "visibility" -> runCatching { PasteVisibility.valueOf(value.uppercase()) }.getOrElse { value } + "type" -> runCatching { PasteType.valueOf(value.uppercase()) }.getOrElse { value } + "storageType" -> runCatching { StorageType.valueOf(value.uppercase()) }.getOrElse { value } + "createdAt", "updatedAt", "expireAt" -> parseInstant(value) ?: value + else -> value + } + + private fun parseInstant(value: String): Instant? = + runCatching { Instant.parse(value) } + .recoverCatching { Timestamp.valueOf(value).toInstant() } + .getOrNull() + + companion object { + private val FIELD_ALIASES = mapOf( + "created_at" to "createdAt", + "updated_at" to "updatedAt", + "expire_at" to "expireAt", + "user_id" to "userId", + "forked_from" to "forkedFrom", + "storage_type" to "storageType", + "indexed_in_elastic" to "indexedInElastic", + "engagement_score" to "engagementScore", + "starred_by" to "starredBy", + "star_count" to "starCount", + ) + } +} diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQuery.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQuery.kt new file mode 100644 index 0000000..e3171b5 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQuery.kt @@ -0,0 +1,52 @@ +package de.interaapps.pastefy.service.query + +import de.interaapps.pastefy.entities.User + +data class LegacyPasteQuery( + val page: Int, + val pageLimit: Int, + val search: String?, + val sorts: List, + val filter: LegacyFilter?, + val filterTags: List, + val shortenContent: Boolean, + val withAiInfo: Boolean, + val currentUser: User?, +) { + val offset: Int get() = pageLimit * (page - 1) +} + +data class LegacySort( + val field: String, + val ascending: Boolean, +) + +sealed interface LegacyFilter + +data class LegacyFilterGroup( + val operator: LegacyGroupOperator, + val children: List, +) : LegacyFilter + +data class LegacyFieldFilter( + val field: String, + val operator: LegacyFilterOperator, + val value: String? = null, +) : LegacyFilter + +enum class LegacyGroupOperator { AND, OR } + +enum class LegacyFilterOperator(val legacyName: String) { + EQ("\$eq"), + NE("\$ne"), + NULL("\$null"), + NOT_NULL("\$notNull"), + GT("\$gt"), + GTE("\$gte"), + LT("\$lt"), + LTE("\$lte"); + + companion object { + fun fromLegacyName(value: String): LegacyFilterOperator? = entries.firstOrNull { it.legacyName == value } + } +} diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQueryParser.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQueryParser.kt new file mode 100644 index 0000000..603d205 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQueryParser.kt @@ -0,0 +1,174 @@ +package de.interaapps.pastefy.service.query + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import de.interaapps.pastefy.config.PastefyProperties +import de.interaapps.pastefy.entities.User +import de.interaapps.pastefy.enums.PasteVisibility +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.stereotype.Component + +@Component +class LegacyPasteQueryParser( + private val objectMapper: ObjectMapper, + private val properties: PastefyProperties, +) { + fun parse( + request: HttpServletRequest, + response: HttpServletResponse, + user: User?, + guarded: Boolean = true, + visibility: PasteVisibility? = null, + encrypted: Boolean? = null, + userId: String? = null, + starredBy: String? = null, + defaultSort: String = "createdAt", + additionalFilters: List = emptyList(), + ): LegacyPasteQuery { + val page = request.getParameter("page")?.toIntOrNull()?.coerceAtLeast(1) ?: 1 + val pageLimit = request.getParameter("page_limit")?.toIntOrNull()?.coerceAtLeast(1) + ?.coerceAtMost(properties.paginationPageLimit.coerceAtLeast(1)) + ?: properties.paginationPageLimit.coerceAtLeast(1) + + response.setHeader("PAGINATION_LIMIT", pageLimit.toString()) + response.setHeader("PAGINATION_PAGE", (page - 1).toString()) + + val filters = mutableListOf() + parseClientFilter(request)?.let(filters::add) + request.getParameter("folder")?.takeIf(String::isNotBlank) + ?.let { filters += LegacyFieldFilter("folder", LegacyFilterOperator.EQ, it) } + visibility?.let { filters += LegacyFieldFilter("visibility", LegacyFilterOperator.EQ, it.name) } + encrypted?.let { filters += LegacyFieldFilter("encrypted", LegacyFilterOperator.EQ, it.toString()) } + userId?.let { filters += LegacyFieldFilter("userId", LegacyFilterOperator.EQ, it) } + starredBy?.let { filters += LegacyFieldFilter("starredBy", LegacyFilterOperator.EQ, it) } + filters += additionalFilters + + if (guarded && user?.isAdmin != true) { + val visible = mutableListOf(LegacyFieldFilter("visibility", LegacyFilterOperator.EQ, PasteVisibility.PUBLIC.name)) + user?.let { + visible += LegacyFieldFilter("userId", LegacyFilterOperator.EQ, it.id) + visible += LegacyFieldFilter("starredBy", LegacyFilterOperator.EQ, it.id) + } + filters += LegacyFilterGroup(LegacyGroupOperator.OR, visible) + } + + return LegacyPasteQuery( + page = page, + pageLimit = pageLimit, + search = request.getParameter("search")?.trim()?.takeIf(String::isNotEmpty), + sorts = parseSorts(request.getParameter("sort"), defaultSort), + filter = filters.compactAnd(), + filterTags = request.getParameter("filter_tags") + ?.split(',') + ?.map(String::trim) + ?.filter(String::isNotEmpty) + .orEmpty(), + shortenContent = request.getParameter("shorten_content").equals("true", true), + withAiInfo = request.getParameter("with_ai_analysis").equals("true", true), + currentUser = user, + ) + } + + private fun parseSorts(raw: String?, defaultSort: String): List { + val source = raw?.takeIf(String::isNotBlank) ?: defaultSort + return source.split(',').mapNotNull { part -> + val value = part.trim().takeIf(String::isNotEmpty) ?: return@mapNotNull null + LegacySort(field = value.removePrefix("+"), ascending = value.startsWith("+")) + } + } + + private fun parseClientFilter(request: HttpServletRequest): LegacyFilter? { + request.getParameter("filters")?.trim()?.takeIf(String::isNotEmpty)?.let { + return parseJsonObject(objectMapper.readTree(it)) + } + + request.getParameter("filter")?.trim()?.takeIf { it.startsWith("{") }?.let { + return parseJsonObject(objectMapper.readTree(it)).let { filter -> + LegacyFilterGroup(LegacyGroupOperator.AND, listOf(filter)) + } + } + + val formFilter = mutableMapOf() + request.parameterMap.forEach { (name, values) -> + val tokens = BRACKET_TOKEN.findAll(name).map { it.groupValues[1] }.toList() + if (tokens.firstOrNull() == "filter" && tokens.size > 1) { + insert(formFilter, tokens.drop(1), values.map(String::trim).filter(String::isNotEmpty)) + } + } + if (formFilter.isEmpty()) return null + return parseObject(formFilter).let { LegacyFilterGroup(LegacyGroupOperator.AND, listOf(it)) } + } + + private fun insert(target: MutableMap, path: List, values: List) { + if (path.isEmpty()) return + val key = path.first() + if (path.size == 1) { + target[key] = values.firstOrNull().orEmpty() + return + } + val child = target.getOrPut(key) { mutableMapOf() } + if (child is MutableMap<*, *>) { + @Suppress("UNCHECKED_CAST") + insert(child as MutableMap, path.drop(1), values) + } + } + + private fun parseJsonObject(node: JsonNode): LegacyFilter = parseObject(jsonToAny(node) as Map) + + private fun jsonToAny(node: JsonNode): Any? = when { + node.isObject -> node.fields().asSequence().associate { it.key to jsonToAny(it.value) } + node.isArray -> node.map(::jsonToAny) + node.isNull -> null + node.isBoolean -> node.booleanValue().toString() + node.isNumber -> node.asText() + else -> node.asText() + } + + private fun parseObject(values: Map): LegacyFilter = + values.mapNotNull { (key, value) -> parseEntry(key, value) }.compactAnd() + ?: LegacyFilterGroup(LegacyGroupOperator.AND, emptyList()) + + private fun parseEntry(key: String, value: Any?): LegacyFilter? = when (key) { + "\$and" -> LegacyFilterGroup(LegacyGroupOperator.AND, parseGroupChildren(value)) + "\$or" -> LegacyFilterGroup(LegacyGroupOperator.OR, parseGroupChildren(value)) + else -> parseField(key, value) + } + + private fun parseGroupChildren(value: Any?): List = when (value) { + is List<*> -> value.mapNotNull { parseGroupChild(it) } + is Map<*, *> -> listOfNotNull(parseGroupChild(value)) + else -> emptyList() + } + + private fun parseGroupChild(value: Any?): LegacyFilter? = when (value) { + is Map<*, *> -> { + @Suppress("UNCHECKED_CAST") + parseObject(value as Map) + } + else -> null + } + + private fun parseField(field: String, value: Any?): LegacyFilter? { + if (value is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + val operators = value as Map + val filters = operators.mapNotNull { (operator, operatorValue) -> + LegacyFilterOperator.fromLegacyName(operator) + ?.let { LegacyFieldFilter(field, it, operatorValue?.toString()) } + } + if (filters.isNotEmpty()) return filters.compactAnd() + } + return LegacyFieldFilter(field, LegacyFilterOperator.EQ, value?.toString()) + } + + private fun List.compactAnd(): LegacyFilter? = when (size) { + 0 -> null + 1 -> first() + else -> LegacyFilterGroup(LegacyGroupOperator.AND, this) + } + + companion object { + private val BRACKET_TOKEN = Regex("([^\\[\\]]+)") + } +} diff --git a/backend-kt/src/main/resources/application-local.properties b/backend-kt/src/main/resources/application-local.properties new file mode 100644 index 0000000..db5f719 --- /dev/null +++ b/backend-kt/src/main/resources/application-local.properties @@ -0,0 +1,34 @@ +# Local development profile: seed deterministic test data into the configured database. +# The seeder is idempotent and only inserts missing records. +pastefy.seeding.enabled=${PASTEFY_SEEDING_ENABLED:true} + +# Local docker-compose services in backend-kt/docker-compose.yaml. +pastefy.redis.enabled=${PASTEFY_REDIS_ENABLED:true} +pastefy.redis.cache-after-accesses=${PASTEFY_REDIS_CACHE_AFTER_ACCESSES:1} +spring.data.redis.host=${REDIS_HOST:localhost} +spring.data.redis.port=${REDIS_PORT:6379} +spring.data.redis.password=${REDIS_PASSWORD:} + +pastefy.s3.enabled=${PASTEFY_S3_ENABLED:true} +pastefy.s3.endpoint=${MINIO_SERVER:http://localhost:9000} +pastefy.s3.access-key=${MINIO_ACCESS_KEY:minioadmin} +pastefy.s3.secret-key=${MINIO_SECRET_KEY:minioadmin} +pastefy.s3.bucket=${MINIO_BUCKET:pastefy} +pastefy.s3.region=${MINIO_REGION:us-east-1} +pastefy.s3.paste-size-threshold=${MINIO_PASTESIZE_THRESHOLD:1024} +pastefy.s3.create-bucket=${MINIO_CREATE_BUCKET:true} + +pastefy.elasticsearch.enabled=${PASTEFY_ELASTICSEARCH_ENABLED:true} +pastefy.elasticsearch.migrations.enabled=${PASTEFY_ELASTICSEARCH_MIGRATIONS_ENABLED:true} +spring.elasticsearch.uris=${ELASTICSEARCH_URL:http://localhost:9200} +spring.elasticsearch.username=${ELASTICSEARCH_USER:} +spring.elasticsearch.password=${ELASTICSEARCH_PASSWORD:} + +pastefy.analytics.enabled=${PASTEFY_ANALYTICS_ENABLED:true} +pastefy.analytics.jdbc-url=${ANALYTICS_CLICKHOUSE_JDBC_URL:jdbc:clickhouse://localhost:8123/pastefy} +pastefy.analytics.database=${ANALYTICS_CLICKHOUSE_DATABASE:pastefy} +pastefy.analytics.table=${ANALYTICS_CLICKHOUSE_TABLE:pastefy_analytics_visits} +pastefy.analytics.user=${ANALYTICS_CLICKHOUSE_USER:pastefy} +pastefy.analytics.password=${ANALYTICS_CLICKHOUSE_PASSWORD:pastefy} +pastefy.analytics.ip-hash-salt=${ANALYTICS_IP_HASH_SALT:local-development-ip-hash-salt} +pastefy.analytics.migrations.enabled=${ANALYTICS_CLICKHOUSE_MIGRATIONS_ENABLED:true} diff --git a/backend-kt/src/main/resources/application.properties b/backend-kt/src/main/resources/application.properties index 7512500..7457f95 100644 --- a/backend-kt/src/main/resources/application.properties +++ b/backend-kt/src/main/resources/application.properties @@ -37,8 +37,10 @@ pastefy.encryption-default=${PASTEFY_ENCRYPTION_DEFAULT:false} pastefy.custom-logo=${PASTEFY_CUSTOM_LOGO:} pastefy.custom-name=${PASTEFY_CUSTOM_NAME:} pastefy.custom-footer=${PASTEFY_CUSTOM_FOOTER:} +pastefy.custom-header=${PASTEFY_CUSTOM_HEADER:} +pastefy.custom-body=${PASTEFY_CUSTOM_BODY:} pastefy.grant-access-required=${PASTEFY_GRANT_ACCESS_REQUIRED:false} -pastefy.oauth-state-secret=${PASTEFY_OAUTH_STATE_SECRET:} +pastefy.oauth-state-secret=${PASTEFY_OAUTH_STATE_SECRET:pastefydefaultstatechangeitinfuture} pastefy.oauth.callback-base-url=${PASTEFY_OAUTH_CALLBACK_BASE_URL:http://localhost:8080} pastefy.oauth.interaapps.client-id=${OAUTH2_INTERAAPPS_CLIENT_ID:} pastefy.oauth.interaapps.client-secret=${OAUTH2_INTERAAPPS_CLIENT_SECRET:} @@ -76,6 +78,7 @@ pastefy.s3.secret-key=${MINIO_SECRET_KEY:} pastefy.s3.bucket=${MINIO_BUCKET:pastefy} pastefy.s3.region=${MINIO_REGION:} pastefy.s3.paste-size-threshold=${MINIO_PASTESIZE_THRESHOLD:-1} +pastefy.s3.create-bucket=${MINIO_CREATE_BUCKET:false} pastefy.elasticsearch.enabled=${PASTEFY_ELASTICSEARCH_ENABLED:false} pastefy.elasticsearch.index-name=${PASTEFY_ELASTICSEARCH_INDEX_NAME:pastefy_pastes_current} @@ -126,6 +129,9 @@ pastefy.analytics.track-bots=${ANALYTICS_TRACK_BOTS:true} # Flyway owns ClickHouse schema changes. Enable only in the deployment step that applies migrations. pastefy.analytics.migrations.enabled=${ANALYTICS_CLICKHOUSE_MIGRATIONS_ENABLED:false} +# Local test fixtures. The seeder is disabled by default and does not run with the prod Spring profile. +pastefy.seeding.enabled=${PASTEFY_SEEDING_ENABLED:false} + springdoc.api-docs.path=/v3/api-docs springdoc.swagger-ui.path=/swagger-ui.html springdoc.model-converters.pageable-converter.enabled=true diff --git a/backend-kt/src/main/resources/db/changelog/001-baseline.yaml b/backend-kt/src/main/resources/db/changelog/001-baseline.yaml new file mode 100644 index 0000000..f5c277d --- /dev/null +++ b/backend-kt/src/main/resources/db/changelog/001-baseline.yaml @@ -0,0 +1,910 @@ +databaseChangeLog: +- changeSet: + id: 1780596708247-1 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: false + primaryKey: true + name: id + type: INT + - column: + constraints: + unique: true + name: key + type: VARCHAR(60) + - column: + name: api_key + type: VARCHAR(160) + - column: + name: user_id + type: VARCHAR(8) + - column: + defaultValue: 'API' + name: type + type: ENUM('API', 'USER', 'ACCESS_TOKEN') + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() on update current_timestamp() + name: created_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: CURRENT_TIMESTAMP + name: updated_at + type: TIMESTAMP(0) + - column: + name: scopes + type: TEXT + - column: + name: access_token + type: VARCHAR(255) + - column: + name: refresh_token + type: VARCHAR(255) + tableName: pastefy_auth_keys +- changeSet: + id: 1780596708247-2 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + constraints: + nullable: false + primaryKey: true + name: key + type: VARCHAR(255) + - column: + defaultValueComputed: 'NULL' + name: type + type: ENUM('PASTE_AI_INFO') + - column: + defaultValueComputed: 'NULL' + name: entity_id + type: INT + - column: + defaultValueComputed: 'NULL' + name: source_version + type: INT + - column: + defaultValueComputed: 'NULL' + name: prompt_version + type: INT + - column: + defaultValue: 'PENDING' + name: status + type: ENUM('PENDING', 'RUNNING', 'DONE', 'FAILED') + - column: + defaultValueComputed: 'NULL' + name: attempts + type: INT + - column: + defaultValueComputed: 'NULL' + name: available_at + type: TIMESTAMP(0) + - column: + defaultValueComputed: 'NULL' + name: lease_until + type: TIMESTAMP(0) + - column: + name: lease_token + type: VARCHAR(64) + - column: + name: last_error + type: VARCHAR(2048) + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() + name: created_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() on update current_timestamp() + name: updated_at + type: TIMESTAMP(0) + tableName: pastefy_background_jobs +- changeSet: + id: 1780596708247-3 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: false + primaryKey: true + name: id + type: INT + - column: + name: key + type: VARCHAR(8) + - column: + name: name + type: TEXT + - column: + name: user_id + type: VARCHAR(8) + - column: + name: parent + type: TEXT + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() on update current_timestamp() + name: created_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: CURRENT_TIMESTAMP + name: updated_at + type: TIMESTAMP(0) + tableName: pastefy_folder +- changeSet: + id: 1780596708247-4 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: false + primaryKey: true + name: id + type: INT + - column: + name: message + type: TEXT + - column: + name: user_id + type: VARCHAR(8) + - column: + name: url + type: TEXT + - column: + name: already_read + type: BIT + - column: + name: received + type: BIT + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() on update current_timestamp() + name: created_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: CURRENT_TIMESTAMP + name: updated_at + type: TIMESTAMP(0) + tableName: pastefy_notification +- changeSet: + id: 1780596708247-5 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + constraints: + nullable: false + primaryKey: true + name: paste_id + type: INT + - column: + defaultValueComputed: 'NULL' + name: source_paste_version + type: INT + - column: + defaultValueComputed: 'NULL' + name: prompt_version + type: INT + - column: + name: provider + type: VARCHAR(30) + - column: + name: model + type: VARCHAR(100) + - column: + name: description + type: VARCHAR(500) + - column: + name: tags_json + type: VARCHAR(2048) + - column: + name: warnings_json + type: VARCHAR(4096) + - column: + name: dangerous + type: BIT + - column: + defaultValueComputed: 'NULL' + name: max_severity + type: INT + - column: + name: suggested_filename + type: VARCHAR(255) + - column: + defaultValueComputed: 'NULL' + name: generated_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() + name: created_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() on update current_timestamp() + name: updated_at + type: TIMESTAMP(0) + tableName: pastefy_paste_ai_info +- changeSet: + id: 1780596708247-6 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: false + primaryKey: true + name: id + type: INT + - column: + constraints: + nullable: false + name: paste + type: VARCHAR(8) + - column: + constraints: + nullable: false + name: user_id + type: VARCHAR(8) + - column: + defaultValueComputed: 'NULL' + name: parent_id + type: INT + - column: + constraints: + nullable: false + name: content + type: VARCHAR(2000) + - column: + defaultValueComputed: 'NULL' + name: line_from + type: INT + - column: + defaultValueComputed: 'NULL' + name: line_to + type: INT + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() + name: created_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() on update current_timestamp() + name: updated_at + type: TIMESTAMP(0) + tableName: pastefy_paste_comments +- changeSet: + id: 1780596708247-7 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: false + primaryKey: true + name: id + type: INT + - column: + name: paste + type: VARCHAR(8) + - column: + name: user_id + type: VARCHAR(8) + tableName: pastefy_paste_stars +- changeSet: + id: 1780596708247-8 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: false + primaryKey: true + name: id + type: INT + - column: + name: paste + type: VARCHAR(8) + - column: + name: tag + type: VARCHAR(30) + tableName: pastefy_paste_tags +- changeSet: + id: 1780596708247-9 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: false + primaryKey: true + name: id + type: INT + - column: + name: key + type: VARCHAR(8) + - column: + name: title + type: TEXT + - column: + name: content + type: LONGTEXT + - column: + name: user_id + type: VARCHAR(8) + - column: + name: encrypted + type: BIT + - column: + name: folder + type: VARCHAR(8) + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() + name: created_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: CURRENT_TIMESTAMP + name: updated_at + type: TIMESTAMP(0) + - column: + defaultValue: 'PASTE' + name: type + type: ENUM('PASTE', 'MULTI_PASTE') + - column: + name: forked_from + type: VARCHAR(8) + - column: + defaultValue: 'UNLISTED' + name: visibility + type: ENUM('UNLISTED', 'PUBLIC', 'PRIVATE') + - column: + defaultValueComputed: 'NULL' + name: expire_at + type: TIMESTAMP(0) + - column: + defaultValue: 'DATABASE' + name: storage_type + type: ENUM('DATABASE', 'S3', 'HTTP') + - column: + defaultValueComputed: 'NULL' + name: version + type: INT + - column: + name: indexed_in_elastic + type: BIT + - column: + defaultValueComputed: 'NULL' + name: length + type: INT + - column: + name: hash + type: VARCHAR(64) + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-10 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: false + primaryKey: true + name: id + type: INT + - column: + defaultValueComputed: 'NULL' + name: paste_id + type: INT + - column: + defaultValueComputed: 'NULL' + name: score + type: INT + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() on update current_timestamp() + name: created_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: CURRENT_TIMESTAMP + name: updated_at + type: TIMESTAMP(0) + tableName: pastefy_public_paste_engagements +- changeSet: + id: 1780596708247-11 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: false + primaryKey: true + name: id + type: INT + - column: + name: user_id + type: VARCHAR(8) + - column: + name: target_id + type: VARCHAR(8) + - column: + name: paste + type: TEXT + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() on update current_timestamp() + name: created_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: CURRENT_TIMESTAMP + name: updated_at + type: TIMESTAMP(0) + tableName: pastefy_shared_pastes +- changeSet: + id: 1780596708247-12 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + constraints: + nullable: false + primaryKey: true + name: tag + type: VARCHAR(30) + - column: + name: display_name + type: TEXT + - column: + name: image_url + type: TEXT + - column: + name: description + type: TEXT + - column: + name: website + type: TEXT + - column: + name: icon + type: TEXT + - column: + defaultValueComputed: 'NULL' + name: paste_count + type: INT + tableName: pastefy_tag_listing +- changeSet: + id: 1780596708247-13 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + constraints: + nullable: false + primaryKey: true + name: id + type: VARCHAR(8) + - column: + name: name + type: TEXT + - column: + name: unique_name + type: VARCHAR(33) + - column: + name: email + type: TEXT + - column: + name: avatar + type: TEXT + - column: + name: auth_id + type: VARCHAR(455) + - column: + name: auth_provider + type: ENUM('INTERAAPPS', 'GOOGLE', 'GITHUB', 'TWITCH', 'DISCORD') + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() on update current_timestamp() + name: created_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: CURRENT_TIMESTAMP + name: updated_at + type: TIMESTAMP(0) + - column: + defaultValue: 'USER' + name: type + type: ENUM('USER', 'ADMIN', 'BLOCKED', 'AWAITING_ACCESS') + tableName: pastefy_users +- changeSet: + id: 1780596708247-14 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: user_id + indexName: pastefy_auth_keys_user_id_index + tableName: pastefy_auth_keys +- changeSet: + id: 1780596708247-15 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: status + - column: + defaultValueComputed: 'NULL' + name: available_at + indexName: pastefy_background_jobs_status_available_at_index + tableName: pastefy_background_jobs +- changeSet: + id: 1780596708247-16 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: status + - column: + defaultValueComputed: 'NULL' + name: lease_until + indexName: pastefy_background_jobs_status_lease_until_index + tableName: pastefy_background_jobs +- changeSet: + id: 1780596708247-17 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: type + - column: + defaultValueComputed: 'NULL' + name: entity_id + indexName: pastefy_background_jobs_type_entity_id_index + tableName: pastefy_background_jobs +- changeSet: + id: 1780596708247-18 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: key + indexName: pastefy_folder_key_index + tableName: pastefy_folder +- changeSet: + id: 1780596708247-19 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: user_id + indexName: pastefy_folder_user_id_index + tableName: pastefy_folder +- changeSet: + id: 1780596708247-20 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: user_id + - column: + name: received + indexName: pastefy_notification_user_id_received_index + tableName: pastefy_notification +- changeSet: + id: 1780596708247-21 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + defaultValueComputed: 'NULL' + name: parent_id + indexName: pastefy_paste_comments_parent_id_index + tableName: pastefy_paste_comments +- changeSet: + id: 1780596708247-22 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: paste + indexName: pastefy_paste_comments_paste_index + tableName: pastefy_paste_comments +- changeSet: + id: 1780596708247-23 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: paste + - column: + defaultValueComputed: 'NULL' + name: line_from + indexName: pastefy_paste_comments_paste_line_index + tableName: pastefy_paste_comments +- changeSet: + id: 1780596708247-24 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: user_id + indexName: pastefy_paste_comments_user_id_index + tableName: pastefy_paste_comments +- changeSet: + id: 1780596708247-25 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: paste + indexName: pastefy_paste_stars_paste_index + tableName: pastefy_paste_stars +- changeSet: + id: 1780596708247-26 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: paste + - column: + name: user_id + indexName: pastefy_paste_stars_paste_user_id_index + tableName: pastefy_paste_stars +- changeSet: + id: 1780596708247-27 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: paste + - column: + name: tag + indexName: pastefy_paste_tags_paste_tag_index + tableName: pastefy_paste_tags +- changeSet: + id: 1780596708247-28 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + defaultValueComputed: 'NULL' + name: expire_at + indexName: pastefy_pastes_expire_at_index + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-29 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: folder + indexName: pastefy_pastes_folder_index + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-30 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: hash + indexName: pastefy_pastes_hash_index + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-31 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: indexed_in_elastic + indexName: pastefy_pastes_indexed_in_elastic_index + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-32 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: key + indexName: pastefy_pastes_key_index + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-33 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + defaultValueComputed: 'NULL' + name: length + indexName: pastefy_pastes_length_index + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-34 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: storage_type + indexName: pastefy_pastes_storage_type_index + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-35 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: user_id + - column: + name: folder + indexName: pastefy_pastes_user_folder_index + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-36 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: user_id + indexName: pastefy_pastes_user_id_index + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-37 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: visibility + indexName: pastefy_pastes_visibility_index + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-38 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + defaultValueComputed: 'NULL' + name: paste_id + - column: + defaultValueComputed: 'NULL' + name: score + indexName: pastefy_public_paste_engagements_id_score_index + tableName: pastefy_public_paste_engagements +- changeSet: + id: 1780596708247-39 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + defaultValueComputed: 'NULL' + name: paste_id + indexName: pastefy_public_paste_engagements_paste_id_index + tableName: pastefy_public_paste_engagements +- changeSet: + id: 1780596708247-40 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: target_id + indexName: pastefy_shared_pastes_target_id_index + tableName: pastefy_shared_pastes +- changeSet: + id: 1780596708247-41 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: user_id + indexName: pastefy_shared_pastes_user_id_index + tableName: pastefy_shared_pastes + diff --git a/backend-kt/src/main/resources/db/changelog/002-json-transformations.yaml b/backend-kt/src/main/resources/db/changelog/002-json-transformations.yaml new file mode 100644 index 0000000..df61051 --- /dev/null +++ b/backend-kt/src/main/resources/db/changelog/002-json-transformations.yaml @@ -0,0 +1,18 @@ +databaseChangeLog: + - changeSet: + id: 002-auth-keys-scopes-json + author: juliangojani + preConditions: + - onFail: HALT + - sqlCheck: + expectedResult: 0 + sql: > + SELECT COUNT(*) + FROM pastefy_auth_keys + WHERE scopes IS NOT NULL + AND JSON_VALID(scopes) = 0 + changes: + - modifyDataType: + tableName: pastefy_auth_keys + columnName: scopes + newDataType: JSON \ No newline at end of file diff --git a/backend-kt/src/main/resources/db/changelog/db.changelog-master.yaml b/backend-kt/src/main/resources/db/changelog/db.changelog-master.yaml index daf9f00..98a68c5 100644 --- a/backend-kt/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/backend-kt/src/main/resources/db/changelog/db.changelog-master.yaml @@ -1 +1,7 @@ -databaseChangeLog: [] +databaseChangeLog: + - include: + file: 001-baseline.yaml + relativeToChangelogFile: true + - include: + file: 002-json-transformations.yaml + relativeToChangelogFile: true \ No newline at end of file diff --git a/backend-kt/src/main/resources/static/index.html b/backend-kt/src/main/resources/static/index.html index 47dd132..e4b0ae5 100644 --- a/backend-kt/src/main/resources/static/index.html +++ b/backend-kt/src/main/resources/static/index.html @@ -30,8 +30,8 @@ - - + +
diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/controller/ControllerHttpTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/controller/ControllerHttpTest.kt index 0167ce9..80474ce 100644 --- a/backend-kt/src/test/kotlin/de/interaapps/pastefy/controller/ControllerHttpTest.kt +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/controller/ControllerHttpTest.kt @@ -4,19 +4,32 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.PropertyNamingStrategies import de.interaapps.pastefy.auth.annotations.CurrentAuthKey import de.interaapps.pastefy.auth.annotations.CurrentUser +import de.interaapps.pastefy.auth.oauth.OAuth2ProviderRegistry import de.interaapps.pastefy.config.PastefyProperties import de.interaapps.pastefy.controller.public.PublicUserController +import de.interaapps.pastefy.controller.pastes.PasteMetaSSRController import de.interaapps.pastefy.controller.pastes.PasteRawController import de.interaapps.pastefy.controller.stats.StatsController +import de.interaapps.pastefy.controller.user.UserController import de.interaapps.pastefy.dto.app.StatsResponse import de.interaapps.pastefy.entities.AuthKey import de.interaapps.pastefy.entities.User import de.interaapps.pastefy.infrastructure.ai.PasteAI import de.interaapps.pastefy.infrastructure.analytics.AnalyticsService +import de.interaapps.pastefy.repositories.PasteAIInfoRepository +import de.interaapps.pastefy.repositories.PasteRepository +import de.interaapps.pastefy.repositories.PasteTagRepository +import de.interaapps.pastefy.repositories.SharedPasteRepository +import de.interaapps.pastefy.repositories.UserRepository +import de.interaapps.pastefy.service.FolderService +import de.interaapps.pastefy.service.FrontendIndexService import de.interaapps.pastefy.service.PasteService +import de.interaapps.pastefy.service.PasteQueryService import de.interaapps.pastefy.service.PublicPasteEngagementService +import de.interaapps.pastefy.service.SeoRenderer import de.interaapps.pastefy.service.StatsService import de.interaapps.pastefy.service.UserService +import jakarta.servlet.RequestDispatcher import org.junit.jupiter.api.Test import org.mockito.Mockito.mock import org.mockito.Mockito.`when` @@ -117,6 +130,110 @@ class ControllerHttpTest { } } + @Test + fun `logged out user response still exposes configured auth types`() { + val providers = mock(OAuth2ProviderRegistry::class.java) + `when`(providers.names()).thenReturn(linkedSetOf("interaapps", "github")) + val mvc = mockMvc( + UserController( + mock(UserService::class.java), + mock(FolderService::class.java), + mock(PasteQueryService::class.java), + mock(PasteRepository::class.java), + mock(SharedPasteRepository::class.java), + providers, + ), + ) + + mvc.get("/api/v2/user") + .andExpect { + status { isOk() } + jsonPath("$.logged_in") { value(false) } + jsonPath("$.auth_types[0]") { value("interaapps") } + jsonPath("$.auth_types[1]") { value("github") } + } + } + + @Test + fun `frontend root serves prepared index`() { + val properties = PastefyProperties(customHeader = "") + val mvc = mockMvc(FrontendController(FrontendIndexService(properties))) + + mvc.get("/") + .andExpect { + status { isOk() } + content { contentType("text/html;charset=UTF-8") } + content { string(org.hamcrest.Matchers.containsString("
")) } + content { string(org.hamcrest.Matchers.containsString("")) } + } + } + + @Test + fun `frontend error fallback serves prepared index for spa routes`() { + val mvc = mockMvc(FrontendController(FrontendIndexService(PastefyProperties()))) + + mvc.get("/error") { + requestAttr(RequestDispatcher.ERROR_STATUS_CODE, 404) + requestAttr(RequestDispatcher.ERROR_REQUEST_URI, "/fewnadscfknjajsf/whrsdefncwas") + }.andExpect { + status { isOk() } + content { contentType("text/html;charset=UTF-8") } + content { string(org.hamcrest.Matchers.containsString("
")) } + } + } + + @Test + fun `frontend fallback does not swallow api routes`() { + val mvc = mockMvc(FrontendController(FrontendIndexService(PastefyProperties()))) + + mvc.get("/error") { + requestAttr(RequestDispatcher.ERROR_STATUS_CODE, 404) + requestAttr(RequestDispatcher.ERROR_REQUEST_URI, "/api/does-not-exist") + } + .andExpect { + status { isNotFound() } + } + } + + @Test + fun `frontend error fallback also serves prepared index for missing assets`() { + val mvc = mockMvc(FrontendController(FrontendIndexService(PastefyProperties()))) + + mvc.get("/error") { + requestAttr(RequestDispatcher.ERROR_STATUS_CODE, 404) + requestAttr(RequestDispatcher.ERROR_REQUEST_URI, "/assets/missing.js") + }.andExpect { + status { isOk() } + content { contentType("text/html;charset=UTF-8") } + } + } + + @Test + fun `missing paste seo route falls back to frontend index`() { + val pasteService = mock(PasteService::class.java) + `when`(pasteService.get("missing1")).thenReturn(null) + val properties = PastefyProperties() + val frontend = FrontendIndexService(properties) + val mvc = mockMvc( + PasteMetaSSRController( + pasteService, + mock(UserRepository::class.java), + mock(PasteTagRepository::class.java), + mock(PasteAIInfoRepository::class.java), + properties, + SeoRenderer(properties, frontend), + frontend, + ), + ) + + mvc.get("/missing1") + .andExpect { + status { isOk() } + content { contentType("text/html;charset=UTF-8") } + content { string(org.hamcrest.Matchers.containsString("
")) } + } + } + private fun mockMvc(controller: Any): MockMvc = MockMvcBuilders.standaloneSetup(controller) .setCustomArgumentResolvers(NullableAuthArgumentResolver()) diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/repositories/PasteCommentRepositoryIntegrationTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/repositories/PasteCommentRepositoryIntegrationTest.kt index 55b2e91..d8a0567 100644 --- a/backend-kt/src/test/kotlin/de/interaapps/pastefy/repositories/PasteCommentRepositoryIntegrationTest.kt +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/repositories/PasteCommentRepositoryIntegrationTest.kt @@ -1,15 +1,23 @@ package de.interaapps.pastefy.repositories +import com.fasterxml.jackson.databind.ObjectMapper import de.interaapps.pastefy.entities.AuthKey import de.interaapps.pastefy.entities.Paste import de.interaapps.pastefy.entities.PasteComment +import de.interaapps.pastefy.entities.PasteStar import de.interaapps.pastefy.entities.PublicPasteEngagement import de.interaapps.pastefy.enums.PasteVisibility +import de.interaapps.pastefy.config.PastefyProperties +import de.interaapps.pastefy.service.query.JpaPasteQueryAdapter +import de.interaapps.pastefy.service.query.LegacyPasteQueryParser +import jakarta.persistence.EntityManager import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.data.domain.PageRequest +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse import org.springframework.test.context.DynamicPropertyRegistry import org.springframework.test.context.DynamicPropertySource import org.testcontainers.containers.MySQLContainer @@ -29,6 +37,8 @@ class PasteCommentRepositoryIntegrationTest( @Autowired private val pastes: PasteRepository, @Autowired private val engagements: PublicPasteEngagementRepository, @Autowired private val authKeys: AuthKeyRepository, + @Autowired private val stars: PasteStarRepository, + @Autowired private val entityManager: EntityManager, ) { @Test fun `loads top level comments newest first and replies oldest first`() { @@ -77,9 +87,49 @@ class PasteCommentRepositoryIntegrationTest( ) } + @Test + fun `legacy paste query filters with bracket parameters`() { + pastes.save(paste("query001", "Kotlin Public", PasteVisibility.PUBLIC)) + pastes.save(paste("query002", "Kotlin Private", PasteVisibility.PRIVATE)) + entityManager.flush() + + val request = MockHttpServletRequest().apply { + addParameter("search", "kotlin") + addParameter("filter[visibility]", "PUBLIC") + } + + assertEquals( + listOf("query001"), + queryAdapter().find(parser().parse(request, MockHttpServletResponse(), user = null, guarded = false)).map(Paste::key), + ) + } + + @Test + fun `legacy paste query supports starredBy filters json`() { + val paste = pastes.save(paste("query003", "Starred Paste", PasteVisibility.UNLISTED)) + stars.save(PasteStar(paste = paste.key, userId = "user-001")) + entityManager.flush() + + val request = MockHttpServletRequest().apply { + addParameter("filters", """{"starredBy":"user-001"}""") + } + + assertEquals( + listOf("query003"), + queryAdapter().find(parser().parse(request, MockHttpServletResponse(), user = null, guarded = false)).map(Paste::key), + ) + } + private fun comment(paste: String, content: String, parentId: Int? = null, createdAt: Instant) = PasteComment(paste = paste, userId = "user-001", content = content, parentId = parentId, createdAt = createdAt) + private fun paste(key: String, title: String, visibility: PasteVisibility): Paste = + Paste(key = key, title = title, visibility = visibility).apply { setDatabaseContent(title.lowercase()) } + + private fun parser() = LegacyPasteQueryParser(ObjectMapper(), PastefyProperties()) + + private fun queryAdapter() = JpaPasteQueryAdapter(entityManager) + companion object { @Container @JvmStatic diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/SeoRendererTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/SeoRendererTest.kt index f35a3d4..004da1a 100644 --- a/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/SeoRendererTest.kt +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/SeoRendererTest.kt @@ -8,8 +8,10 @@ import org.junit.jupiter.api.Test class SeoRendererTest { @Test fun `injects escaped metadata and canonical url`() { + val properties = PastefyProperties(metaTagsEnabled = true, serverName = "https://pastefy.example/") val renderer = SeoRenderer( - PastefyProperties(metaTagsEnabled = true, serverName = "https://pastefy.example/"), + properties, + FrontendIndexService(properties), ) val html = renderer.render( diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQueryParserTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQueryParserTest.kt new file mode 100644 index 0000000..fb86ad5 --- /dev/null +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQueryParserTest.kt @@ -0,0 +1,52 @@ +package de.interaapps.pastefy.service.query + +import com.fasterxml.jackson.databind.ObjectMapper +import de.interaapps.pastefy.config.PastefyProperties +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse + +class LegacyPasteQueryParserTest { + private val parser = LegacyPasteQueryParser(ObjectMapper(), PastefyProperties(paginationPageLimit = 25)) + + @Test + fun `parses legacy bracket filters and range operators`() { + val request = MockHttpServletRequest().apply { + addParameter("filter[visibility]", "PUBLIC") + addParameter("filter[createdAt][\$gt]", "2026-01-01T00:00:00Z") + addParameter("page_limit", "100") + addParameter("sort", "+title,createdAt") + } + val response = MockHttpServletResponse() + + val query = parser.parse(request, response, user = null, guarded = false) + val fields = query.filter.collectFields() + + assertEquals(25, query.pageLimit) + assertEquals("25", response.getHeader("PAGINATION_LIMIT")) + assertEquals(listOf(LegacySort("title", true), LegacySort("createdAt", false)), query.sorts) + assertTrue(fields.contains(LegacyFieldFilter("visibility", LegacyFilterOperator.EQ, "PUBLIC"))) + assertTrue(fields.contains(LegacyFieldFilter("createdAt", LegacyFilterOperator.GT, "2026-01-01T00:00:00Z"))) + } + + @Test + fun `parses filters json with or groups`() { + val request = MockHttpServletRequest().apply { + addParameter("filters", """{"${'$'}or":[{"visibility":"PUBLIC"},{"userId":"user-001"}]}""") + } + + val query = parser.parse(request, MockHttpServletResponse(), user = null, guarded = false) + val group = query.filter as LegacyFilterGroup + + assertEquals(LegacyGroupOperator.OR, group.operator) + assertEquals(2, group.children.size) + } + + private fun LegacyFilter?.collectFields(): List = when (this) { + null -> emptyList() + is LegacyFieldFilter -> listOf(this) + is LegacyFilterGroup -> children.flatMap { it.collectFields() } + } +} diff --git a/backend/src/main/java/de/interaapps/pastefy/model/database/Paste.java b/backend/src/main/java/de/interaapps/pastefy/model/database/Paste.java index ca4765d..15680de 100644 --- a/backend/src/main/java/de/interaapps/pastefy/model/database/Paste.java +++ b/backend/src/main/java/de/interaapps/pastefy/model/database/Paste.java @@ -346,6 +346,7 @@ public void save() { super.save(); boolean bigEnoughForS3 = content != null && content.length() > Pastefy.getInstance().getConfig().getInt("minio.pastesize.threshold", -1); + if (Pastefy.getInstance().isMinioEnabled() && (storageType == StorageType.S3 || bigEnoughForS3)) { Pastefy.getInstance().executeAsync(() -> MinioPaste.store(this)); } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3c3a6d1..ecbf6dd 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -16,7 +16,7 @@ console.log( export default defineConfig({ plugins: [vue(), vueDevTools(), tailwindcss()], build: { - outDir: '../backend/src/main/resources/static', + outDir: '../backend-kt/src/main/resources/static', emptyOutDir: true, minify: true, }, From 0267ea651401bad9d8e2885c414b8c3864e98a22 Mon Sep 17 00:00:00 2001 From: juliangojani Date: Thu, 4 Jun 2026 23:55:10 +0200 Subject: [PATCH 09/22] Initial spring-boot-kotlin --- backend-kt/.idea/dataSources.local.xml | 46 + backend-kt/.idea/dataSources.xml | 26 + .../b37783b9-20c2-452a-8fe1-13f1a147f7ca.xml | 26 + .../c563d18b-01ac-45cd-aec1-2e7fe7a80fb9.xml | 2443 +++++++++++++++++ .../_src_/schema/system.L3Icyw.meta | 2 + .../dataSources/data_sources_history.xml | 64 + .../f90989bf-a7eb-4fb1-90d3-4e8a669d378d.xml | 1899 +++++++++++++ .../schema/information_schema.FNRwLQ.meta | 2 + .../schema/performance_schema.kIw0nw.meta | 2 + backend-kt/.idea/db-forest-config.xml | 11 + backend-kt/.idea/misc.xml | 2 +- backend-kt/README.md | 10 + .../starquery/starquery-meta.sqlite | Bin 0 -> 94208 bytes .../starquery/starquery-meta.sqlite-shm | Bin 0 -> 32768 bytes .../starquery/starquery-meta.sqlite-wal | 0 backend-kt/docker-compose.yaml | 51 +- backend-kt/gradle/wrapper/gradle-wrapper.jar | Bin 60756 -> 46175 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 +- backend-kt/gradlew | 50 +- backend-kt/gradlew.bat | 40 +- .../pastefy/config/PastefyProperties.kt | 1 + .../pastefy/controller/AppController.kt | 2 + .../controller/pastes/PasteRawController.kt | 7 + .../controller/public/TagsController.kt | 3 + .../{pastes => seo}/PasteMetaSSRController.kt | 17 +- ...aController.kt => TagMetaSSRController.kt} | 17 +- .../controller/stats/StatsController.kt | 1 + .../analytics/AnalyticsConfiguration.kt | 11 +- .../ClickHouseFlywayConfiguration.kt | 12 +- .../elastic/ElasticPasteDocument.kt | 10 + .../seeding/LocalTestingSeeder.kt | 37 +- .../pastefy/service/FrontendIndexService.kt | 9 + .../service/query/LegacyPasteQueryParser.kt | 12 +- .../resources/application-local.properties | 2 + .../src/main/resources/application.properties | 3 + .../V1__create_analytics_visits.sql | 2 +- .../pastefy/controller/ControllerHttpTest.kt | 2 +- 37 files changed, 4734 insertions(+), 93 deletions(-) create mode 100644 backend-kt/.idea/dataSources.local.xml create mode 100644 backend-kt/.idea/dataSources.xml create mode 100644 backend-kt/.idea/dataSources/b37783b9-20c2-452a-8fe1-13f1a147f7ca.xml create mode 100644 backend-kt/.idea/dataSources/c563d18b-01ac-45cd-aec1-2e7fe7a80fb9.xml create mode 100644 backend-kt/.idea/dataSources/c563d18b-01ac-45cd-aec1-2e7fe7a80fb9/storage_v2/_src_/schema/system.L3Icyw.meta create mode 100644 backend-kt/.idea/dataSources/data_sources_history.xml create mode 100644 backend-kt/.idea/dataSources/f90989bf-a7eb-4fb1-90d3-4e8a669d378d.xml create mode 100644 backend-kt/.idea/dataSources/f90989bf-a7eb-4fb1-90d3-4e8a669d378d/storage_v2/_src_/schema/information_schema.FNRwLQ.meta create mode 100644 backend-kt/.idea/dataSources/f90989bf-a7eb-4fb1-90d3-4e8a669d378d/storage_v2/_src_/schema/performance_schema.kIw0nw.meta create mode 100644 backend-kt/.idea/db-forest-config.xml create mode 100644 backend-kt/README.md create mode 100644 backend-kt/development/starquery/starquery-meta.sqlite create mode 100644 backend-kt/development/starquery/starquery-meta.sqlite-shm create mode 100644 backend-kt/development/starquery/starquery-meta.sqlite-wal rename backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/{pastes => seo}/PasteMetaSSRController.kt (91%) rename backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/{TagMetaController.kt => TagMetaSSRController.kt} (79%) diff --git a/backend-kt/.idea/dataSources.local.xml b/backend-kt/.idea/dataSources.local.xml new file mode 100644 index 0000000..4c06b24 --- /dev/null +++ b/backend-kt/.idea/dataSources.local.xml @@ -0,0 +1,46 @@ + + + + + + $ + ` + true + + + master_key + myuser + + + + + + + + + true + + + master_key + + + + + + + + + ` + true + + + master_key + pastefy + + + + + + + + \ No newline at end of file diff --git a/backend-kt/.idea/dataSources.xml b/backend-kt/.idea/dataSources.xml new file mode 100644 index 0000000..00fcccc --- /dev/null +++ b/backend-kt/.idea/dataSources.xml @@ -0,0 +1,26 @@ + + + + + mysql.8 + true + com.mysql.cj.jdbc.Driver + jdbc:mysql://localhost:3306/mydatabase + $ProjectFileDir$ + + + redis + true + jdbc.RedisDriver + jdbc:redis://localhost:6379/0 + $ProjectFileDir$ + + + clickhouse + true + com.clickhouse.jdbc.ClickHouseDriver + jdbc:clickhouse://localhost:8123/pastefy + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/backend-kt/.idea/dataSources/b37783b9-20c2-452a-8fe1-13f1a147f7ca.xml b/backend-kt/.idea/dataSources/b37783b9-20c2-452a-8fe1-13f1a147f7ca.xml new file mode 100644 index 0000000..bfcdd6a --- /dev/null +++ b/backend-kt/.idea/dataSources/b37783b9-20c2-452a-8fe1-13f1a147f7ca.xml @@ -0,0 +1,26 @@ + + + + + 7.4.9 + + + 1 + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend-kt/.idea/dataSources/c563d18b-01ac-45cd-aec1-2e7fe7a80fb9.xml b/backend-kt/.idea/dataSources/c563d18b-01ac-45cd-aec1-2e7fe7a80fb9.xml new file mode 100644 index 0000000..0cc0e7a --- /dev/null +++ b/backend-kt/.idea/dataSources/c563d18b-01ac-45cd-aec1-2e7fe7a80fb9.xml @@ -0,0 +1,2443 @@ + + + + + 26.5.1.882 + + + + + + + 1 + + + 1 + + + 1 + + + + + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + 1 + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + + + + + + + + + + + + 1 + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + + + 1 + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + 1 + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + 1 + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + 1 + + + + 1 + + + + + + + + + + 1 + + + 1 + + + + + + + + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + + 1 + + + 1 + + + + + + + + + + 1 + + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + + + 1 + + + + + + 1 + + + + + 1 + + + 1 + + + + + 1 + + + + + + + + 1 + + + 1 + + + + 1 + + + 1 + + + 1 + + + + 1 + + + + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + 1 + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + + + + + + + + + + + 1 + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + [plaintext_password] + 1 + ::/0 + + + MergeTree + PRIMARY KEY (script) +ORDER BY (script) +SETTINGS index_granularity = 8192 + 062a09d7-b86d-4079-b8f7-b53fb112e79d +
+ + MergeTree + PARTITION BY toYYYYMM(visited_at) +ORDER BY (paste_key, visited_at) +TTL visited_at + toIntervalDay(90) +SETTINGS index_granularity = 8192 + 9058fccf-1cfe-428b-84ea-6ac0367f1fed +
+ + 1 + 1 + Int32|0s + + + 2 + String|0s + + + 1 + 3 + String|0s + + + 1 + 4 + String|0s + + + 1 + 5 + String|0s + + + 6 + Int32|0s + + + 1 + 7 + String|0s + + + now() + 1 + 8 + DateTime|0s + + + 1 + 9 + Int32|0s + + + 1 + 10 + Bool|0s + + + 1 + 1 + FixedString(8)|0s + + + 1 + 2 + LowCardinality(String)|0v + + + 3 + FixedString(8)|0s + + + 1 + 4 + LowCardinality(String)|0v + + + 1 + 5 + DateTime64(3, 'UTC')|0v + + + 1 + 6 + LowCardinality(String)|0v + + + 1 + 7 + LowCardinality(String)|0v + + + 1 + 8 + LowCardinality(String)|0v + + + 9 + FixedString(8)|0s + + + 1 + 10 + LowCardinality(String)|0v + + + 1 + 11 + LowCardinality(String)|0v + + + 1 + 12 + LowCardinality(String)|0v + + + 13 + UInt64|0s + + + 1 + 14 + String|0s + + + 1 + 15 + LowCardinality(String)|0v + + + 1 + 16 + UInt8|0s + +
+
\ No newline at end of file diff --git a/backend-kt/.idea/dataSources/c563d18b-01ac-45cd-aec1-2e7fe7a80fb9/storage_v2/_src_/schema/system.L3Icyw.meta b/backend-kt/.idea/dataSources/c563d18b-01ac-45cd-aec1-2e7fe7a80fb9/storage_v2/_src_/schema/system.L3Icyw.meta new file mode 100644 index 0000000..dd07835 --- /dev/null +++ b/backend-kt/.idea/dataSources/c563d18b-01ac-45cd-aec1-2e7fe7a80fb9/storage_v2/_src_/schema/system.L3Icyw.meta @@ -0,0 +1,2 @@ +#n:system +! [null, 0, null, null, -2147483648, -2147483648] diff --git a/backend-kt/.idea/dataSources/data_sources_history.xml b/backend-kt/.idea/dataSources/data_sources_history.xml new file mode 100644 index 0000000..5e424ad --- /dev/null +++ b/backend-kt/.idea/dataSources/data_sources_history.xml @@ -0,0 +1,64 @@ + + + + + $ + ` + true + + + mysql.8 + true + com.mysql.cj.jdbc.Driver + jdbc:mysql://localhost:3306/mydatabase + master_key + myuser + + + + + + $ProjectFileDir$ + + + + + + true + + + redis + true + jdbc.RedisDriver + jdbc:redis://localhost:6379/0 + master_key + + + + + + $ProjectFileDir$ + + + + + + ` + true + + + clickhouse + true + com.clickhouse.jdbc.ClickHouseDriver + jdbc:clickhouse://localhost:8123/pastefy + master_key + pastefy + + + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/backend-kt/.idea/dataSources/f90989bf-a7eb-4fb1-90d3-4e8a669d378d.xml b/backend-kt/.idea/dataSources/f90989bf-a7eb-4fb1-90d3-4e8a669d378d.xml new file mode 100644 index 0000000..96c0a55 --- /dev/null +++ b/backend-kt/.idea/dataSources/f90989bf-a7eb-4fb1-90d3-4e8a669d378d.xml @@ -0,0 +1,1899 @@ + + + + + caching_sha2_password + exact + InnoDB + InnoDB + mydatabase|schema||myuser||ALTER|G +mydatabase|schema||myuser||ALTER ROUTINE|G +mydatabase|schema||myuser||CREATE|G +mydatabase|schema||myuser||CREATE ROUTINE|G +mydatabase|schema||myuser||CREATE TEMPORARY TABLES|G +mydatabase|schema||myuser||CREATE VIEW|G +mydatabase|schema||myuser||DELETE|G +mydatabase|schema||myuser||DROP|G +mydatabase|schema||myuser||EVENT|G +mydatabase|schema||myuser||EXECUTE|G +mydatabase|schema||myuser||INDEX|G +mydatabase|schema||myuser||INSERT|G +mydatabase|schema||myuser||LOCK TABLES|G +mydatabase|schema||myuser||REFERENCES|G +mydatabase|schema||myuser||SELECT|G +mydatabase|schema||myuser||SHOW VIEW|G +mydatabase|schema||myuser||TRIGGER|G +mydatabase|schema||myuser||UPDATE|G + 8.4.8 + + + armscii8 + + + armscii8 + 1 + + + ascii + + + ascii + 1 + + + big5 + + + big5 + 1 + + + binary + 1 + + + cp1250 + + + cp1250 + + + cp1250 + + + cp1250 + 1 + + + cp1250 + + + cp1251 + + + cp1251 + + + cp1251 + 1 + + + cp1251 + + + cp1251 + + + cp1256 + + + cp1256 + 1 + + + cp1257 + + + cp1257 + 1 + + + cp1257 + + + cp850 + + + cp850 + 1 + + + cp852 + + + cp852 + 1 + + + cp866 + + + cp866 + 1 + + + cp932 + + + cp932 + 1 + + + dec8 + + + dec8 + 1 + + + eucjpms + + + eucjpms + 1 + + + euckr + + + euckr + 1 + + + gb18030 + + + gb18030 + 1 + + + gb18030 + + + gb2312 + + + gb2312 + 1 + + + gbk + + + gbk + 1 + + + geostd8 + + + geostd8 + 1 + + + greek + + + greek + 1 + + + hebrew + + + hebrew + 1 + + + hp8 + + + hp8 + 1 + + + keybcs2 + + + keybcs2 + 1 + + + koi8r + + + koi8r + 1 + + + koi8u + + + koi8u + 1 + + + latin1 + + + latin1 + + + latin1 + + + latin1 + + + latin1 + + + latin1 + + + latin1 + + + latin1 + 1 + + + latin2 + + + latin2 + + + latin2 + + + latin2 + 1 + + + latin2 + + + latin5 + + + latin5 + 1 + + + latin7 + + + latin7 + + + latin7 + 1 + + + latin7 + + + macce + + + macce + 1 + + + macroman + + + macroman + 1 + + + sjis + + + sjis + 1 + + + swe7 + + + swe7 + 1 + + + tis620 + + + tis620 + 1 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + 1 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ujis + + + ujis + 1 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + 1 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16le + + + utf16le + 1 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + 1 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + 1 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb4 + 1 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb3_general_ci + + + 3 + 1 + 3 + 2026-06-04.21:03:37 + utf8mb4_0900_ai_ci + + + utf8mb4_0900_ai_ci + + + + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 1 + 1 + varchar(255)|0s + + + 1 + 2 + varchar(255)|0s + + + 1 + 3 + varchar(255)|0s + + + 1 + 4 + datetime|0s + + + 1 + 5 + int|0s + + + 1 + 6 + varchar(10)|0s + + + 7 + varchar(35)|0s + + + 8 + varchar(255)|0s + + + 9 + varchar(255)|0s + + + 10 + varchar(255)|0s + + + 11 + varchar(20)|0s + + + 12 + varchar(255)|0s + + + 13 + varchar(255)|0s + + + 14 + varchar(10)|0s + + + 1 + 1 + int|0s + + + 1 + 2 + tinyint|0s + + + 3 + datetime|0s + + + 4 + varchar(255)|0s + + + ID + btree + 1 + + + 1 + 1 + PRIMARY + + + 5 + 1 + 1 + int|0s + + + 2 + varchar(60)|0s + + + 3 + varchar(160)|0s + + + 4 + varchar(8)|0s + + + 'API' + 5 + enum('API', 'USER', 'ACCESS_TOKEN')|0e + + + CURRENT_TIMESTAMP + 1 + CURRENT_TIMESTAMP + 6 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + 7 + timestamp|0s + + + 8 + json|0s + + + 9 + varchar(255)|0s + + + 10 + varchar(255)|0s + + + id + btree + 1 + + + key + btree + 1 + + + user_id + btree + + + 1 + 1 + PRIMARY + + + key + + + 1 + 1 + varchar(255)|0s + + + 2 + enum('PASTE_AI_INFO')|0e + + + 3 + int|0s + + + 4 + int|0s + + + 5 + int|0s + + + 'PENDING' + 6 + enum('PENDING', 'RUNNING', 'DONE', 'FAILED')|0e + + + 7 + int|0s + + + 8 + timestamp|0s + + + 9 + timestamp|0s + + + 10 + varchar(64)|0s + + + 11 + varchar(2048)|0s + + + CURRENT_TIMESTAMP + 1 + 12 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + CURRENT_TIMESTAMP + 13 + timestamp|0s + + + key + btree + 1 + + + type +entity_id + btree + + + status +available_at + btree + + + status +lease_until + btree + + + 1 + 1 + PRIMARY + + + 11 + 1 + 1 + int|0s + + + 2 + varchar(8)|0s + + + 3 + text|0s + + + 4 + varchar(8)|0s + + + 5 + text|0s + + + CURRENT_TIMESTAMP + 1 + CURRENT_TIMESTAMP + 6 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + 7 + timestamp|0s + + + id + btree + 1 + + + key + btree + + + user_id + btree + + + 1 + 1 + PRIMARY + + + 4 + 1 + 1 + int|0s + + + 2 + text|0s + + + 3 + varchar(8)|0s + + + 4 + text|0s + + + 5 + bit(1)|0s + + + 6 + bit(1)|0s + + + CURRENT_TIMESTAMP + 1 + CURRENT_TIMESTAMP + 7 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + 8 + timestamp|0s + + + id + btree + 1 + + + user_id +received + btree + + + 1 + 1 + PRIMARY + + + 1 + 1 + int|0s + + + 2 + int|0s + + + 3 + int|0s + + + 4 + varchar(30)|0s + + + 5 + varchar(100)|0s + + + 6 + varchar(500)|0s + + + 7 + varchar(2048)|0s + + + 8 + varchar(4096)|0s + + + 9 + bit(1)|0s + + + 10 + int|0s + + + 11 + varchar(255)|0s + + + 12 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + 13 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + CURRENT_TIMESTAMP + 14 + timestamp|0s + + + paste_id + btree + 1 + + + 1 + 1 + PRIMARY + + + 7 + 1 + 1 + int|0s + + + 1 + 2 + varchar(8)|0s + + + 1 + 3 + varchar(8)|0s + + + 4 + int|0s + + + 1 + 5 + varchar(2000)|0s + + + 6 + int|0s + + + 7 + int|0s + + + CURRENT_TIMESTAMP + 1 + 8 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + CURRENT_TIMESTAMP + 9 + timestamp|0s + + + id + btree + 1 + + + paste +line_from + btree + + + paste + btree + + + user_id + btree + + + parent_id + btree + + + 1 + 1 + PRIMARY + + + 6 + 1 + 1 + int|0s + + + 2 + varchar(8)|0s + + + 3 + varchar(8)|0s + + + id + btree + 1 + + + paste +user_id + btree + + + paste + btree + + + 1 + 1 + PRIMARY + + + 590 + 1 + 1 + int|0s + + + 2 + varchar(8)|0s + + + 3 + varchar(30)|0s + + + id + btree + 1 + + + paste +tag + btree + + + 1 + 1 + PRIMARY + + + 205 + 1 + 1 + int|0s + + + 2 + varchar(8)|0s + + + 3 + text|0s + + + 4 + longtext|0s + + + 5 + varchar(8)|0s + + + 6 + bit(1)|0s + + + 7 + varchar(8)|0s + + + CURRENT_TIMESTAMP + 1 + 8 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + 9 + timestamp|0s + + + 'PASTE' + 10 + enum('PASTE', 'MULTI_PASTE')|0e + + + 11 + varchar(8)|0s + + + 'UNLISTED' + 12 + enum('UNLISTED', 'PUBLIC', 'PRIVATE')|0e + + + 13 + timestamp|0s + + + 'DATABASE' + 14 + enum('DATABASE', 'S3', 'HTTP')|0e + + + 15 + int|0s + + + 16 + bit(1)|0s + + + 17 + int|0s + + + 18 + varchar(64)|0s + + + id + btree + 1 + + + key + btree + + + user_id +folder + btree + + + user_id + btree + + + folder + btree + + + visibility + btree + + + expire_at + btree + + + storage_type + btree + + + indexed_in_elastic + btree + + + length + btree + + + hash + btree + + + 1 + 1 + PRIMARY + + + 2 + 1 + 1 + int|0s + + + 2 + int|0s + + + 3 + int|0s + + + CURRENT_TIMESTAMP + 1 + CURRENT_TIMESTAMP + 4 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + 5 + timestamp|0s + + + id + btree + 1 + + + paste_id +score + btree + + + paste_id + btree + + + 1 + 1 + PRIMARY + + + 2 + 1 + 1 + int|0s + + + 2 + varchar(8)|0s + + + 3 + varchar(8)|0s + + + 4 + text|0s + + + CURRENT_TIMESTAMP + 1 + CURRENT_TIMESTAMP + 5 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + 6 + timestamp|0s + + + id + btree + 1 + + + user_id + btree + + + target_id + btree + + + 1 + 1 + PRIMARY + + + 1 + 1 + varchar(30)|0s + + + 2 + text|0s + + + 3 + text|0s + + + 4 + text|0s + + + 5 + text|0s + + + 6 + text|0s + + + 7 + int|0s + + + tag + btree + 1 + + + 1 + 1 + PRIMARY + + + 1 + 1 + varchar(8)|0s + + + 2 + text|0s + + + 3 + varchar(33)|0s + + + 4 + text|0s + + + 5 + text|0s + + + 6 + varchar(455)|0s + + + 7 + enum('INTERAAPPS', 'GOOGLE', 'GITHUB', 'TWITCH', 'DISCORD')|0e + + + CURRENT_TIMESTAMP + 1 + CURRENT_TIMESTAMP + 8 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + 9 + timestamp|0s + + + 'USER' + 10 + enum('USER', 'ADMIN', 'BLOCKED', 'AWAITING_ACCESS')|0e + + + id + btree + 1 + + + 1 + 1 + PRIMARY + +
+
\ No newline at end of file diff --git a/backend-kt/.idea/dataSources/f90989bf-a7eb-4fb1-90d3-4e8a669d378d/storage_v2/_src_/schema/information_schema.FNRwLQ.meta b/backend-kt/.idea/dataSources/f90989bf-a7eb-4fb1-90d3-4e8a669d378d/storage_v2/_src_/schema/information_schema.FNRwLQ.meta new file mode 100644 index 0000000..1ff3db2 --- /dev/null +++ b/backend-kt/.idea/dataSources/f90989bf-a7eb-4fb1-90d3-4e8a669d378d/storage_v2/_src_/schema/information_schema.FNRwLQ.meta @@ -0,0 +1,2 @@ +#n:information_schema +! [null, 0, null, null, -2147483648, -2147483648] diff --git a/backend-kt/.idea/dataSources/f90989bf-a7eb-4fb1-90d3-4e8a669d378d/storage_v2/_src_/schema/performance_schema.kIw0nw.meta b/backend-kt/.idea/dataSources/f90989bf-a7eb-4fb1-90d3-4e8a669d378d/storage_v2/_src_/schema/performance_schema.kIw0nw.meta new file mode 100644 index 0000000..9394db1 --- /dev/null +++ b/backend-kt/.idea/dataSources/f90989bf-a7eb-4fb1-90d3-4e8a669d378d/storage_v2/_src_/schema/performance_schema.kIw0nw.meta @@ -0,0 +1,2 @@ +#n:performance_schema +! [null, 0, null, null, -2147483648, -2147483648] diff --git a/backend-kt/.idea/db-forest-config.xml b/backend-kt/.idea/db-forest-config.xml new file mode 100644 index 0000000..c5fda93 --- /dev/null +++ b/backend-kt/.idea/db-forest-config.xml @@ -0,0 +1,11 @@ + + + + . + ---------------------------------------- + 1:0:f90989bf-a7eb-4fb1-90d3-4e8a669d378d + 2:0:b37783b9-20c2-452a-8fe1-13f1a147f7ca + 3:0:c563d18b-01ac-45cd-aec1-2e7fe7a80fb9 + . + + \ No newline at end of file diff --git a/backend-kt/.idea/misc.xml b/backend-kt/.idea/misc.xml index e4d905b..fd385d4 100644 --- a/backend-kt/.idea/misc.xml +++ b/backend-kt/.idea/misc.xml @@ -1,7 +1,7 @@ - + \ No newline at end of file diff --git a/backend-kt/README.md b/backend-kt/README.md new file mode 100644 index 0000000..0224878 --- /dev/null +++ b/backend-kt/README.md @@ -0,0 +1,10 @@ +# Backend + + +## Local Starquery +Your can either visit http://localhost:8081/ once the docker-compose is running or add the url as a server in the [StarQuery client](https://starquery.app/). + +Field|Value +---|--- +username|admin +password|ADMIN_pastefy \ No newline at end of file diff --git a/backend-kt/development/starquery/starquery-meta.sqlite b/backend-kt/development/starquery/starquery-meta.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..1f45c60bd87abbd9b272763e0adc1808aa72b2ca GIT binary patch literal 94208 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 literal 0 HcmV?d00001 diff --git a/backend-kt/development/starquery/starquery-meta.sqlite-shm b/backend-kt/development/starquery/starquery-meta.sqlite-shm new file mode 100644 index 0000000000000000000000000000000000000000..fe9ac2845eca6fe6da8a63cd096d9cf9e24ece10 GIT binary patch literal 32768 zcmeIuAr62r32v%2 zy2jY`r|L&NwdbC5&AHZASAvGYhCo0-fPjFYcwhhD3mpOxLPbVff<-}9mQ7hfN=8*n zMn@YK0`jk~Y#ADPZt&s;&o%Vh+1OqX$SQPQUbO~kT2|`trE{h9WQ$5t)0<0SGK(9o zy!{fv+oYdReexE`UMYzV3-kOr>x=rJ7+6+0b5EnF$IG$Dt(hUAKx2>*-_*>j|Id49Q3}YN>5=$q?@D;}*%{N1&Ngq- zT;Qj#_R=+0ba4EqMNa487mOM?^?N!cyt;9!ID^&OIS$OX?qC^kSGrHw@&-mB@~L!$ zQMIB|qD849?j6c_o6Y9s2-@J%jl@tu1+mdGN~J$RK!v{juhQkNSMup%E!|Iwjp}G} z6l3PDwQp#b$A`v-92bY=W{dghjg1@gO53Q}P!4oN?n)(dY4}3I1erK<3&=O2;)*)+_&gzJwCFLYl&;nZCm zs21P5net@>H0V>H2FQ%TUoZBiSRH2w*u~K%d6Y|Fc_eO}lhQ1A!Z|)oX3+mS``s4O zQE>^#ibNrUi4P;{KRbbTOVweOhejS2x&Oab?s zB}^!pSukn*hb<|^*8b+28w~Kqr z5YDH20(#-gOLJR&1Q4qEEb{G)%nsAqPsEfj9FgZ% z5k%IHRQk6Xh}==R`LYmK?%(0w9zI}hkkj|3qvo$_FzU9$%Zf>(S>m|JTn!rYUwC)S z^+V+Gh@*U(Za&jUW#Wh#;1*R2he9SI68(&DeI%UQ&0gyQ73g7)Xts{uPx^&U`MALc)G9+Y<9KIjR1lICfNnw_Ju8 z-O7hoBM!+}IMUYZr29cN{aHL&dmr!ayq7;r?`7M3z+L@~Fx4o}lk{l?0w3=rqRxpv z0Tp-ETUvB<*2vTh_dr%}Lfx)%pxlb$ch}yCCUz6k4)hyMJ_Lq$SS(Rd8aWG-K{8TD zDUtTM2SQ|y5F;}M&9eL-xGpj#vTy0*Egq$K1aZnGq3I^$31WARgcJUb0T*QaRo~*Q*;H_Jc_7LeyDXHPh?}Ick1s{(QZWni3%OL|i zJ7foQ%gLbU+dOZP7Z^96OoW5YbS=0%+#j3#o3bYsnB}Ztbu_KuFcBz9M~>z z{s?I|KWR0CJT6eqNlIj57Jq@-><8 zV&>W=5}GL`X|of9PiXwZaoKWOehcgaB1!y0@zY^+$YFgk3UB@$4#qATzJk?b^M#iL zKe}&w?|SGj<-3Z>pDd^+G3w_>76zq%EZGhqzOYx6YQgnb;vA^%6(Sx4?gytM=^m`C z@c+mG0LSQOqF$oK!j8-B4hG`=`%8Hp#$+IvanscDc42T#q4=v2YuoSZd{VS%kBNtx zLd6U%s>y+0*0?dDt&wJ`=F&iRWyJS1Y>kZds97Z^J?Kmeu!Fh-L+F9?o#ZILhhvI& zyE^o10y()W>x@1skNd<(ehL$G%S9yZ>AxGNktZ_$h9RD?hd_YxvNIeb?3~*XE*54b z;}9`U&d_XFzBbijUqrX}i?s24Ox?EOfTz$aTz;dtw~F)!(XK9voHS_ii|YmI?eRrX z%Gr=T-7Qx7eB&|iMk+jCw4x6X6Hae`0esw}b;uVy6ljeACOq{ZM6e`2k%XdE* zcZotR`H{lmO?;6sfMz|Xv|aJ!F2{Ucp1Y5HM68;}hw4h%ntF`pl0QNFk@W?2S67+W zF1AU5YS7<_7H6+NrwMJ)&D8^-Sgj_rttU*gt3dvWH^sG8W6BbhtT{Lm3VV5cSo;$3 zNuSXq<>-4y>$9__aC`0aka&~k=}#N;Co3O<6()7bWgAZuB~%E!lv`DCbEMM)G$IQ< z*b89{3RV{((?H&X1kBl8+K_XHL`Hc=25|M6Djk8YZUc&s3Ki&|KcOb&!$LVf5~6*K z>pgW7g-7ASM5ZZ5?Ah_e13r7Z98K>?leVWPNQs_MXx_&Ftg92|SR`xrt$4|%fVGS- zTNZt(a#pl7RaYzzJlX1vk0kt*Vpxw_{M%KG%Q}`scIVU

pVX@HRij*jw$g4?}Pn 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 literal 60756 zcmb5WV{~QRw(p$^Dz@00IL3?^hro$gg*4VI_WAaTyVM5Foj~O|-84 z$;06hMwt*rV;^8iB z1~&0XWpYJmG?Ts^K9PC62H*`G}xom%S%yq|xvG~FIfP=9*f zZoDRJBm*Y0aId=qJ?7dyb)6)JGWGwe)MHeNSzhi)Ko6J<-m@v=a%NsP537lHe0R* z`If4$aaBA#S=w!2z&m>{lpTy^Lm^mg*3?M&7HFv}7K6x*cukLIGX;bQG|QWdn{%_6 zHnwBKr84#B7Z+AnBXa16a?or^R?+>$4`}{*a_>IhbjvyTtWkHw)|ay)ahWUd-qq$~ zMbh6roVsj;_qnC-R{G+Cy6bApVOinSU-;(DxUEl!i2)1EeQ9`hrfqj(nKI7?Z>Xur zoJz-a`PxkYit1HEbv|jy%~DO^13J-ut986EEG=66S}D3!L}Efp;Bez~7tNq{QsUMm zh9~(HYg1pA*=37C0}n4g&bFbQ+?-h-W}onYeE{q;cIy%eZK9wZjSwGvT+&Cgv z?~{9p(;bY_1+k|wkt_|N!@J~aoY@|U_RGoWX<;p{Nu*D*&_phw`8jYkMNpRTWx1H* z>J-Mi_!`M468#5Aix$$u1M@rJEIOc?k^QBc?T(#=n&*5eS#u*Y)?L8Ha$9wRWdH^3D4|Ps)Y?m0q~SiKiSfEkJ!=^`lJ(%W3o|CZ zSrZL-Xxc{OrmsQD&s~zPfNJOpSZUl%V8tdG%ei}lQkM+z@-4etFPR>GOH9+Y_F<3=~SXln9Kb-o~f>2a6Xz@AS3cn^;c_>lUwlK(n>z?A>NbC z`Ud8^aQy>wy=$)w;JZzA)_*Y$Z5hU=KAG&htLw1Uh00yE!|Nu{EZkch zY9O6x7Y??>!7pUNME*d!=R#s)ghr|R#41l!c?~=3CS8&zr6*aA7n9*)*PWBV2w+&I zpW1-9fr3j{VTcls1>ua}F*bbju_Xq%^v;-W~paSqlf zolj*dt`BBjHI)H9{zrkBo=B%>8}4jeBO~kWqO!~Thi!I1H(in=n^fS%nuL=X2+s!p}HfTU#NBGiwEBF^^tKU zbhhv+0dE-sbK$>J#t-J!B$TMgN@Wh5wTtK2BG}4BGfsZOoRUS#G8Cxv|6EI*n&Xxq zt{&OxCC+BNqz$9b0WM7_PyBJEVObHFh%%`~!@MNZlo*oXDCwDcFwT~Rls!aApL<)^ zbBftGKKBRhB!{?fX@l2_y~%ygNFfF(XJzHh#?`WlSL{1lKT*gJM zs>bd^H9NCxqxn(IOky5k-wALFowQr(gw%|`0991u#9jXQh?4l|l>pd6a&rx|v=fPJ z1mutj{YzpJ_gsClbWFk(G}bSlFi-6@mwoQh-XeD*j@~huW4(8ub%^I|azA)h2t#yG z7e_V_<4jlM3D(I+qX}yEtqj)cpzN*oCdYHa!nm%0t^wHm)EmFP*|FMw!tb@&`G-u~ zK)=Sf6z+BiTAI}}i{*_Ac$ffr*Wrv$F7_0gJkjx;@)XjYSh`RjAgrCck`x!zP>Ifu z&%he4P|S)H*(9oB4uvH67^0}I-_ye_!w)u3v2+EY>eD3#8QR24<;7?*hj8k~rS)~7 zSXs5ww)T(0eHSp$hEIBnW|Iun<_i`}VE0Nc$|-R}wlSIs5pV{g_Dar(Zz<4X3`W?K z6&CAIl4U(Qk-tTcK{|zYF6QG5ArrEB!;5s?tW7 zrE3hcFY&k)+)e{+YOJ0X2uDE_hd2{|m_dC}kgEKqiE9Q^A-+>2UonB+L@v3$9?AYw zVQv?X*pK;X4Ovc6Ev5Gbg{{Eu*7{N3#0@9oMI~}KnObQE#Y{&3mM4`w%wN+xrKYgD zB-ay0Q}m{QI;iY`s1Z^NqIkjrTlf`B)B#MajZ#9u41oRBC1oM1vq0i|F59> z#StM@bHt|#`2)cpl_rWB($DNJ3Lap}QM-+A$3pe}NyP(@+i1>o^fe-oxX#Bt`mcQc zb?pD4W%#ep|3%CHAYnr*^M6Czg>~L4?l16H1OozM{P*en298b+`i4$|w$|4AHbzqB zHpYUsHZET$Z0ztC;U+0*+amF!@PI%^oUIZy{`L{%O^i{Xk}X0&nl)n~tVEpcAJSJ} zverw15zP1P-O8h9nd!&hj$zuwjg?DoxYIw{jWM zW5_pj+wFy8Tsa9g<7Qa21WaV&;ejoYflRKcz?#fSH_)@*QVlN2l4(QNk| z4aPnv&mrS&0|6NHq05XQw$J^RR9T{3SOcMKCXIR1iSf+xJ0E_Wv?jEc*I#ZPzyJN2 zUG0UOXHl+PikM*&g$U@g+KbG-RY>uaIl&DEtw_Q=FYq?etc!;hEC_}UX{eyh%dw2V zTTSlap&5>PY{6I#(6`j-9`D&I#|YPP8a;(sOzgeKDWsLa!i-$frD>zr-oid!Hf&yS z!i^cr&7tN}OOGmX2)`8k?Tn!!4=tz~3hCTq_9CdiV!NIblUDxHh(FJ$zs)B2(t5@u z-`^RA1ShrLCkg0)OhfoM;4Z{&oZmAec$qV@ zGQ(7(!CBk<5;Ar%DLJ0p0!ResC#U<+3i<|vib1?{5gCebG7$F7URKZXuX-2WgF>YJ^i zMhHDBsh9PDU8dlZ$yJKtc6JA#y!y$57%sE>4Nt+wF1lfNIWyA`=hF=9Gj%sRwi@vd z%2eVV3y&dvAgyuJ=eNJR+*080dbO_t@BFJO<@&#yqTK&+xc|FRR;p;KVk@J3$S{p` zGaMj6isho#%m)?pOG^G0mzOAw0z?!AEMsv=0T>WWcE>??WS=fII$t$(^PDPMU(P>o z_*0s^W#|x)%tx8jIgZY~A2yG;US0m2ZOQt6yJqW@XNY_>_R7(Nxb8Ged6BdYW6{prd!|zuX$@Q2o6Ona8zzYC1u!+2!Y$Jc9a;wy+pXt}o6~Bu1oF1c zp7Y|SBTNi@=I(K%A60PMjM#sfH$y*c{xUgeSpi#HB`?|`!Tb&-qJ3;vxS!TIzuTZs-&%#bAkAyw9m4PJgvey zM5?up*b}eDEY+#@tKec)-c(#QF0P?MRlD1+7%Yk*jW;)`f;0a-ZJ6CQA?E%>i2Dt7T9?s|9ZF|KP4;CNWvaVKZ+Qeut;Jith_y{v*Ny6Co6!8MZx;Wgo z=qAi%&S;8J{iyD&>3CLCQdTX*$+Rx1AwA*D_J^0>suTgBMBb=*hefV+Ars#mmr+YsI3#!F@Xc1t4F-gB@6aoyT+5O(qMz*zG<9Qq*f0w^V!03rpr*-WLH}; zfM{xSPJeu6D(%8HU%0GEa%waFHE$G?FH^kMS-&I3)ycx|iv{T6Wx}9$$D&6{%1N_8 z_CLw)_9+O4&u94##vI9b-HHm_95m)fa??q07`DniVjAy`t7;)4NpeyAY(aAk(+T_O z1om+b5K2g_B&b2DCTK<>SE$Ode1DopAi)xaJjU>**AJK3hZrnhEQ9E`2=|HHe<^tv z63e(bn#fMWuz>4erc47}!J>U58%<&N<6AOAewyzNTqi7hJc|X{782&cM zHZYclNbBwU6673=!ClmxMfkC$(CykGR@10F!zN1Se83LR&a~$Ht&>~43OX22mt7tcZUpa;9@q}KDX3O&Ugp6< zLZLfIMO5;pTee1vNyVC$FGxzK2f>0Z-6hM82zKg44nWo|n}$Zk6&;5ry3`(JFEX$q zK&KivAe${e^5ZGc3a9hOt|!UOE&OocpVryE$Y4sPcs4rJ>>Kbi2_subQ9($2VN(3o zb~tEzMsHaBmBtaHAyES+d3A(qURgiskSSwUc9CfJ@99&MKp2sooSYZu+-0t0+L*!I zYagjOlPgx|lep9tiU%ts&McF6b0VE57%E0Ho%2oi?=Ks+5%aj#au^OBwNwhec zta6QAeQI^V!dF1C)>RHAmB`HnxyqWx?td@4sd15zPd*Fc9hpDXP23kbBenBxGeD$k z;%0VBQEJ-C)&dTAw_yW@k0u?IUk*NrkJ)(XEeI z9Y>6Vel>#s_v@=@0<{4A{pl=9cQ&Iah0iD0H`q)7NeCIRz8zx;! z^OO;1+IqoQNak&pV`qKW+K0^Hqp!~gSohcyS)?^P`JNZXw@gc6{A3OLZ?@1Uc^I2v z+X!^R*HCm3{7JPq{8*Tn>5;B|X7n4QQ0Bs79uTU%nbqOJh`nX(BVj!#f;#J+WZxx4 z_yM&1Y`2XzhfqkIMO7tB3raJKQS+H5F%o83bM+hxbQ zeeJm=Dvix$2j|b4?mDacb67v-1^lTp${z=jc1=j~QD>7c*@+1?py>%Kj%Ejp7Y-!? z8iYRUlGVrQPandAaxFfks53@2EC#0)%mrnmGRn&>=$H$S8q|kE_iWko4`^vCS2aWg z#!`RHUGyOt*k?bBYu3*j3u0gB#v(3tsije zgIuNNWNtrOkx@Pzs;A9un+2LX!zw+p3_NX^Sh09HZAf>m8l@O*rXy_82aWT$Q>iyy zqO7Of)D=wcSn!0+467&!Hl))eff=$aneB?R!YykdKW@k^_uR!+Q1tR)+IJb`-6=jj zymzA>Sv4>Z&g&WWu#|~GcP7qP&m*w-S$)7Xr;(duqCTe7p8H3k5>Y-n8438+%^9~K z3r^LIT_K{i7DgEJjIocw_6d0!<;wKT`X;&vv+&msmhAAnIe!OTdybPctzcEzBy88_ zWO{6i4YT%e4^WQZB)KHCvA(0tS zHu_Bg+6Ko%a9~$EjRB90`P(2~6uI@SFibxct{H#o&y40MdiXblu@VFXbhz>Nko;7R z70Ntmm-FePqhb%9gL+7U8@(ch|JfH5Fm)5${8|`Lef>LttM_iww6LW2X61ldBmG0z zax3y)njFe>j*T{i0s8D4=L>X^j0)({R5lMGVS#7(2C9@AxL&C-lZQx~czI7Iv+{%1 z2hEG>RzX4S8x3v#9sgGAnPzptM)g&LB}@%E>fy0vGSa(&q0ch|=ncKjNrK z`jA~jObJhrJ^ri|-)J^HUyeZXz~XkBp$VhcTEcTdc#a2EUOGVX?@mYx#Vy*!qO$Jv zQ4rgOJ~M*o-_Wptam=~krnmG*p^j!JAqoQ%+YsDFW7Cc9M%YPiBOrVcD^RY>m9Pd< zu}#9M?K{+;UIO!D9qOpq9yxUquQRmQNMo0pT`@$pVt=rMvyX)ph(-CCJLvUJy71DI zBk7oc7)-%ngdj~s@76Yse3L^gV0 z2==qfp&Q~L(+%RHP0n}+xH#k(hPRx(!AdBM$JCfJ5*C=K3ts>P?@@SZ_+{U2qFZb>4kZ{Go37{# zSQc+-dq*a-Vy4?taS&{Ht|MLRiS)Sn14JOONyXqPNnpq&2y~)6wEG0oNy>qvod$FF z`9o&?&6uZjhZ4_*5qWVrEfu(>_n2Xi2{@Gz9MZ8!YmjYvIMasE9yVQL10NBrTCczq zcTY1q^PF2l!Eraguf{+PtHV3=2A?Cu&NN&a8V(y;q(^_mFc6)%Yfn&X&~Pq zU1?qCj^LF(EQB1F`8NxNjyV%fde}dEa(Hx=r7$~ts2dzDwyi6ByBAIx$NllB4%K=O z$AHz1<2bTUb>(MCVPpK(E9wlLElo(aSd(Os)^Raum`d(g9Vd_+Bf&V;l=@mM=cC>) z)9b0enb)u_7V!!E_bl>u5nf&Rl|2r=2F3rHMdb7y9E}}F82^$Rf+P8%dKnOeKh1vs zhH^P*4Ydr^$)$h@4KVzxrHyy#cKmWEa9P5DJ|- zG;!Qi35Tp7XNj60=$!S6U#!(${6hyh7d4q=pF{`0t|N^|L^d8pD{O9@tF~W;#Je*P z&ah%W!KOIN;SyAEhAeTafJ4uEL`(RtnovM+cb(O#>xQnk?dzAjG^~4$dFn^<@-Na3 z395;wBnS{t*H;Jef2eE!2}u5Ns{AHj>WYZDgQJt8v%x?9{MXqJsGP|l%OiZqQ1aB! z%E=*Ig`(!tHh>}4_z5IMpg{49UvD*Pp9!pxt_gdAW%sIf3k6CTycOT1McPl=_#0?8 zVjz8Hj*Vy9c5-krd-{BQ{6Xy|P$6LJvMuX$* zA+@I_66_ET5l2&gk9n4$1M3LN8(yEViRx&mtd#LD}AqEs?RW=xKC(OCWH;~>(X6h!uDxXIPH06xh z*`F4cVlbDP`A)-fzf>MuScYsmq&1LUMGaQ3bRm6i7OsJ|%uhTDT zlvZA1M}nz*SalJWNT|`dBm1$xlaA>CCiQ zK`xD-RuEn>-`Z?M{1%@wewf#8?F|(@1e0+T4>nmlSRrNK5f)BJ2H*$q(H>zGD0>eL zQ!tl_Wk)k*e6v^m*{~A;@6+JGeWU-q9>?+L_#UNT%G?4&BnOgvm9@o7l?ov~XL+et zbGT)|G7)KAeqb=wHSPk+J1bdg7N3$vp(ekjI1D9V$G5Cj!=R2w=3*4!z*J-r-cyeb zd(i2KmX!|Lhey!snRw z?#$Gu%S^SQEKt&kep)up#j&9}e+3=JJBS(s>MH+|=R(`8xK{mmndWo_r`-w1#SeRD&YtAJ#GiVI*TkQZ}&aq<+bU2+coU3!jCI6E+Ad_xFW*ghnZ$q zAoF*i&3n1j#?B8x;kjSJD${1jdRB;)R*)Ao!9bd|C7{;iqDo|T&>KSh6*hCD!rwv= zyK#F@2+cv3=|S1Kef(E6Niv8kyLVLX&e=U;{0x{$tDfShqkjUME>f8d(5nzSkY6@! z^-0>DM)wa&%m#UF1F?zR`8Y3X#tA!*7Q$P3lZJ%*KNlrk_uaPkxw~ zxZ1qlE;Zo;nb@!SMazSjM>;34ROOoygo%SF);LL>rRonWwR>bmSd1XD^~sGSu$Gg# zFZ`|yKU0%!v07dz^v(tY%;So(e`o{ZYTX`hm;@b0%8|H>VW`*cr8R%3n|ehw2`(9B+V72`>SY}9^8oh$En80mZK9T4abVG*to;E z1_S6bgDOW?!Oy1LwYy=w3q~KKdbNtyH#d24PFjX)KYMY93{3-mPP-H>@M-_>N~DDu zENh~reh?JBAK=TFN-SfDfT^=+{w4ea2KNWXq2Y<;?(gf(FgVp8Zp-oEjKzB%2Iqj;48GmY3h=bcdYJ}~&4tS`Q1sb=^emaW$IC$|R+r-8V- zf0$gGE(CS_n4s>oicVk)MfvVg#I>iDvf~Ov8bk}sSxluG!6#^Z_zhB&U^`eIi1@j( z^CK$z^stBHtaDDHxn+R;3u+>Lil^}fj?7eaGB z&5nl^STqcaBxI@v>%zG|j))G(rVa4aY=B@^2{TFkW~YP!8!9TG#(-nOf^^X-%m9{Z zCC?iC`G-^RcBSCuk=Z`(FaUUe?hf3{0C>>$?Vs z`2Uud9M+T&KB6o4o9kvdi^Q=Bw!asPdxbe#W-Oaa#_NP(qpyF@bVxv5D5))srkU#m zj_KA+#7sqDn*Ipf!F5Byco4HOSd!Ui$l94|IbW%Ny(s1>f4|Mv^#NfB31N~kya9!k zWCGL-$0ZQztBate^fd>R!hXY_N9ZjYp3V~4_V z#eB)Kjr8yW=+oG)BuNdZG?jaZlw+l_ma8aET(s+-x+=F-t#Qoiuu1i`^x8Sj>b^U} zs^z<()YMFP7CmjUC@M=&lA5W7t&cxTlzJAts*%PBDAPuqcV5o7HEnqjif_7xGt)F% zGx2b4w{@!tE)$p=l3&?Bf#`+!-RLOleeRk3 z7#pF|w@6_sBmn1nECqdunmG^}pr5(ZJQVvAt$6p3H(16~;vO>?sTE`Y+mq5YP&PBo zvq!7#W$Gewy`;%6o^!Dtjz~x)T}Bdk*BS#=EY=ODD&B=V6TD2z^hj1m5^d6s)D*wk zu$z~D7QuZ2b?5`p)E8e2_L38v3WE{V`bVk;6fl#o2`) z99JsWhh?$oVRn@$S#)uK&8DL8>An0&S<%V8hnGD7Z^;Y(%6;^9!7kDQ5bjR_V+~wp zfx4m3z6CWmmZ<8gDGUyg3>t8wgJ5NkkiEm^(sedCicP^&3D%}6LtIUq>mXCAt{9eF zNXL$kGcoUTf_Lhm`t;hD-SE)m=iBnxRU(NyL}f6~1uH)`K!hmYZjLI%H}AmEF5RZt z06$wn63GHnApHXZZJ}s^s)j9(BM6e*7IBK6Bq(!)d~zR#rbxK9NVIlgquoMq z=eGZ9NR!SEqP6=9UQg#@!rtbbSBUM#ynF);zKX+|!Zm}*{H z+j=d?aZ2!?@EL7C~%B?6ouCKLnO$uWn;Y6Xz zX8dSwj732u(o*U3F$F=7xwxm>E-B+SVZH;O-4XPuPkLSt_?S0)lb7EEg)Mglk0#eS z9@jl(OnH4juMxY+*r03VDfPx_IM!Lmc(5hOI;`?d37f>jPP$?9jQQIQU@i4vuG6MagEoJrQ=RD7xt@8E;c zeGV*+Pt+t$@pt!|McETOE$9k=_C!70uhwRS9X#b%ZK z%q(TIUXSS^F0`4Cx?Rk07C6wI4!UVPeI~-fxY6`YH$kABdOuiRtl73MqG|~AzZ@iL&^s?24iS;RK_pdlWkhcF z@Wv-Om(Aealfg)D^adlXh9Nvf~Uf@y;g3Y)i(YP zEXDnb1V}1pJT5ZWyw=1i+0fni9yINurD=EqH^ciOwLUGi)C%Da)tyt=zq2P7pV5-G zR7!oq28-Fgn5pW|nlu^b!S1Z#r7!Wtr{5J5PQ>pd+2P7RSD?>(U7-|Y z7ZQ5lhYIl_IF<9?T9^IPK<(Hp;l5bl5tF9>X-zG14_7PfsA>6<$~A338iYRT{a@r_ zuXBaT=`T5x3=s&3=RYx6NgG>No4?5KFBVjE(swfcivcIpPQFx5l+O;fiGsOrl5teR z_Cm+;PW}O0Dwe_(4Z@XZ)O0W-v2X><&L*<~*q3dg;bQW3g7)a#3KiQP>+qj|qo*Hk z?57>f2?f@`=Fj^nkDKeRkN2d$Z@2eNKpHo}ksj-$`QKb6n?*$^*%Fb3_Kbf1(*W9K>{L$mud2WHJ=j0^=g30Xhg8$#g^?36`p1fm;;1@0Lrx+8t`?vN0ZorM zSW?rhjCE8$C|@p^sXdx z|NOHHg+fL;HIlqyLp~SSdIF`TnSHehNCU9t89yr@)FY<~hu+X`tjg(aSVae$wDG*C zq$nY(Y494R)hD!i1|IIyP*&PD_c2FPgeY)&mX1qujB1VHPG9`yFQpLFVQ0>EKS@Bp zAfP5`C(sWGLI?AC{XEjLKR4FVNw(4+9b?kba95ukgR1H?w<8F7)G+6&(zUhIE5Ef% z=fFkL3QKA~M@h{nzjRq!Y_t!%U66#L8!(2-GgFxkD1=JRRqk=n%G(yHKn%^&$dW>; zSjAcjETMz1%205se$iH_)ZCpfg_LwvnsZQAUCS#^FExp8O4CrJb6>JquNV@qPq~3A zZ<6dOU#6|8+fcgiA#~MDmcpIEaUO02L5#T$HV0$EMD94HT_eXLZ2Zi&(! z&5E>%&|FZ`)CN10tM%tLSPD*~r#--K(H-CZqIOb99_;m|D5wdgJ<1iOJz@h2Zkq?} z%8_KXb&hf=2Wza(Wgc;3v3TN*;HTU*q2?#z&tLn_U0Nt!y>Oo>+2T)He6%XuP;fgn z-G!#h$Y2`9>Jtf}hbVrm6D70|ERzLAU>3zoWhJmjWfgM^))T+2u$~5>HF9jQDkrXR z=IzX36)V75PrFjkQ%TO+iqKGCQ-DDXbaE;C#}!-CoWQx&v*vHfyI>$HNRbpvm<`O( zlx9NBWD6_e&J%Ous4yp~s6)Ghni!I6)0W;9(9$y1wWu`$gs<$9Mcf$L*piP zPR0Av*2%ul`W;?-1_-5Zy0~}?`e@Y5A&0H!^ApyVTT}BiOm4GeFo$_oPlDEyeGBbh z1h3q&Dx~GmUS|3@4V36&$2uO8!Yp&^pD7J5&TN{?xphf*-js1fP?B|`>p_K>lh{ij zP(?H%e}AIP?_i^f&Li=FDSQ`2_NWxL+BB=nQr=$ zHojMlXNGauvvwPU>ZLq!`bX-5F4jBJ&So{kE5+ms9UEYD{66!|k~3vsP+mE}x!>%P za98bAU0!h0&ka4EoiDvBM#CP#dRNdXJcb*(%=<(g+M@<)DZ!@v1V>;54En?igcHR2 zhubQMq}VSOK)onqHfczM7YA@s=9*ow;k;8)&?J3@0JiGcP! zP#00KZ1t)GyZeRJ=f0^gc+58lc4Qh*S7RqPIC6GugG1gXe$LIQMRCo8cHf^qXgAa2 z`}t>u2Cq1CbSEpLr~E=c7~=Qkc9-vLE%(v9N*&HF`(d~(0`iukl5aQ9u4rUvc8%m) zr2GwZN4!s;{SB87lJB;veebPmqE}tSpT>+`t?<457Q9iV$th%i__Z1kOMAswFldD6 ztbOvO337S5o#ZZgN2G99_AVqPv!?Gmt3pzgD+Hp3QPQ`9qJ(g=kjvD+fUSS3upJn! zqoG7acIKEFRX~S}3|{EWT$kdz#zrDlJU(rPkxjws_iyLKU8+v|*oS_W*-guAb&Pj1 z35Z`3z<&Jb@2Mwz=KXucNYdY#SNO$tcVFr9KdKm|%^e-TXzs6M`PBper%ajkrIyUe zp$vVxVs9*>Vp4_1NC~Zg)WOCPmOxI1V34QlG4!aSFOH{QqSVq1^1)- z0P!Z?tT&E-ll(pwf0?=F=yOzik=@nh1Clxr9}Vij89z)ePDSCYAqw?lVI?v?+&*zH z)p$CScFI8rrwId~`}9YWPFu0cW1Sf@vRELs&cbntRU6QfPK-SO*mqu|u~}8AJ!Q$z znzu}50O=YbjwKCuSVBs6&CZR#0FTu)3{}qJJYX(>QPr4$RqWiwX3NT~;>cLn*_&1H zaKpIW)JVJ>b{uo2oq>oQt3y=zJjb%fU@wLqM{SyaC6x2snMx-}ivfU<1- znu1Lh;i$3Tf$Kh5Uk))G!D1UhE8pvx&nO~w^fG)BC&L!_hQk%^p`Kp@F{cz>80W&T ziOK=Sq3fdRu*V0=S53rcIfWFazI}Twj63CG(jOB;$*b`*#B9uEnBM`hDk*EwSRdwP8?5T?xGUKs=5N83XsR*)a4|ijz|c{4tIU+4j^A5C<#5 z*$c_d=5ml~%pGxw#?*q9N7aRwPux5EyqHVkdJO=5J>84!X6P>DS8PTTz>7C#FO?k#edkntG+fJk8ZMn?pmJSO@`x-QHq;7^h6GEXLXo1TCNhH z8ZDH{*NLAjo3WM`xeb=X{((uv3H(8&r8fJJg_uSs_%hOH%JDD?hu*2NvWGYD+j)&` zz#_1%O1wF^o5ryt?O0n;`lHbzp0wQ?rcbW(F1+h7_EZZ9{>rePvLAPVZ_R|n@;b$;UchU=0j<6k8G9QuQf@76oiE*4 zXOLQ&n3$NR#p4<5NJMVC*S);5x2)eRbaAM%VxWu9ohlT;pGEk7;002enCbQ>2r-us z3#bpXP9g|mE`65VrN`+3mC)M(eMj~~eOf)do<@l+fMiTR)XO}422*1SL{wyY(%oMpBgJagtiDf zz>O6(m;};>Hi=t8o{DVC@YigqS(Qh+ix3Rwa9aliH}a}IlOCW1@?%h_bRbq-W{KHF z%Vo?-j@{Xi@=~Lz5uZP27==UGE15|g^0gzD|3x)SCEXrx`*MP^FDLl%pOi~~Il;dc z^hrwp9sYeT7iZ)-ajKy@{a`kr0-5*_!XfBpXwEcFGJ;%kV$0Nx;apKrur zJN2J~CAv{Zjj%FolyurtW8RaFmpn&zKJWL>(0;;+q(%(Hx!GMW4AcfP0YJ*Vz!F4g z!ZhMyj$BdXL@MlF%KeInmPCt~9&A!;cRw)W!Hi@0DY(GD_f?jeV{=s=cJ6e}JktJw zQORnxxj3mBxfrH=x{`_^Z1ddDh}L#V7i}$njUFRVwOX?qOTKjfPMBO4y(WiU<)epb zvB9L=%jW#*SL|Nd_G?E*_h1^M-$PG6Pc_&QqF0O-FIOpa4)PAEPsyvB)GKasmBoEt z?_Q2~QCYGH+hW31x-B=@5_AN870vY#KB~3a*&{I=f);3Kv7q4Q7s)0)gVYx2#Iz9g(F2;=+Iy4 z6KI^8GJ6D@%tpS^8boU}zpi=+(5GfIR)35PzrbuXeL1Y1N%JK7PG|^2k3qIqHfX;G zQ}~JZ-UWx|60P5?d1e;AHx!_;#PG%d=^X(AR%i`l0jSpYOpXoKFW~7ip7|xvN;2^? zsYC9fanpO7rO=V7+KXqVc;Q5z%Bj})xHVrgoR04sA2 zl~DAwv=!(()DvH*=lyhIlU^hBkA0$e*7&fJpB0|oB7)rqGK#5##2T`@_I^|O2x4GO z;xh6ROcV<9>?e0)MI(y++$-ksV;G;Xe`lh76T#Htuia+(UrIXrf9?

L(tZ$0BqX1>24?V$S+&kLZ`AodQ4_)P#Q3*4xg8}lMV-FLwC*cN$< zt65Rf%7z41u^i=P*qO8>JqXPrinQFapR7qHAtp~&RZ85$>ob|Js;GS^y;S{XnGiBc zGa4IGvDl?x%gY`vNhv8wgZnP#UYI-w*^4YCZnxkF85@ldepk$&$#3EAhrJY0U)lR{F6sM3SONV^+$;Zx8BD&Eku3K zKNLZyBni3)pGzU0;n(X@1fX8wYGKYMpLmCu{N5-}epPDxClPFK#A@02WM3!myN%bkF z|GJ4GZ}3sL{3{qXemy+#Uk{4>Kf8v11;f8I&c76+B&AQ8udd<8gU7+BeWC`akUU~U zgXoxie>MS@rBoyY8O8Tc&8id!w+_ooxcr!1?#rc$-|SBBtH6S?)1e#P#S?jFZ8u-Bs&k`yLqW|{j+%c#A4AQ>+tj$Y z^CZajspu$F%73E68Lw5q7IVREED9r1Ijsg#@DzH>wKseye>hjsk^{n0g?3+gs@7`i zHx+-!sjLx^fS;fY!ERBU+Q zVJ!e0hJH%P)z!y%1^ZyG0>PN@5W~SV%f>}c?$H8r;Sy-ui>aruVTY=bHe}$e zi&Q4&XK!qT7-XjCrDaufT@>ieQ&4G(SShUob0Q>Gznep9fR783jGuUynAqc6$pYX; z7*O@@JW>O6lKIk0G00xsm|=*UVTQBB`u1f=6wGAj%nHK_;Aqmfa!eAykDmi-@u%6~ z;*c!pS1@V8r@IX9j&rW&d*}wpNs96O2Ute>%yt{yv>k!6zfT6pru{F1M3P z2WN1JDYqoTB#(`kE{H676QOoX`cnqHl1Yaru)>8Ky~VU{)r#{&s86Vz5X)v15ULHA zAZDb{99+s~qI6;-dQ5DBjHJP@GYTwn;Dv&9kE<0R!d z8tf1oq$kO`_sV(NHOSbMwr=To4r^X$`sBW4$gWUov|WY?xccQJN}1DOL|GEaD_!@& z15p?Pj+>7d`@LvNIu9*^hPN)pwcv|akvYYq)ks%`G>!+!pW{-iXPZsRp8 z35LR;DhseQKWYSD`%gO&k$Dj6_6q#vjWA}rZcWtQr=Xn*)kJ9kacA=esi*I<)1>w^ zO_+E>QvjP)qiSZg9M|GNeLtO2D7xT6vsj`88sd!94j^AqxFLi}@w9!Y*?nwWARE0P znuI_7A-saQ+%?MFA$gttMV-NAR^#tjl_e{R$N8t2NbOlX373>e7Ox=l=;y#;M7asp zRCz*CLnrm$esvSb5{T<$6CjY zmZ(i{Rs_<#pWW>(HPaaYj`%YqBra=Ey3R21O7vUbzOkJJO?V`4-D*u4$Me0Bx$K(lYo`JO}gnC zx`V}a7m-hLU9Xvb@K2ymioF)vj12<*^oAqRuG_4u%(ah?+go%$kOpfb`T96P+L$4> zQ#S+sA%VbH&mD1k5Ak7^^dZoC>`1L%i>ZXmooA!%GI)b+$D&ziKrb)a=-ds9xk#~& z7)3iem6I|r5+ZrTRe_W861x8JpD`DDIYZNm{$baw+$)X^Jtjnl0xlBgdnNY}x%5za zkQ8E6T<^$sKBPtL4(1zi_Rd(tVth*3Xs!ulflX+70?gb&jRTnI8l+*Aj9{|d%qLZ+ z>~V9Z;)`8-lds*Zgs~z1?Fg?Po7|FDl(Ce<*c^2=lFQ~ahwh6rqSjtM5+$GT>3WZW zj;u~w9xwAhOc<kF}~`CJ68 z?(S5vNJa;kriPlim33{N5`C{9?NWhzsna_~^|K2k4xz1`xcui*LXL-1#Y}Hi9`Oo!zQ>x-kgAX4LrPz63uZ+?uG*84@PKq-KgQlMNRwz=6Yes) zY}>YN+qP}nwr$(CZQFjUOI=-6J$2^XGvC~EZ+vrqWaOXB$k?%Suf5k=4>AveC1aJ! ziaW4IS%F$_Babi)kA8Y&u4F7E%99OPtm=vzw$$ zEz#9rvn`Iot_z-r3MtV>k)YvErZ<^Oa${`2>MYYODSr6?QZu+be-~MBjwPGdMvGd!b!elsdi4% z`37W*8+OGulab8YM?`KjJ8e+jM(tqLKSS@=jimq3)Ea2EB%88L8CaM+aG7;27b?5` z4zuUWBr)f)k2o&xg{iZ$IQkJ+SK>lpq4GEacu~eOW4yNFLU!Kgc{w4&D$4ecm0f}~ zTTzquRW@`f0}|IILl`!1P+;69g^upiPA6F{)U8)muWHzexRenBU$E^9X-uIY2%&1w z_=#5*(nmxJ9zF%styBwivi)?#KMG96-H@hD-H_&EZiRNsfk7mjBq{L%!E;Sqn!mVX*}kXhwH6eh;b42eD!*~upVG@ z#smUqz$ICm!Y8wY53gJeS|Iuard0=;k5i5Z_hSIs6tr)R4n*r*rE`>38Pw&lkv{_r!jNN=;#?WbMj|l>cU(9trCq; z%nN~r^y7!kH^GPOf3R}?dDhO=v^3BeP5hF|%4GNQYBSwz;x({21i4OQY->1G=KFyu z&6d`f2tT9Yl_Z8YACZaJ#v#-(gcyeqXMhYGXb=t>)M@fFa8tHp2x;ODX=Ap@a5I=U z0G80^$N0G4=U(>W%mrrThl0DjyQ-_I>+1Tdd_AuB3qpYAqY54upwa3}owa|x5iQ^1 zEf|iTZxKNGRpI>34EwkIQ2zHDEZ=(J@lRaOH>F|2Z%V_t56Km$PUYu^xA5#5Uj4I4RGqHD56xT%H{+P8Ag>e_3pN$4m8n>i%OyJFPNWaEnJ4McUZPa1QmOh?t8~n& z&RulPCors8wUaqMHECG=IhB(-tU2XvHP6#NrLVyKG%Ee*mQ5Ps%wW?mcnriTVRc4J`2YVM>$ixSF2Xi+Wn(RUZnV?mJ?GRdw%lhZ+t&3s7g!~g{%m&i<6 z5{ib-<==DYG93I(yhyv4jp*y3#*WNuDUf6`vTM%c&hiayf(%=x@4$kJ!W4MtYcE#1 zHM?3xw63;L%x3drtd?jot!8u3qeqctceX3m;tWetK+>~q7Be$h>n6riK(5@ujLgRS zvOym)k+VAtyV^mF)$29Y`nw&ijdg~jYpkx%*^ z8dz`C*g=I?;clyi5|!27e2AuSa$&%UyR(J3W!A=ZgHF9OuKA34I-1U~pyD!KuRkjA zbkN!?MfQOeN>DUPBxoy5IX}@vw`EEB->q!)8fRl_mqUVuRu|C@KD-;yl=yKc=ZT0% zB$fMwcC|HE*0f8+PVlWHi>M`zfsA(NQFET?LrM^pPcw`cK+Mo0%8*x8@65=CS_^$cG{GZQ#xv($7J z??R$P)nPLodI;P!IC3eEYEHh7TV@opr#*)6A-;EU2XuogHvC;;k1aI8asq7ovoP!* z?x%UoPrZjj<&&aWpsbr>J$Er-7!E(BmOyEv!-mbGQGeJm-U2J>74>o5x`1l;)+P&~ z>}f^=Rx(ZQ2bm+YE0u=ZYrAV@apyt=v1wb?R@`i_g64YyAwcOUl=C!i>=Lzb$`tjv zOO-P#A+)t-JbbotGMT}arNhJmmGl-lyUpMn=2UacVZxmiG!s!6H39@~&uVokS zG=5qWhfW-WOI9g4!R$n7!|ViL!|v3G?GN6HR0Pt_L5*>D#FEj5wM1DScz4Jv@Sxnl zB@MPPmdI{(2D?;*wd>3#tjAirmUnQoZrVv`xM3hARuJksF(Q)wd4P$88fGYOT1p6U z`AHSN!`St}}UMBT9o7i|G`r$ zrB=s$qV3d6$W9@?L!pl0lf%)xs%1ko^=QY$ty-57=55PvP(^6E7cc zGJ*>m2=;fOj?F~yBf@K@9qwX0hA803Xw+b0m}+#a(>RyR8}*Y<4b+kpp|OS+!whP( zH`v{%s>jsQI9rd$*vm)EkwOm#W_-rLTHcZRek)>AtF+~<(did)*oR1|&~1|e36d-d zgtm5cv1O0oqgWC%Et@P4Vhm}Ndl(Y#C^MD03g#PH-TFy+7!Osv1z^UWS9@%JhswEq~6kSr2DITo59+; ze=ZC}i2Q?CJ~Iyu?vn|=9iKV>4j8KbxhE4&!@SQ^dVa-gK@YfS9xT(0kpW*EDjYUkoj! zE49{7H&E}k%5(>sM4uGY)Q*&3>{aitqdNnRJkbOmD5Mp5rv-hxzOn80QsG=HJ_atI-EaP69cacR)Uvh{G5dTpYG7d zbtmRMq@Sexey)||UpnZ?;g_KMZq4IDCy5}@u!5&B^-=6yyY{}e4Hh3ee!ZWtL*s?G zxG(A!<9o!CL+q?u_utltPMk+hn?N2@?}xU0KlYg?Jco{Yf@|mSGC<(Zj^yHCvhmyx z?OxOYoxbptDK()tsJ42VzXdINAMWL$0Gcw?G(g8TMB)Khw_|v9`_ql#pRd2i*?CZl z7k1b!jQB=9-V@h%;Cnl7EKi;Y^&NhU0mWEcj8B|3L30Ku#-9389Q+(Yet0r$F=+3p z6AKOMAIi|OHyzlHZtOm73}|ntKtFaXF2Fy|M!gOh^L4^62kGUoWS1i{9gsds_GWBc zLw|TaLP64z3z9?=R2|T6Xh2W4_F*$cq>MtXMOy&=IPIJ`;!Tw?PqvI2b*U1)25^<2 zU_ZPoxg_V0tngA0J+mm?3;OYw{i2Zb4x}NedZug!>EoN3DC{1i)Z{Z4m*(y{ov2%- zk(w>+scOO}MN!exSc`TN)!B=NUX`zThWO~M*ohqq;J2hx9h9}|s#?@eR!=F{QTrq~ zTcY|>azkCe$|Q0XFUdpFT=lTcyW##i;-e{}ORB4D?t@SfqGo_cS z->?^rh$<&n9DL!CF+h?LMZRi)qju!meugvxX*&jfD!^1XB3?E?HnwHP8$;uX{Rvp# zh|)hM>XDv$ZGg=$1{+_bA~u-vXqlw6NH=nkpyWE0u}LQjF-3NhATL@9rRxMnpO%f7 z)EhZf{PF|mKIMFxnC?*78(}{Y)}iztV12}_OXffJ;ta!fcFIVjdchyHxH=t%ci`Xd zX2AUB?%?poD6Zv*&BA!6c5S#|xn~DK01#XvjT!w!;&`lDXSJT4_j$}!qSPrb37vc{ z9^NfC%QvPu@vlxaZ;mIbn-VHA6miwi8qJ~V;pTZkKqqOii<1Cs}0i?uUIss;hM4dKq^1O35y?Yp=l4i zf{M!@QHH~rJ&X~8uATV><23zZUbs-J^3}$IvV_ANLS08>k`Td7aU_S1sLsfi*C-m1 z-e#S%UGs4E!;CeBT@9}aaI)qR-6NU@kvS#0r`g&UWg?fC7|b^_HyCE!8}nyh^~o@< zpm7PDFs9yxp+byMS(JWm$NeL?DNrMCNE!I^ko-*csB+dsf4GAq{=6sfyf4wb>?v1v zmb`F*bN1KUx-`ra1+TJ37bXNP%`-Fd`vVQFTwWpX@;s(%nDQa#oWhgk#mYlY*!d>( zE&!|ySF!mIyfING+#%RDY3IBH_fW$}6~1%!G`suHub1kP@&DoAd5~7J55;5_noPI6eLf{t;@9Kf<{aO0`1WNKd?<)C-|?C?)3s z>wEq@8=I$Wc~Mt$o;g++5qR+(6wt9GI~pyrDJ%c?gPZe)owvy^J2S=+M^ z&WhIE`g;;J^xQLVeCtf7b%Dg#Z2gq9hp_%g)-%_`y*zb; zn9`f`mUPN-Ts&fFo(aNTsXPA|J!TJ{0hZp0^;MYHLOcD=r_~~^ymS8KLCSeU3;^QzJNqS z5{5rEAv#l(X?bvwxpU;2%pQftF`YFgrD1jt2^~Mt^~G>T*}A$yZc@(k9orlCGv&|1 zWWvVgiJsCAtamuAYT~nzs?TQFt<1LSEx!@e0~@yd6$b5!Zm(FpBl;(Cn>2vF?k zOm#TTjFwd2D-CyA!mqR^?#Uwm{NBemP>(pHmM}9;;8`c&+_o3#E5m)JzfwN?(f-a4 zyd%xZc^oQx3XT?vcCqCX&Qrk~nu;fxs@JUoyVoi5fqpi&bUhQ2y!Ok2pzsFR(M(|U zw3E+kH_zmTRQ9dUMZWRE%Zakiwc+lgv7Z%|YO9YxAy`y28`Aw;WU6HXBgU7fl@dnt z-fFBV)}H-gqP!1;V@Je$WcbYre|dRdp{xt!7sL3Eoa%IA`5CAA%;Wq8PktwPdULo! z8!sB}Qt8#jH9Sh}QiUtEPZ6H0b*7qEKGJ%ITZ|vH)5Q^2m<7o3#Z>AKc%z7_u`rXA zqrCy{-{8;9>dfllLu$^M5L z-hXs))h*qz%~ActwkIA(qOVBZl2v4lwbM>9l70Y`+T*elINFqt#>OaVWoja8RMsep z6Or3f=oBnA3vDbn*+HNZP?8LsH2MY)x%c13@(XfuGR}R?Nu<|07{$+Lc3$Uv^I!MQ z>6qWgd-=aG2Y^24g4{Bw9ueOR)(9h`scImD=86dD+MnSN4$6 z^U*o_mE-6Rk~Dp!ANp#5RE9n*LG(Vg`1)g6!(XtDzsov$Dvz|Gv1WU68J$CkshQhS zCrc|cdkW~UK}5NeaWj^F4MSgFM+@fJd{|LLM)}_O<{rj z+?*Lm?owq?IzC%U%9EBga~h-cJbIu=#C}XuWN>OLrc%M@Gu~kFEYUi4EC6l#PR2JS zQUkGKrrS#6H7}2l0F@S11DP`@pih0WRkRJl#F;u{c&ZC{^$Z+_*lB)r)-bPgRFE;* zl)@hK4`tEP=P=il02x7-C7p%l=B`vkYjw?YhdJU9!P!jcmY$OtC^12w?vy3<<=tlY zUwHJ_0lgWN9vf>1%WACBD{UT)1qHQSE2%z|JHvP{#INr13jM}oYv_5#xsnv9`)UAO zuwgyV4YZ;O)eSc3(mka6=aRohi!HH@I#xq7kng?Acdg7S4vDJb6cI5fw?2z%3yR+| zU5v@Hm}vy;${cBp&@D=HQ9j7NcFaOYL zj-wV=eYF{|XTkFNM2uz&T8uH~;)^Zo!=KP)EVyH6s9l1~4m}N%XzPpduPg|h-&lL` zAXspR0YMOKd2yO)eMFFJ4?sQ&!`dF&!|niH*!^*Ml##o0M(0*uK9&yzekFi$+mP9s z>W9d%Jb)PtVi&-Ha!o~Iyh@KRuKpQ@)I~L*d`{O8!kRObjO7=n+Gp36fe!66neh+7 zW*l^0tTKjLLzr`x4`_8&on?mjW-PzheTNox8Hg7Nt@*SbE-%kP2hWYmHu#Fn@Q^J(SsPUz*|EgOoZ6byg3ew88UGdZ>9B2Tq=jF72ZaR=4u%1A6Vm{O#?@dD!(#tmR;eP(Fu z{$0O%=Vmua7=Gjr8nY%>ul?w=FJ76O2js&17W_iq2*tb!i{pt#`qZB#im9Rl>?t?0c zicIC}et_4d+CpVPx)i4~$u6N-QX3H77ez z?ZdvXifFk|*F8~L(W$OWM~r`pSk5}#F?j_5u$Obu9lDWIknO^AGu+Blk7!9Sb;NjS zncZA?qtASdNtzQ>z7N871IsPAk^CC?iIL}+{K|F@BuG2>qQ;_RUYV#>hHO(HUPpk@ z(bn~4|F_jiZi}Sad;_7`#4}EmD<1EiIxa48QjUuR?rC}^HRocq`OQPM@aHVKP9E#q zy%6bmHygCpIddPjE}q_DPC`VH_2m;Eey&ZH)E6xGeStOK7H)#+9y!%-Hm|QF6w#A( zIC0Yw%9j$s-#odxG~C*^MZ?M<+&WJ+@?B_QPUyTg9DJGtQN#NIC&-XddRsf3n^AL6 zT@P|H;PvN;ZpL0iv$bRb7|J{0o!Hq+S>_NrH4@coZtBJu#g8#CbR7|#?6uxi8d+$g z87apN>EciJZ`%Zv2**_uiET9Vk{pny&My;+WfGDw4EVL#B!Wiw&M|A8f1A@ z(yFQS6jfbH{b8Z-S7D2?Ixl`j0{+ZnpT=;KzVMLW{B$`N?Gw^Fl0H6lT61%T2AU**!sX0u?|I(yoy&Xveg7XBL&+>n6jd1##6d>TxE*Vj=8lWiG$4=u{1UbAa5QD>5_ z;Te^42v7K6Mmu4IWT6Rnm>oxrl~b<~^e3vbj-GCdHLIB_>59}Ya+~OF68NiH=?}2o zP(X7EN=quQn&)fK>M&kqF|<_*H`}c zk=+x)GU>{Af#vx&s?`UKUsz})g^Pc&?Ka@t5$n$bqf6{r1>#mWx6Ep>9|A}VmWRnowVo`OyCr^fHsf# zQjQ3Ttp7y#iQY8l`zEUW)(@gGQdt(~rkxlkefskT(t%@i8=|p1Y9Dc5bc+z#n$s13 zGJk|V0+&Ekh(F};PJzQKKo+FG@KV8a<$gmNSD;7rd_nRdc%?9)p!|B-@P~kxQG}~B zi|{0}@}zKC(rlFUYp*dO1RuvPC^DQOkX4<+EwvBAC{IZQdYxoq1Za!MW7%p7gGr=j zzWnAq%)^O2$eItftC#TTSArUyL$U54-O7e|)4_7%Q^2tZ^0-d&3J1}qCzR4dWX!)4 zzIEKjgnYgMus^>6uw4Jm8ga6>GBtMjpNRJ6CP~W=37~||gMo_p@GA@#-3)+cVYnU> zE5=Y4kzl+EbEh%dhQokB{gqNDqx%5*qBusWV%!iprn$S!;oN_6E3?0+umADVs4ako z?P+t?m?};gev9JXQ#Q&KBpzkHPde_CGu-y z<{}RRAx=xlv#mVi+Ibrgx~ujW$h{?zPfhz)Kp7kmYS&_|97b&H&1;J-mzrBWAvY} zh8-I8hl_RK2+nnf&}!W0P+>5?#?7>npshe<1~&l_xqKd0_>dl_^RMRq@-Myz&|TKZBj1=Q()) zF{dBjv5)h=&Z)Aevx}+i|7=R9rG^Di!sa)sZCl&ctX4&LScQ-kMncgO(9o6W6)yd< z@Rk!vkja*X_N3H=BavGoR0@u0<}m-7|2v!0+2h~S2Q&a=lTH91OJsvms2MT~ zY=c@LO5i`mLpBd(vh|)I&^A3TQLtr>w=zoyzTd=^f@TPu&+*2MtqE$Avf>l>}V|3-8Fp2hzo3y<)hr_|NO(&oSD z!vEjTWBxbKTiShVl-U{n*B3#)3a8$`{~Pk}J@elZ=>Pqp|MQ}jrGv7KrNcjW%TN_< zZz8kG{#}XoeWf7qY?D)L)8?Q-b@Na&>i=)(@uNo zr;cH98T3$Iau8Hn*@vXi{A@YehxDE2zX~o+RY`)6-X{8~hMpc#C`|8y> zU8Mnv5A0dNCf{Ims*|l-^ z(MRp{qoGohB34|ggDI*p!Aw|MFyJ|v+<+E3brfrI)|+l3W~CQLPbnF@G0)P~Ly!1TJLp}xh8uW`Q+RB-v`MRYZ9Gam3cM%{ zb4Cb*f)0deR~wtNb*8w-LlIF>kc7DAv>T0D(a3@l`k4TFnrO+g9XH7;nYOHxjc4lq zMmaW6qpgAgy)MckYMhl?>sq;-1E)-1llUneeA!ya9KM$)DaNGu57Z5aE>=VST$#vb zFo=uRHr$0M{-ha>h(D_boS4zId;3B|Tpqo|?B?Z@I?G(?&Iei+-{9L_A9=h=Qfn-U z1wIUnQe9!z%_j$F_{rf&`ZFSott09gY~qrf@g3O=Y>vzAnXCyL!@(BqWa)Zqt!#_k zfZHuwS52|&&)aK;CHq9V-t9qt0au{$#6c*R#e5n3rje0hic7c7m{kW$p(_`wB=Gw7 z4k`1Hi;Mc@yA7dp@r~?@rfw)TkjAW++|pkfOG}0N|2guek}j8Zen(!+@7?qt_7ndX zB=BG6WJ31#F3#Vk3=aQr8T)3`{=p9nBHlKzE0I@v`{vJ}h8pd6vby&VgFhzH|q;=aonunAXL6G2y(X^CtAhWr*jI zGjpY@raZDQkg*aMq}Ni6cRF z{oWv}5`nhSAv>usX}m^GHt`f(t8@zHc?K|y5Zi=4G*UG1Sza{$Dpj%X8 zzEXaKT5N6F5j4J|w#qlZP!zS7BT)9b+!ZSJdToqJts1c!)fwih4d31vfb{}W)EgcA zH2pZ^8_k$9+WD2n`6q5XbOy8>3pcYH9 z07eUB+p}YD@AH!}p!iKv><2QF-Y^&xx^PAc1F13A{nUeCDg&{hnix#FiO!fe(^&%Qcux!h znu*S!s$&nnkeotYsDthh1dq(iQrE|#f_=xVgfiiL&-5eAcC-> z5L0l|DVEM$#ulf{bj+Y~7iD)j<~O8CYM8GW)dQGq)!mck)FqoL^X zwNdZb3->hFrbHFm?hLvut-*uK?zXn3q1z|UX{RZ;-WiLoOjnle!xs+W0-8D)kjU#R z+S|A^HkRg$Ij%N4v~k`jyHffKaC~=wg=9)V5h=|kLQ@;^W!o2^K+xG&2n`XCd>OY5Ydi= zgHH=lgy++erK8&+YeTl7VNyVm9-GfONlSlVb3)V9NW5tT!cJ8d7X)!b-$fb!s76{t z@d=Vg-5K_sqHA@Zx-L_}wVnc@L@GL9_K~Zl(h5@AR#FAiKad8~KeWCo@mgXIQ#~u{ zgYFwNz}2b6Vu@CP0XoqJ+dm8px(5W5-Jpis97F`+KM)TuP*X8H@zwiVKDKGVp59pI zifNHZr|B+PG|7|Y<*tqap0CvG7tbR1R>jn70t1X`XJixiMVcHf%Ez*=xm1(CrTSDt z0cle!+{8*Ja&EOZ4@$qhBuKQ$U95Q%rc7tg$VRhk?3=pE&n+T3upZg^ZJc9~c2es% zh7>+|mrmA-p&v}|OtxqmHIBgUxL~^0+cpfkSK2mhh+4b=^F1Xgd2)}U*Yp+H?ls#z zrLxWg_hm}AfK2XYWr!rzW4g;+^^&bW%LmbtRai9f3PjU${r@n`JThy-cphbcwn)rq9{A$Ht`lmYKxOacy z6v2R(?gHhD5@&kB-Eg?4!hAoD7~(h>(R!s1c1Hx#s9vGPePUR|of32bS`J5U5w{F) z>0<^ktO2UHg<0{oxkdOQ;}coZDQph8p6ruj*_?uqURCMTac;>T#v+l1Tc~%^k-Vd@ zkc5y35jVNc49vZpZx;gG$h{%yslDI%Lqga1&&;mN{Ush1c7p>7e-(zp}6E7f-XmJb4nhk zb8zS+{IVbL$QVF8pf8}~kQ|dHJAEATmmnrb_wLG}-yHe>W|A&Y|;muy-d^t^<&)g5SJfaTH@P1%euONny=mxo+C z4N&w#biWY41r8k~468tvuYVh&XN&d#%QtIf9;iVXfWY)#j=l`&B~lqDT@28+Y!0E+MkfC}}H*#(WKKdJJq=O$vNYCb(ZG@p{fJgu;h z21oHQ(14?LeT>n5)s;uD@5&ohU!@wX8w*lB6i@GEH0pM>YTG+RAIWZD;4#F1&F%Jp zXZUml2sH0!lYJT?&sA!qwez6cXzJEd(1ZC~kT5kZSp7(@=H2$Azb_*W&6aA|9iwCL zdX7Q=42;@dspHDwYE?miGX#L^3xD&%BI&fN9^;`v4OjQXPBaBmOF1;#C)8XA(WFlH zycro;DS2?(G&6wkr6rqC>rqDv3nfGw3hmN_9Al>TgvmGsL8_hXx09};l9Ow@)F5@y z#VH5WigLDwZE4nh^7&@g{1FV^UZ%_LJ-s<{HN*2R$OPg@R~Z`c-ET*2}XB@9xvAjrK&hS=f|R8Gr9 zr|0TGOsI7RD+4+2{ZiwdVD@2zmg~g@^D--YL;6UYGSM8i$NbQr4!c7T9rg!8;TM0E zT#@?&S=t>GQm)*ua|?TLT2ktj#`|R<_*FAkOu2Pz$wEc%-=Y9V*$&dg+wIei3b*O8 z2|m$!jJG!J!ZGbbIa!(Af~oSyZV+~M1qGvelMzPNE_%5?c2>;MeeG2^N?JDKjFYCy z7SbPWH-$cWF9~fX%9~v99L!G(wi!PFp>rB!9xj7=Cv|F+7CsGNwY0Q_J%FID%C^CBZQfJ9K(HK%k31j~e#&?hQ zNuD6gRkVckU)v+53-fc} z7ZCzYN-5RG4H7;>>Hg?LU9&5_aua?A0)0dpew1#MMlu)LHe(M;OHjHIUl7|%%)YPo z0cBk;AOY00%Fe6heoN*$(b<)Cd#^8Iu;-2v@>cE-OB$icUF9EEoaC&q8z9}jMTT2I z8`9;jT%z0;dy4!8U;GW{i`)3!c6&oWY`J3669C!tM<5nQFFrFRglU8f)5Op$GtR-3 zn!+SPCw|04sv?%YZ(a7#L?vsdr7ss@WKAw&A*}-1S|9~cL%uA+E~>N6QklFE>8W|% zyX-qAUGTY1hQ-+um`2|&ji0cY*(qN!zp{YpDO-r>jPk*yuVSay<)cUt`t@&FPF_&$ zcHwu1(SQ`I-l8~vYyUxm@D1UEdFJ$f5Sw^HPH7b!9 zzYT3gKMF((N(v0#4f_jPfVZ=ApN^jQJe-X$`A?X+vWjLn_%31KXE*}5_}d8 zw_B1+a#6T1?>M{ronLbHIlEsMf93muJ7AH5h%;i99<~JX^;EAgEB1uHralD*!aJ@F zV2ruuFe9i2Q1C?^^kmVy921eb=tLDD43@-AgL^rQ3IO9%+vi_&R2^dpr}x{bCVPej z7G0-0o64uyWNtr*loIvslyo0%)KSDDKjfThe0hcqs)(C-MH1>bNGBDRTW~scy_{w} zp^aq8Qb!h9Lwielq%C1b8=?Z=&U)ST&PHbS)8Xzjh2DF?d{iAv)Eh)wsUnf>UtXN( zL7=$%YrZ#|^c{MYmhn!zV#t*(jdmYdCpwqpZ{v&L8KIuKn`@IIZfp!uo}c;7J57N` zAxyZ-uA4=Gzl~Ovycz%MW9ZL7N+nRo&1cfNn9(1H5eM;V_4Z_qVann7F>5f>%{rf= zPBZFaV@_Sobl?Fy&KXyzFDV*FIdhS5`Uc~S^Gjo)aiTHgn#<0C=9o-a-}@}xDor;D zZyZ|fvf;+=3MZd>SR1F^F`RJEZo+|MdyJYQAEauKu%WDol~ayrGU3zzbHKsnHKZ*z zFiwUkL@DZ>!*x05ql&EBq@_Vqv83&?@~q5?lVmffQZ+V-=qL+!u4Xs2Z2zdCQ3U7B&QR9_Iggy} z(om{Y9eU;IPe`+p1ifLx-XWh?wI)xU9ik+m#g&pGdB5Bi<`PR*?92lE0+TkRuXI)z z5LP!N2+tTc%cB6B1F-!fj#}>S!vnpgVU~3!*U1ej^)vjUH4s-bd^%B=ItQqDCGbrEzNQi(dJ`J}-U=2{7-d zK8k^Rlq2N#0G?9&1?HSle2vlkj^KWSBYTwx`2?9TU_DX#J+f+qLiZCqY1TXHFxXZqYMuD@RU$TgcnCC{_(vwZ-*uX)~go#%PK z@}2Km_5aQ~(<3cXeJN6|F8X_1@L%@xTzs}$_*E|a^_URF_qcF;Pfhoe?FTFwvjm1o z8onf@OY@jC2tVcMaZS;|T!Ks(wOgPpRzRnFS-^RZ4E!9dsnj9sFt609a|jJbb1Dt@ z<=Gal2jDEupxUSwWu6zp<<&RnAA;d&4gKVG0iu6g(DsST(4)z6R)zDpfaQ}v{5ARt zyhwvMtF%b-YazR5XLz+oh=mn;y-Mf2a8>7?2v8qX;19y?b>Z5laGHvzH;Nu9S`B8} zI)qN$GbXIQ1VL3lnof^6TS~rvPVg4V?Dl2Bb*K2z4E{5vy<(@@K_cN@U>R!>aUIRnb zL*)=787*cs#zb31zBC49x$`=fkQbMAef)L2$dR{)6BAz!t5U_B#1zZG`^neKSS22oJ#5B=gl%U=WeqL9REF2g zZnfCb0?quf?Ztj$VXvDSWoK`0L=Zxem2q}!XWLoT-kYMOx)!7fcgT35uC~0pySEme z`{wGWTkGr7>+Kb^n;W?BZH6ZP(9tQX%-7zF>vc2}LuWDI(9kh1G#7B99r4x6;_-V+k&c{nPUrR zAXJGRiMe~aup{0qzmLNjS_BC4cB#sXjckx{%_c&^xy{M61xEb>KW_AG5VFXUOjAG4 z^>Qlm9A#1N{4snY=(AmWzatb!ngqiqPbBZ7>Uhb3)dTkSGcL#&SH>iMO-IJBPua`u zo)LWZ>=NZLr758j{%(|uQuZ)pXq_4c!!>s|aDM9#`~1bzK3J1^^D#<2bNCccH7~-X}Ggi!pIIF>uFx%aPARGQsnC8ZQc8lrQ5o~smqOg>Ti^GNme94*w z)JZy{_{#$jxGQ&`M z!OMvZMHR>8*^>eS%o*6hJwn!l8VOOjZQJvh)@tnHVW&*GYPuxqXw}%M!(f-SQf`=L z5;=5w2;%82VMH6Xi&-K3W)o&K^+vJCepWZ-rW%+Dc6X3(){z$@4zjYxQ|}8UIojeC zYZpQ1dU{fy=oTr<4VX?$q)LP}IUmpiez^O&N3E_qPpchGTi5ZM6-2ScWlQq%V&R2Euz zO|Q0Hx>lY1Q1cW5xHv5!0OGU~PVEqSuy#fD72d#O`N!C;o=m+YioGu-wH2k6!t<~K zSr`E=W9)!g==~x9VV~-8{4ZN9{~-A9zJpRe%NGg$+MDuI-dH|b@BD)~>pPCGUNNzY zMDg||0@XGQgw`YCt5C&A{_+J}mvV9Wg{6V%2n#YSRN{AP#PY?1FF1#|vO_%e+#`|2*~wGAJaeRX6=IzFNeWhz6gJc8+(03Ph4y6ELAm=AkN7TOgMUEw*N{= z_)EIDQx5q22oUR+_b*tazu9+pX|n1c*IB-}{DqIj z-?E|ks{o3AGRNb;+iKcHkZvYJvFsW&83RAPs1Oh@IWy%l#5x2oUP6ZCtv+b|q>jsf zZ_9XO;V!>n`UxH1LvH8)L4?8raIvasEhkpQoJ`%!5rBs!0Tu(s_D{`4opB;57)pkX z4$A^8CsD3U5*!|bHIEqsn~{q+Ddj$ME@Gq4JXtgVz&7l{Ok!@?EA{B3P~NAqb9)4? zkQo30A^EbHfQ@87G5&EQTd`frrwL)&Yw?%-W@uy^Gn23%j?Y!Iea2xw<-f;esq zf%w5WN@E1}zyXtYv}}`U^B>W`>XPmdLj%4{P298|SisrE;7HvXX;A}Ffi8B#3Lr;1 zHt6zVb`8{#+e$*k?w8|O{Uh|&AG}|DG1PFo1i?Y*cQm$ZwtGcVgMwtBUDa{~L1KT-{jET4w60>{KZ27vXrHJ;fW{6| z=|Y4!&UX020wU1>1iRgB@Q#m~1^Z^9CG1LqDhYBrnx%IEdIty z!46iOoKlKs)c}newDG)rWUikD%j`)p z_w9Ph&e40=(2eBy;T!}*1p1f1SAUDP9iWy^u^Ubdj21Kn{46;GR+hwLO=4D11@c~V zI8x&(D({K~Df2E)Nx_yQvYfh4;MbMJ@Z}=Dt3_>iim~QZ*hZIlEs0mEb z_54+&*?wMD`2#vsQRN3KvoT>hWofI_Vf(^C1ff-Ike@h@saEf7g}<9T`W;HAne-Nd z>RR+&SP35w)xKn8^U$7))PsM!jKwYZ*RzEcG-OlTrX3}9a{q%#Un5E5W{{hp>w~;` zGky+3(vJvQyGwBo`tCpmo0mo((?nM8vf9aXrrY1Ve}~TuVkB(zeds^jEfI}xGBCM2 zL1|#tycSaWCurP+0MiActG3LCas@_@tao@(R1ANlwB$4K53egNE_;!&(%@Qo$>h`^1S_!hN6 z)vZtG$8fN!|BXBJ=SI>e(LAU(y(i*PHvgQ2llulxS8>qsimv7yL}0q_E5WiAz7)(f zC(ahFvG8&HN9+6^jGyLHM~$)7auppeWh_^zKk&C_MQ~8;N??OlyH~azgz5fe^>~7F zl3HnPN3z-kN)I$4@`CLCMQx3sG~V8hPS^}XDXZrQA>}mQPw%7&!sd(Pp^P=tgp-s^ zjl}1-KRPNWXgV_K^HkP__SR`S-|OF0bR-N5>I%ODj&1JUeAQ3$9i;B~$S6}*^tK?= z**%aCiH7y?xdY?{LgVP}S0HOh%0%LI$wRx;$T|~Y8R)Vdwa}kGWv8?SJVm^>r6+%I z#lj1aR94{@MP;t-scEYQWc#xFA30^}?|BeX*W#9OL;Q9#WqaaM546j5j29((^_8Nu z4uq}ESLr~r*O7E7$D{!k9W>`!SLoyA53i9QwRB{!pHe8um|aDE`Cg0O*{jmor)^t)3`>V>SWN-2VJcFmj^1?~tT=JrP`fVh*t zXHarp=8HEcR#vFe+1a%XXuK+)oFs`GDD}#Z+TJ}Ri`FvKO@ek2ayn}yaOi%(8p%2$ zpEu)v0Jym@f}U|-;}CbR=9{#<^z28PzkkTNvyKvJDZe+^VS2bES3N@Jq!-*}{oQlz z@8bgC_KnDnT4}d#&Cpr!%Yb?E!brx0!eVOw~;lLwUoz#Np%d$o%9scc3&zPm`%G((Le|6o1 zM(VhOw)!f84zG^)tZ1?Egv)d8cdNi+T${=5kV+j;Wf%2{3g@FHp^Gf*qO0q!u$=m9 zCaY`4mRqJ;FTH5`a$affE5dJrk~k`HTP_7nGTY@B9o9vvnbytaID;^b=Tzp7Q#DmD zC(XEN)Ktn39z5|G!wsVNnHi) z%^q94!lL|hF`IijA^9NR0F$@h7k5R^ljOW(;Td9grRN0Mb)l_l7##{2nPQ@?;VjXv zaLZG}yuf$r$<79rVPpXg?6iiieX|r#&`p#Con2i%S8*8F}(E) zI5E6c3tG*<;m~6>!&H!GJ6zEuhH7mkAzovdhLy;)q z{H2*8I^Pb}xC4s^6Y}6bJvMu=8>g&I)7!N!5QG$xseeU#CC?ZM-TbjsHwHgDGrsD= z{%f;@Sod+Ch66Ko2WF~;Ty)v>&x^aovCbCbD7>qF*!?BXmOV3(s|nxsb*Lx_2lpB7 zokUnzrk;P=T-&kUHO}td+Zdj!3n&NR?K~cRU zAXU!DCp?51{J4w^`cV#ye}(`SQhGQkkMu}O3M*BWt4UsC^jCFUy;wTINYmhD$AT;4 z?Xd{HaJjP`raZ39qAm;%beDbrLpbRf(mkKbANan7XsL>_pE2oo^$TgdidjRP!5-`% zv0d!|iKN$c0(T|L0C~XD0aS8t{*&#LnhE;1Kb<9&=c2B+9JeLvJr*AyyRh%@jHej=AetOMSlz^=!kxX>>B{2B1uIrQyfd8KjJ+DBy!h)~*(!|&L4^Q_07SQ~E zcemVP`{9CwFvPFu7pyVGCLhH?LhEVb2{7U+Z_>o25#+3<|8%1T^5dh}*4(kfJGry} zm%r#hU+__Z;;*4fMrX=Bkc@7|v^*B;HAl0((IBPPii%X9+u3DDF6%bI&6?Eu$8&aWVqHIM7mK6?Uvq$1|(-T|)IV<>e?!(rY zqkmO1MRaLeTR=)io(0GVtQT@s6rN%C6;nS3@eu;P#ry4q;^O@1ZKCJyp_Jo)Ty^QW z+vweTx_DLm{P-XSBj~Sl<%_b^$=}odJ!S2wAcxenmzFGX1t&Qp8Vxz2VT`uQsQYtdn&_0xVivIcxZ_hnrRtwq4cZSj1c-SG9 z7vHBCA=fd0O1<4*=lu$6pn~_pVKyL@ztw1swbZi0B?spLo56ZKu5;7ZeUml1Ws1?u zqMf1p{5myAzeX$lAi{jIUqo1g4!zWLMm9cfWcnw`k6*BR^?$2(&yW?>w;G$EmTA@a z6?y#K$C~ZT8+v{87n5Dm&H6Pb_EQ@V0IWmG9cG=O;(;5aMWWrIPzz4Q`mhK;qQp~a z+BbQrEQ+w{SeiuG-~Po5f=^EvlouB@_|4xQXH@A~KgpFHrwu%dwuCR)=B&C(y6J4J zvoGk9;lLs9%iA-IJGU#RgnZZR+@{5lYl8(e1h6&>Vc_mvg0d@);X zji4T|n#lB!>pfL|8tQYkw?U2bD`W{na&;*|znjmalA&f;*U++_aBYerq;&C8Kw7mI z7tsG*?7*5j&dU)Lje;^{D_h`%(dK|pB*A*1(Jj)w^mZ9HB|vGLkF1GEFhu&rH=r=8 zMxO42e{Si6$m+Zj`_mXb&w5Q(i|Yxyg?juUrY}78uo@~3v84|8dfgbPd0iQJRdMj< zncCNGdMEcsxu#o#B5+XD{tsg*;j-eF8`mp~K8O1J!Z0+>0=7O=4M}E?)H)ENE;P*F z$Ox?ril_^p0g7xhDUf(q652l|562VFlC8^r8?lQv;TMvn+*8I}&+hIQYh2 z1}uQQaag&!-+DZ@|C+C$bN6W;S-Z@)d1|en+XGvjbOxCa-qAF*LA=6s(Jg+g;82f$ z(Vb)8I)AH@cdjGFAR5Rqd0wiNCu!xtqWbcTx&5kslzTb^7A78~Xzw1($UV6S^VWiP zFd{Rimd-0CZC_Bu(WxBFW7+k{cOW7DxBBkJdJ;VsJ4Z@lERQr%3eVv&$%)b%<~ zCl^Y4NgO}js@u{|o~KTgH}>!* z_iDNqX2(As7T0xivMH|3SC1ivm8Q}6Ffcd7owUKN5lHAtzMM4<0v+ykUT!QiowO;`@%JGv+K$bBx@*S7C8GJVqQ_K>12}M`f_Ys=S zKFh}HM9#6Izb$Y{wYzItTy+l5U2oL%boCJn?R3?jP@n$zSIwlmyGq30Cw4QBO|14` zW5c);AN*J3&eMFAk$SR~2k|&+&Bc$e>s%c{`?d~85S-UWjA>DS5+;UKZ}5oVa5O(N zqqc@>)nee)+4MUjH?FGv%hm2{IlIF-QX}ym-7ok4Z9{V+ZHVZQl$A*x!(q%<2~iVv znUa+BX35&lCb#9VE-~Y^W_f;Xhl%vgjwdjzMy$FsSIj&ok}L+X`4>J=9BkN&nu^E*gbhj3(+D>C4E z@Fwq_=N)^bKFSHTzZk?-gNU$@l}r}dwGyh_fNi=9b|n}J>&;G!lzilbWF4B}BBq4f zYIOl?b)PSh#XTPp4IS5ZR_2C!E)Z`zH0OW%4;&~z7UAyA-X|sh9@~>cQW^COA9hV4 zXcA6qUo9P{bW1_2`eo6%hgbN%(G-F1xTvq!sc?4wN6Q4`e9Hku zFwvlAcRY?6h^Fj$R8zCNEDq8`=uZB8D-xn)tA<^bFFy}4$vA}Xq0jAsv1&5!h!yRA zU()KLJya5MQ`q&LKdH#fwq&(bNFS{sKlEh_{N%{XCGO+po#(+WCLmKW6&5iOHny>g z3*VFN?mx!16V5{zyuMWDVP8U*|BGT$(%IO|)?EF|OI*sq&RovH!N%=>i_c?K*A>>k zyg1+~++zY4Q)J;VWN0axhoIKx;l&G$gvj(#go^pZskEVj8^}is3Jw26LzYYVos0HX zRPvmK$dVxM8(Tc?pHFe0Z3uq){{#OK3i-ra#@+;*=ui8)y6hsRv z4Fxx1c1+fr!VI{L3DFMwXKrfl#Q8hfP@ajgEau&QMCxd{g#!T^;ATXW)nUg&$-n25 zruy3V!!;{?OTobo|0GAxe`Acn3GV@W=&n;~&9 zQM>NWW~R@OYORkJAo+eq1!4vzmf9K%plR4(tB@TR&FSbDoRgJ8qVcH#;7lQub*nq&?Z>7WM=oeEVjkaG zT#f)=o!M2DO5hLR+op>t0CixJCIeXH*+z{-XS|%jx)y(j&}Wo|3!l7{o)HU3m7LYyhv*xF&tq z%IN7N;D4raue&&hm0xM=`qv`+TK@;_xAcGKuK(2|75~ar2Yw)geNLSmVxV@x89bQu zpViVKKnlkwjS&&c|-X6`~xdnh}Ps)Hs z4VbUL^{XNLf7_|Oi>tA%?SG5zax}esF*FH3d(JH^Gvr7Rp*n=t7frH!U;!y1gJB^i zY_M$KL_}mW&XKaDEi9K-wZR|q*L32&m+2n_8lq$xRznJ7p8}V>w+d@?uB!eS3#u<} zIaqi!b!w}a2;_BfUUhGMy#4dPx>)_>yZ`ai?Rk`}d0>~ce-PfY-b?Csd(28yX22L% zI7XI>OjIHYTk_@Xk;Gu^F52^Gn6E1&+?4MxDS2G_#PQ&yXPXP^<-p|2nLTb@AAQEY zI*UQ9Pmm{Kat}wuazpjSyXCdnrD&|C1c5DIb1TnzF}f4KIV6D)CJ!?&l&{T)e4U%3HTSYqsQ zo@zWB1o}ceQSV)<4G<)jM|@@YpL+XHuWsr5AYh^Q{K=wSV99D~4RRU52FufmMBMmd z_H}L#qe(}|I9ZyPRD6kT>Ivj&2Y?qVZq<4bG_co_DP`sE*_Xw8D;+7QR$Uq(rr+u> z8bHUWbV19i#)@@G4bCco@Xb<8u~wVDz9S`#k@ciJtlu@uP1U0X?yov8v9U3VOig2t zL9?n$P3=1U_Emi$#slR>N5wH-=J&T=EdUHA}_Z zZIl3nvMP*AZS9{cDqFanrA~S5BqxtNm9tlu;^`)3X&V4tMAkJ4gEIPl= zoV!Gyx0N{3DpD@)pv^iS*dl2FwANu;1;%EDl}JQ7MbxLMAp>)UwNwe{=V}O-5C*>F zu?Ny+F64jZn<+fKjF01}8h5H_3pey|;%bI;SFg$w8;IC<8l|3#Lz2;mNNik6sVTG3 z+Su^rIE#40C4a-587$U~%KedEEw1%r6wdvoMwpmlXH$xPnNQN#f%Z7|p)nC>WsuO= z4zyqapLS<8(UJ~Qi9d|dQijb_xhA2)v>la)<1md5s^R1N&PiuA$^k|A<+2C?OiHbj z>Bn$~t)>Y(Zb`8hW7q9xQ=s>Rv81V+UiuZJc<23HplI88isqRCId89fb`Kt|CxVIg znWcwprwXnotO>3s&Oypkte^9yJjlUVVxSe%_xlzmje|mYOVPH^vjA=?6xd0vaj0Oz zwJ4OJNiFdnHJX3rw&inskjryukl`*fRQ#SMod5J|KroJRsVXa5_$q7whSQ{gOi*s0 z1LeCy|JBWRsDPn7jCb4s(p|JZiZ8+*ExC@Vj)MF|*Vp{B(ziccSn`G1Br9bV(v!C2 z6#?eqpJBc9o@lJ#^p-`-=`4i&wFe>2)nlPK1p9yPFzJCzBQbpkcR>={YtamIw)3nt z(QEF;+)4`>8^_LU)_Q3 zC5_7lgi_6y>U%m)m@}Ku4C}=l^J=<<7c;99ec3p{aR+v=diuJR7uZi%aQv$oP?dn?@6Yu_+*^>T0ptf(oobdL;6)N-I!TO`zg^Xbv3#L0I~sn@WGk-^SmPh5>W+LB<+1PU}AKa?FCWF|qMNELOgdxR{ zbqE7@jVe+FklzdcD$!(A$&}}H*HQFTJ+AOrJYnhh}Yvta(B zQ_bW4Rr;R~&6PAKwgLWXS{Bnln(vUI+~g#kl{r+_zbngT`Y3`^Qf=!PxN4IYX#iW4 zucW7@LLJA9Zh3(rj~&SyN_pjO8H&)|(v%!BnMWySBJV=eSkB3YSTCyIeJ{i;(oc%_hk{$_l;v>nWSB)oVeg+blh=HB5JSlG_r7@P z3q;aFoZjD_qS@zygYqCn=;Zxjo!?NK!%J$ z52lOP`8G3feEj+HTp@Tnn9X~nG=;tS+z}u{mQX_J0kxtr)O30YD%oo)L@wy`jpQYM z@M>Me=95k1p*FW~rHiV1CIfVc{K8r|#Kt(ApkXKsDG$_>76UGNhHExFCw#Ky9*B-z zNq2ga*xax!HMf_|Vp-86r{;~YgQKqu7%szk8$hpvi_2I`OVbG1doP(`gn}=W<8%Gn z%81#&WjkH4GV;4u43EtSW>K_Ta3Zj!XF?;SO3V#q=<=>Tc^@?A`i;&`-cYj|;^ zEo#Jl5zSr~_V-4}y8pnufXLa80vZY4z2ko7fj>DR)#z=wWuS1$$W!L?(y}YC+yQ|G z@L&`2upy3f>~*IquAjkVNU>}c10(fq#HdbK$~Q3l6|=@-eBbo>B9(6xV`*)sae58*f zym~RRVx;xoCG3`JV`xo z!lFw)=t2Hy)e!IFs?0~7osWk(d%^wxq&>_XD4+U#y&-VF%4z?XH^i4w`TxpF{`XhZ z%G}iEzf!T(l>g;W9<~K+)$g!{UvhW{E0Lis(S^%I8OF&%kr!gJ&fMOpM=&=Aj@wuL zBX?*6i51Qb$uhkwkFYkaD_UDE+)rh1c;(&Y=B$3)J&iJfQSx!1NGgPtK!$c9OtJuu zX(pV$bfuJpRR|K(dp@^j}i&HeJOh@|7lWo8^$*o~Xqo z5Sb+!EtJ&e@6F+h&+_1ETbg7LfP5GZjvIUIN3ibCOldAv z)>YdO|NH$x7AC8dr=<2ekiY1%fN*r~e5h6Yaw<{XIErujKV~tiyrvV_DV0AzEknC- zR^xKM3i<1UkvqBj3C{wDvytOd+YtDSGu!gEMg+!&|8BQrT*|p)(dwQLEy+ zMtMzij3zo40)CA!BKZF~yWg?#lWhqD3@qR)gh~D{uZaJO;{OWV8XZ_)J@r3=)T|kt zUS1pXr6-`!Z}w2QR7nP%d?ecf90;K_7C3d!UZ`N(TZoWNN^Q~RjVhQG{Y<%E1PpV^4 z-m-K+$A~-+VDABs^Q@U*)YvhY4Znn2^w>732H?NRK(5QSS$V@D7yz2BVX4)f5A04~$WbxGOam22>t&uD)JB8-~yiQW6ik;FGblY_I>SvB_z2?PS z*Qm&qbKI{H1V@YGWzpx`!v)WeLT02};JJo*#f$a*FH?IIad-^(;9XC#YTWN6;Z6+S zm4O1KH=#V@FJw7Pha0!9Vb%ZIM$)a`VRMoiN&C|$YA3~ZC*8ayZRY^fyuP6$n%2IU z$#XceYZeqLTXw(m$_z|33I$B4k~NZO>pP6)H_}R{E$i%USGy{l{-jOE;%CloYPEU+ zRFxOn4;7lIOh!7abb23YKD+_-?O z0FP9otcAh+oSj;=f#$&*ExUHpd&e#bSF%#8*&ItcL2H$Sa)?pt0Xtf+t)z$_u^wZi z44oE}r4kIZGy3!Mc8q$B&6JqtnHZ>Znn!Zh@6rgIu|yU+zG8q`q9%B18|T|oN3zMq z`l&D;U!OL~%>vo&q0>Y==~zLiCZk4v%s_7!9DxQ~id1LLE93gf*gg&2$|hB#j8;?3 z5v4S;oM6rT{Y;I+#FdmNw z){d%tNM<<#GN%n9ox7B=3#;u7unZ~tLB_vRZ52a&2=IM)2VkXm=L+Iqq~uk#Dug|x z>S84e+A7EiOY5lj*!q?6HDkNh~0g;0Jy(al!ZHHDtur9T$y-~)94HelX1NHjXWIM7UAe}$?jiz z9?P4`I0JM=G5K{3_%2jPLC^_Mlw?-kYYgb7`qGa3@dn|^1fRMwiyM@Ch z;CB&o7&&?c5e>h`IM;Wnha0QKnEp=$hA8TJgR-07N~U5(>9vJzeoFsSRBkDq=x(YgEMpb=l4TDD`2 zwVJpWGTA_u7}?ecW7s6%rUs&NXD3+n;jB86`X?8(l3MBo6)PdakI6V6a}22{)8ilT zM~T*mU}__xSy|6XSrJ^%lDAR3Lft%+yxC|ZUvSO_nqMX!_ul3;R#*{~4DA=h$bP)%8Yv9X zyp><|e8=_ttI}ZAwOd#dlnSjck#6%273{E$kJuCGu=I@O)&6ID{nWF5@gLb16sj|&Sb~+du4e4O_%_o`Ix4NRrAsyr1_}MuP94s>de8cH-OUkVPk3+K z&jW)It9QiU-ti~AuJkL`XMca8Oh4$SyJ=`-5WU<{cIh+XVH#e4d&zive_UHC!pN>W z3TB;Mn5i)9Qn)#6@lo4QpI3jFYc0~+jS)4AFz8fVC;lD^+idw^S~Qhq>Tg(!3$yLD zzktzoFrU@6s4wwCMz}edpF5i5Q1IMmEJQHzp(LAt)pgN3&O!&d?3W@6U4)I^2V{;- z6A(?zd93hS*uQmnh4T)nHnE{wVhh(=MMD(h(P4+^p83Om6t<*cUW>l(qJzr%5vp@K zN27ka(L{JX=1~e2^)F^i=TYj&;<7jyUUR2Bek^A8+3Up*&Xwc{)1nRR5CT8vG>ExV zHnF3UqXJOAno_?bnhCX-&kwI~Ti8t4`n0%Up>!U`ZvK^w2+0Cs-b9%w%4`$+To|k= zKtgc&l}P`*8IS>8DOe?EB84^kx4BQp3<7P{Pq}&p%xF_81pg!l2|u=&I{AuUgmF5n zJQCTLv}%}xbFGYtKfbba{CBo)lWW%Z>i(_NvLhoQZ*5-@2l&x>e+I~0Nld3UI9tdL zRzu8}i;X!h8LHVvN?C+|M81e>Jr38%&*9LYQec9Ax>?NN+9(_>XSRv&6hlCYB`>Qm z1&ygi{Y()OU4@D_jd_-7vDILR{>o|7-k)Sjdxkjgvi{@S>6GqiF|o`*Otr;P)kLHN zZkpts;0zw_6;?f(@4S1FN=m!4^mv~W+lJA`&7RH%2$)49z0A+8@0BCHtj|yH--AEL z0tW6G%X-+J+5a{5*WKaM0QDznf;V?L5&uQw+yegDNDP`hA;0XPYc6e0;Xv6|i|^F2WB)Z$LR|HR4 zTQsRAby9(^Z@yATyOgcfQw7cKyr^3Tz7lc7+JEwwzA7)|2x+PtEb>nD(tpxJQm)Kn zW9K_*r!L%~N*vS8<5T=iv|o!zTe9k_2jC_j*7ik^M_ zaf%k{WX{-;0*`t`G!&`eW;gChVXnJ-Rn)To8vW-?>>a%QU1v`ZC=U)f8iA@%JG0mZ zDqH;~mgBnrCP~1II<=V9;EBL)J+xzCoiRBaeH&J6rL!{4zIY8tZka?_FBeQeNO3q6 zyG_alW54Ba&wQf{&F1v-r1R6ID)PTsqjIBc+5MHkcW5Fnvi~{-FjKe)t1bl}Y;z@< z=!%zvpRua>>t_x}^}z0<7MI!H2v6|XAyR9!t50q-A)xk0nflgF4*OQlCGK==4S|wc zRMsSscNhRzHMBU8TdcHN!q^I}x0iXJ%uehac|Zs_B$p@CnF)HeXPpB_Za}F{<@6-4 zl%kml@}kHQ(ypD8FsPJ2=14xXJE|b20RUIgs!2|R3>LUMGF6X*B_I|$`Qg=;zm7C z{mEDy9dTmPbued7mlO@phdmAmJ7p@GR1bjCkMw6*G7#4+`k>fk1czdJUB!e@Q(~6# zwo%@p@V5RL0ABU2LH7Asq^quDUho@H>eTZH9f*no9fY0T zD_-9px3e}A!>>kv5wk91%C9R1J_Nh!*&Kk$J3KNxC}c_@zlgpJZ+5L)Nw|^p=2ue}CJtm;uj*Iqr)K})kA$xtNUEvX;4!Px*^&9T_`IN{D z{6~QY=Nau6EzpvufB^hflc#XIsSq0Y9(nf$d~6ZwK}fal92)fr%T3=q{0mP-EyP_G z)UR5h@IX}3Qll2b0oCAcBF>b*@Etu*aTLPU<%C>KoOrk=x?pN!#f_Og-w+;xbFgjQ zXp`et%lDBBh~OcFnMKMUoox0YwBNy`N0q~bSPh@+enQ=4RUw1) zpovN`QoV>vZ#5LvC;cl|6jPr}O5tu!Ipoyib8iXqy}TeJ;4+_7r<1kV0v5?Kv>fYp zg>9L`;XwXa&W7-jf|9~uP2iyF5`5AJ`Q~p4eBU$MCC00`rcSF>`&0fbd^_eqR+}mK z4n*PMMa&FOcc)vTUR zlDUAn-mh`ahi_`f`=39JYTNVjsTa_Y3b1GOIi)6dY)D}xeshB0T8Eov5%UhWd1)u}kjEQ|LDo{tqKKrYIfVz~@dp!! zMOnah@vp)%_-jDTUG09l+;{CkDCH|Q{NqX*uHa1YxFShy*1+;J`gywKaz|2Q{lG8x zP?KBur`}r`!WLKXY_K;C8$EWG>jY3UIh{+BLv0=2)KH%P}6xE2kg)%(-uA6lC?u8}{K(#P*c zE9C8t*u%j2r_{;Rpe1A{9nNXU;b_N0vNgyK!EZVut~}+R2rcbsHilqsOviYh-pYX= zHw@53nlmwYI5W5KP>&`dBZe0Jn?nAdC^HY1wlR6$u^PbpB#AS&5L6zqrXN&7*N2Q` z+Rae1EwS)H=aVSIkr8Ek^1jy2iS2o7mqm~Mr&g5=jjt7VxwglQ^`h#Mx+x2v|9ZAwE$i_9918MjJxTMr?n!bZ6n$}y11u8I9COTU`Z$Fi z!AeAQLMw^gp_{+0QTEJrhL424pVDp%wpku~XRlD3iv{vQ!lAf!_jyqd_h}+Tr1XG| z`*FT*NbPqvHCUsYAkFnM`@l4u_QH&bszpUK#M~XLJt{%?00GXY?u_{gj3Hvs!=N(I z(=AuWPijyoU!r?aFTsa8pLB&cx}$*%;K$e*XqF{~*rA-qn)h^!(-;e}O#B$|S~c+U zN4vyOK0vmtx$5K!?g*+J@G1NmlEI=pyZXZ69tAv=@`t%ag_Hk{LP~OH9iE)I= zaJ69b4kuCkV0V zo(M0#>phpQ_)@j;h%m{-a*LGi(72TP)ws2w*@4|C-3+;=5DmC4s7Lp95%n%@Ko zfdr3-a7m*dys9iIci$A=4NPJ`HfJ;hujLgU)ZRuJI`n;Pw|yksu!#LQnJ#dJysgNb z@@qwR^wrk(jbq4H?d!lNyy72~Dnn87KxsgQ!)|*m(DRM+eC$wh7KnS-mho3|KE)7h zK3k;qZ;K1Lj6uEXLYUYi)1FN}F@-xJ z@@3Hb84sl|j{4$3J}aTY@cbX@pzB_qM~APljrjju6P0tY{C@ zpUCOz_NFmALMv1*blCcwUD3?U6tYs+N%cmJ98D%3)%)Xu^uvzF zS5O!sc#X6?EwsYkvPo6A%O8&y8sCCQH<%f2togVwW&{M;PR!a(ZT_A+jVAbf{@5kL zB@Z(hb$3U{T_}SKA_CoQVU-;j>2J=L#lZ~aQCFg-d<9rzs$_gO&d5N6eFSc z1ml8)P*FSi+k@!^M9nDWR5e@ATD8oxtDu=36Iv2!;dZzidIS(PCtEuXAtlBb1;H%Z zwnC^Ek*D)EX4#Q>R$$WA2sxC_t(!!6Tr?C#@{3}n{<^o;9id1RA&-Pig1e-2B1XpG zliNjgmd3c&%A}s>qf{_j#!Z`fu0xIwm4L0)OF=u(OEmp;bLCIaZX$&J_^Z%4Sq4GZ zPn6sV_#+6pJmDN_lx@1;Zw6Md_p0w9h6mHtzpuIEwNn>OnuRSC2=>fP^Hqgc)xu^4 z<3!s`cORHJh#?!nKI`Et7{3C27+EuH)Gw1f)aoP|B3y?fuVfvpYYmmukx0ya-)TQX zR{ggy5cNf4X|g)nl#jC9p>7|09_S7>1D2GTRBUTW zAkQ=JMRogZqG#v;^=11O6@rPPwvJkr{bW-Qg8`q8GoD#K`&Y+S#%&B>SGRL>;ZunM@49!}Uy zN|bBCJ%sO;@3wl0>0gbl3L@1^O60ONObz8ZI7nder>(udj-jt`;yj^nTQ$L9`OU9W zX4alF#$|GiR47%x@s&LV>2Sz2R6?;2R~5k6V>)nz!o_*1Y!$p>BC5&?hJg_MiE6UBy>RkVZj`9UWbRkN-Hk!S`=BS3t3uyX6)7SF#)71*}`~Ogz z1rap5H6~dhBJ83;q-Y<5V35C2&F^JI-it(=5D#v!fAi9p#UwV~2tZQI+W(Dv?1t9? zfh*xpxxO{-(VGB>!Q&0%^YW_F!@aZS#ucP|YaD#>wd1Fv&Z*SR&mc;asi}1G) z_H>`!akh-Zxq9#io(7%;a$)w+{QH)Y$?UK1Dt^4)up!Szcxnu}kn$0afcfJL#IL+S z5gF_Y30j;{lNrG6m~$Ay?)*V9fZuU@3=kd40=LhazjFrau>(Y>SJNtOz>8x_X-BlA zIpl{i>OarVGj1v(4?^1`R}aQB&WCRQzS~;7R{tDZG=HhgrW@B`W|#cdyj%YBky)P= zpxuOZkW>S6%q7U{VsB#G(^FMsH5QuGXhb(sY+!-R8Bmv6Sx3WzSW<1MPPN1!&PurYky(@`bP9tz z52}LH9Q?+FF5jR6-;|+GVdRA!qtd;}*-h&iIw3Tq3qF9sDIb1FFxGbo&fbG5n8$3F zyY&PWL{ys^dTO}oZ#@sIX^BKW*bon=;te9j5k+T%wJ zNJtoN1~YVj4~YRrlZl)b&kJqp+Z`DqT!la$x&&IxgOQw#yZd-nBP3!7FijBXD|IsU8Zl^ zc6?MKpJQ+7ka|tZQLfchD$PD|;K(9FiLE|eUZX#EZxhG!S-63C$jWX1Yd!6-Yxi-u zjULIr|0-Q%D9jz}IF~S%>0(jOqZ(Ln<$9PxiySr&2Oic7vb<8q=46)Ln%Z|<*z5&> z3f~Zw@m;vR(bESB<=Jqkxn(=#hQw42l(7)h`vMQQTttz9XW6^|^8EK7qhju4r_c*b zJIi`)MB$w@9epwdIfnEBR+?~);yd6C(LeMC& zn&&N*?-g&BBJcV;8&UoZi4Lmxcj16ojlxR~zMrf=O_^i1wGb9X-0@6_rpjPYemIin zmJb+;lHe;Yp=8G)Q(L1bzH*}I>}uAqhj4;g)PlvD9_e_ScR{Ipq|$8NvAvLD8MYr}xl=bU~)f%B3E>r3Bu9_t|ThF3C5~BdOve zEbk^r&r#PT&?^V1cb{72yEWH}TXEE}w>t!cY~rA+hNOTK8FAtIEoszp!qqptS&;r$ zaYV-NX96-h$6aR@1xz6_E0^N49mU)-v#bwtGJm)ibygzJ8!7|WIrcb`$XH~^!a#s& z{Db-0IOTFq#9!^j!n_F}#Z_nX{YzBK8XLPVmc&X`fT7!@$U-@2KM9soGbmOSAmqV z{nr$L^MBo_u^Joyf0E^=eo{Rt0{{e$IFA(#*kP@SQd6lWT2-#>` zP1)7_@IO!9lk>Zt?#CU?cuhiLF&)+XEM9B)cS(gvQT!X3`wL*{fArTS;Ak`J<84du zALKPz4}3nlG8Fo^MH0L|oK2-4xIY!~Oux~1sw!+It)&D3p;+N8AgqKI`ld6v71wy8I!eP0o~=RVcFQR2Gr(eP_JbSytoQ$Yt}l*4r@A8Me94y z8cTDWhqlq^qoAhbOzGBXv^Wa4vUz$(7B!mX`T=x_ueKRRDfg&Uc-e1+z4x$jyW_Pm zp?U;-R#xt^Z8Ev~`m`iL4*c#65Nn)q#=Y0l1AuD&+{|8-Gsij3LUZXpM0Bx0u7WWm zH|%yE@-#XEph2}-$-thl+S;__ciBxSSzHveP%~v}5I%u!z_l_KoW{KRx2=eB33umE zIYFtu^5=wGU`Jab8#}cnYry@9p5UE#U|VVvx_4l49JQ;jQdp(uw=$^A$EA$LM%vmE zvdEOaIcp5qX8wX{mYf0;#51~imYYPn4=k&#DsKTxo{_Mg*;S495?OBY?#gv=edYC* z^O@-sd-qa+U24xvcbL0@C7_6o!$`)sVr-jSJE4XQUQ$?L7}2(}Eixqv;L8AdJAVqc zq}RPgpnDb@E_;?6K58r3h4-!4rT4Ab#rLHLX?eMOfluJk=3i1@Gt1i#iA=O`M0@x! z(HtJP9BMHXEzuD93m|B&woj0g6T?f#^)>J>|I4C5?Gam>n9!8CT%~aT;=oco5d6U8 zMXl(=W;$ND_8+DD*?|5bJ!;8ebESXMUKBAf7YBwNVJibGaJ*(2G`F%wx)grqVPjudiaq^Kl&g$8A2 zWMxMr@_$c}d+;_B`#kUX-t|4VKH&_f^^EP0&=DPLW)H)UzBG%%Tra*5 z%$kyZe3I&S#gfie^z5)!twG={3Cuh)FdeA!Kj<-9** zvT*5%Tb`|QbE!iW-XcOuy39>D3oe6x{>&<#E$o8Ac|j)wq#kQzz|ATd=Z0K!p2$QE zPu?jL8Lb^y3_CQE{*}sTDe!2!dtlFjq&YLY@2#4>XS`}v#PLrpvc4*@q^O{mmnr5D zmyJq~t?8>FWU5vZdE(%4cuZuao0GNjp3~Dt*SLaxI#g_u>hu@k&9Ho*#CZP~lFJHj z(e!SYlLigyc?&5-YxlE{uuk$9b&l6d`uIlpg_z15dPo*iU&|Khx2*A5Fp;8iK_bdP z?T6|^7@lcx2j0T@x>X7|kuuBSB7<^zeY~R~4McconTxA2flHC0_jFxmSTv-~?zVT| zG_|yDqa9lkF*B6_{j=T>=M8r<0s;@z#h)3BQ4NLl@`Xr__o7;~M&dL3J8fP&zLfDfy z);ckcTev{@OUlZ`bCo(-3? z1u1xD`PKgSg?RqeVVsF<1SLF;XYA@Bsa&cY!I48ZJn1V<3d!?s=St?TLo zC0cNr`qD*M#s6f~X>SCNVkva^9A2ZP>CoJ9bvgXe_c}WdX-)pHM5m7O zrHt#g$F0AO+nGA;7dSJ?)|Mo~cf{z2L)Rz!`fpi73Zv)H=a5K)*$5sf_IZypi($P5 zsPwUc4~P-J1@^3C6-r9{V-u0Z&Sl7vNfmuMY4yy*cL>_)BmQF!8Om9Dej%cHxbIzA zhtV0d{=%cr?;bpBPjt@4w=#<>k5ee=TiWAXM2~tUGfm z$s&!Dm0R^V$}fOR*B^kGaipi~rx~A2cS0;t&khV1a4u38*XRUP~f za!rZMtay8bsLt6yFYl@>-y^31(*P!L^^s@mslZy(SMsv9bVoX`O#yBgEcjCmGpyc* zeH$Dw6vB5P*;jor+JOX@;6K#+xc)Z9B8M=x2a@Wx-{snPGpRmOC$zpsqW*JCh@M2Y z#K+M(>=#d^>Of9C`))h<=Bsy)6zaMJ&x-t%&+UcpLjV`jo4R2025 zXaG8EA!0lQa)|dx-@{O)qP6`$rhCkoQqZ`^SW8g-kOwrwsK8 z3ms*AIcyj}-1x&A&vSq{r=QMyp3CHdWH35!sad#!Sm>^|-|afB+Q;|Iq@LFgqIp#Z zD1%H+3I?6RGnk&IFo|u+E0dCxXz4yI^1i!QTu7uvIEH>i3rR{srcST`LIRwdV1P;W z+%AN1NIf@xxvVLiSX`8ILA8MzNqE&7>%jMzGt9wm78bo9<;h*W84i29^w!>V>{N+S zd`5Zmz^G;f=icvoOZfK5#1ctx*~UwD=ab4DGQXehQ!XYnak*dee%YN$_ZPL%KZuz$ zD;$PpT;HM^$KwtQm@7uvT`i6>Hae1CoRVM2)NL<2-k2PiX=eAx+-6j#JI?M}(tuBW zkF%jjLR)O`gI2fcPBxF^HeI|DWwQWHVR!;;{BXXHskxh8F@BMDn`oEi-NHt;CLymW z=KSv5)3dyzec0T5B*`g-MQ<;gz=nIWKUi9ko<|4I(-E0k$QncH>E4l z**1w&#={&zv4Tvhgz#c29`m|;lU-jmaXFMC11 z*dlXDMEOG>VoLMc>!rApwOu2prKSi*!w%`yzGmS+k(zm*CsLK*wv{S_0WX^8A-rKy zbk^Gf_92^7iB_uUF)EE+ET4d|X|>d&mdN?x@vxKAQk`O+r4Qdu>XGy(a(19g;=jU} zFX{O*_NG>!$@jh!U369Lnc+D~qch3uT+_Amyi}*k#LAAwh}k8IPK5a-WZ81ufD>l> z$4cF}GSz>ce`3FAic}6W4Z7m9KGO?(eWqi@L|5Hq0@L|&2flN1PVl}XgQ2q*_n2s3 zt5KtowNkTYB5b;SVuoXA@i5irXO)A&%7?V`1@HGCB&)Wgk+l|^XXChq;u(nyPB}b3 zY>m5jkxpZgi)zfbgv&ec4Zqdvm+D<?Im*mXweS9H+V>)zF#Zp3)bhl$PbISY{5=_z!8&*Jv~NYtI-g!>fDs zmvL5O^U%!^VaKA9gvKw|5?-jk>~%CVGvctKmP$kpnpfN{D8@X*Aazi$txfa%vd-|E z>kYmV66W!lNekJPom29LdZ%(I+ZLZYTXzTg*to~m?7vp%{V<~>H+2}PQ?PPAq`36R z<%wR8v6UkS>Wt#hzGk#44W<%9S=nBfB);6clKwnxY}T*w21Qc3_?IJ@4gYzC7s;WP zVQNI(M=S=JT#xsZy7G`cR(BP9*je0bfeN8JN5~zY(DDs0t{LpHOIbN);?T-69Pf3R zSNe*&p2%AwXHL>__g+xd4Hlc_vu<25H?(`nafS%)3UPP7_4;gk-9ckt8SJRTv5v0M z_Hww`qPudL?ajIR&X*;$y-`<)6dxx1U~5eGS13CB!lX;3w7n&lDDiArbAhSycd}+b zya_3p@A`$kQy;|NJZ~s44Hqo7Hwt}X86NK=(ey>lgWTtGL6k@Gy;PbO!M%1~Wcn2k zUFP|*5d>t-X*RU8g%>|(wwj*~#l4z^Aatf^DWd1Wj#Q*AY0D^V@sC`M zjJc6qXu0I7Y*2;;gGu!plAFzG=J;1%eIOdn zQA>J&e05UN*7I5@yRhK|lbBSfJ+5Uq;!&HV@xfPZrgD}kE*1DSq^=%{o%|LChhl#0 zlMb<^a6ixzpd{kNZr|3jTGeEzuo}-eLT-)Q$#b{!vKx8Tg}swCni>{#%vDY$Ww$84 zew3c9BBovqb}_&BRo#^!G(1Eg((BScRZ}C)Oz?y`T5wOrv);)b^4XR8 zhJo7+<^7)qB>I;46!GySzdneZ>n_E1oWZY;kf94#)s)kWjuJN1c+wbVoNQcmnv}{> zN0pF+Sl3E}UQ$}slSZeLJrwT>Sr}#V(dVaezCQl2|4LN`7L7v&siYR|r7M(*JYfR$ zst3=YaDw$FSc{g}KHO&QiKxuhEzF{f%RJLKe3p*7=oo`WNP)M(9X1zIQPP0XHhY3c znrP{$4#Ol$A0s|4S7Gx2L23dv*Gv2o;h((XVn+9+$qvm}s%zi6nI-_s6?mG! zj{DV;qesJb&owKeEK?=J>UcAlYckA7Sl+I&IN=yasrZOkejir*kE@SN`fk<8Fgx*$ zy&fE6?}G)d_N`){P~U@1jRVA|2*69)KSe_}!~?+`Yb{Y=O~_+@!j<&oVQQMnhoIRU zA0CyF1OFfkK44n*JD~!2!SCPM;PRSk%1XL=0&rz00wxPs&-_eapJy#$h!eqY%nS0{ z!aGg58JIJPF3_ci%n)QSVpa2H`vIe$RD43;#IRfDV&Ibit z+?>HW4{2wOfC6Fw)}4x}i1maDxcE1qi@BS*qcxD2gE@h3#4cgU*D-&3z7D|tVZWt= z-Cy2+*Cm@P4GN_TPUtaVyVesbVDazF@)j8VJ4>XZv!f%}&eO1SvIgr}4`A*3#vat< z_MoByL(qW6L7SFZ#|Gc1fFN)L2PxY+{B8tJp+pxRyz*87)vXR}*=&ahXjBlQKguuf zX6x<<6fQulE^C*KH8~W%ptpaC0l?b=_{~*U4?5Vt;dgM4t_{&UZ1C2j?b>b+5}{IF_CUyvz-@QZPMlJ)r_tS$9kH%RPv#2_nMb zRLj5;chJ72*U`Z@Dqt4$@_+k$%|8m(HqLG!qT4P^DdfvGf&){gKnGCX#H0!;W=AGP zbA&Z`-__a)VTS}kKFjWGk z%|>yE?t*EJ!qeQ%dPk$;xIQ+P0;()PCBDgjJm6Buj{f^awNoVx+9<|lg3%-$G(*f) zll6oOkN|yamn1uyl2*N-lnqRI1cvs_JxLTeahEK=THV$Sz*gQhKNb*p0fNoda#-&F zB-qJgW^g}!TtM|0bS2QZekW7_tKu%GcJ!4?lObt0z_$mZ4rbQ0o=^curCs3bJK6sq z9fu-aW-l#>z~ca(B;4yv;2RZ?tGYAU)^)Kz{L|4oPj zdOf_?de|#yS)p2v8-N||+XL=O*%3+y)oI(HbM)Ds?q8~HPzIP(vs*G`iddbWq}! z(2!VjP&{Z1w+%eUq^ /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -133,22 +132,29 @@ 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. + if ! command -v java >/dev/null 2>&1 + then + 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 fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -165,7 +171,6 @@ fi # 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" ) @@ -193,18 +198,27 @@ if "$cygwin" || "$msys" ; then 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. + +# 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"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/backend-kt/gradlew.bat b/backend-kt/gradlew.bat index 107acd3..c4bdd3a 100644 --- a/backend-kt/gradlew.bat +++ b/backend-kt/gradlew.bat @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +27,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 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. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,32 +59,33 @@ 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. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 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 %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 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 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/PastefyProperties.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/PastefyProperties.kt index 4370a2e..fb4a8a4 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/PastefyProperties.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/PastefyProperties.kt @@ -110,6 +110,7 @@ data class PastefyProperties( ) { data class Migrations( var enabled: Boolean = false, + var repairFailedHistory: Boolean = false, ) } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/AppController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/AppController.kt index 12df55f..8f1b04c 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/AppController.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/AppController.kt @@ -5,6 +5,7 @@ import de.interaapps.pastefy.dto.app.AppInfoResponse import de.interaapps.pastefy.infrastructure.ai.PasteAI import de.interaapps.pastefy.infrastructure.analytics.AnalyticsService import org.springframework.beans.factory.ObjectProvider +import org.springframework.cache.annotation.Cacheable import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -17,6 +18,7 @@ class AppController( private val analyticsProvider: ObjectProvider, ) { @GetMapping("/info") + @Cacheable("app-info") fun appInfo(): AppInfoResponse = AppInfoResponse( customLogo = properties.customLogo?.takeIf(String::isNotBlank), customName = properties.customName?.takeIf(String::isNotBlank), diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteRawController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteRawController.kt index d0a0581..9891865 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteRawController.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteRawController.kt @@ -37,17 +37,24 @@ class PasteRawController( request: HttpServletRequest, ): ResponseEntity { val paste = pasteService.get(id) ?: return text("404 - Paste not found", HttpStatus.NOT_FOUND) + pasteService.getAccessiblePasteOrFail(id, user) + if (paste.isPublic) engagement.addInterest(paste, 1) + analyticsProvider.ifAvailable?.track(request, paste, user, AnalyticsService.VisitType.RAW) + val content = pasteService.getContent(paste).orEmpty() + val part = request.getParameter("part") + if (part != null && paste.type == PasteType.MULTI_PASTE) { val contents = objectMapper.readValue(content, object : TypeReference>() {}) .firstOrNull { it.name == part }?.contents ?: return text("404 - Paste part not found", HttpStatus.NOT_FOUND) return text(contents) } + return text(content) } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/TagsController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/TagsController.kt index de41bbd..0d09dbf 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/TagsController.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/TagsController.kt @@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import de.interaapps.pastefy.repositories.TagListingRepository import de.interaapps.pastefy.service.TagListingService +import org.springframework.cache.annotation.Cacheable import org.springframework.data.domain.PageRequest @RestController @@ -19,6 +20,7 @@ class TagsController( private val tags: TagListingService, ) { @GetMapping + @Cacheable("public-tags") fun getTags( @RequestParam("search") search: String? = null, @RequestParam("page", defaultValue = "1") page: Int, @@ -35,5 +37,6 @@ class TagsController( } @GetMapping("/{tag}") + @Cacheable("public-tag", key = "#tag") fun getTag(@PathVariable tag: String): TagListing = tags.getOrCreate(tag) } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteMetaSSRController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/PasteMetaSSRController.kt similarity index 91% rename from backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteMetaSSRController.kt rename to backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/PasteMetaSSRController.kt index 73d30d2..917fac0 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteMetaSSRController.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/PasteMetaSSRController.kt @@ -1,4 +1,4 @@ -package de.interaapps.pastefy.controller.pastes +package de.interaapps.pastefy.controller.seo import de.interaapps.pastefy.config.PastefyProperties import de.interaapps.pastefy.repositories.PasteAIInfoRepository @@ -25,11 +25,11 @@ class PasteMetaSSRController( ) { @GetMapping("/{id}") fun getPasteMetaSSR(@PathVariable id: String): ResponseEntity { - if (!id.matches(Regex("^[A-Za-z0-9_-]{8}$"))) return frontend() + if (!id.matches(Regex("^[A-Za-z0-9_-]{8}$"))) return frontendIndex.frontend() - val paste = pasteService.get(id) ?: return frontend() + val paste = pasteService.get(id) ?: return frontendIndex.frontend() - if (paste.isPrivate || paste.encrypted) return frontend() + if (paste.isPrivate || paste.encrypted) return frontendIndex.frontend() val aiInfo = paste.id?.let(aiInfoRepository::findById)?.orElse(null) @@ -79,12 +79,7 @@ class PasteMetaSSRController( return seo.render(page)?.let { ResponseEntity.ok().contentType(MediaType("text", "html", Charsets.UTF_8)).body(it) - } ?: frontend() - } - - private fun frontend(): ResponseEntity { - val html = frontendIndex.html ?: return ResponseEntity.notFound().build() - return ResponseEntity.ok().contentType(MediaType("text", "html", Charsets.UTF_8)).body(html) + } ?: frontendIndex.frontend() } private fun seoContent( @@ -117,4 +112,4 @@ class PasteMetaSSRController( } private data class Author(val displayName: String, val username: String, val profileUrl: String) -} +} \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/TagMetaController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/TagMetaSSRController.kt similarity index 79% rename from backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/TagMetaController.kt rename to backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/TagMetaSSRController.kt index d469ec3..9fd160a 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/TagMetaController.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/TagMetaSSRController.kt @@ -1,6 +1,7 @@ package de.interaapps.pastefy.controller.seo import de.interaapps.pastefy.config.PastefyProperties +import de.interaapps.pastefy.service.FrontendIndexService import de.interaapps.pastefy.service.SeoRenderer import de.interaapps.pastefy.service.TagListingService import jakarta.servlet.http.HttpServletRequest @@ -13,14 +14,15 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/tags") -class TagMetaController( +class TagMetaSSRController( private val properties: PastefyProperties, private val tags: TagListingService, private val seo: SeoRenderer, + private val frontendIndex: FrontendIndexService, ) { @GetMapping("/{tag}") fun tagMeta(@PathVariable tag: String, request: HttpServletRequest): ResponseEntity { - if (!properties.publicPastesEnabled || tag.isBlank()) return ResponseEntity.notFound().build() + if (!properties.publicPastesEnabled || tag.isBlank()) return frontendIndex.frontend() val listing = tags.getOrCreate(tag) val name = seo.truncate(listing.displayName?.takeIf(String::isNotBlank)?.trim() ?: listing.tag, 120) val description = seo.truncate( @@ -31,8 +33,13 @@ class TagMetaController( val page = seo.page("/tags/${seo.pathSegment(listing.tag)}", "$name | Pastefy", description) .content("

${seo.escapeHtml(name)}

${seo.escapeHtml(description)}

Public pastes: ${listing.pasteCount}

") .image(listing.imageUrl) - return seo.render(page)?.let { - ResponseEntity.ok().contentType(MediaType("text", "html", Charsets.UTF_8)).body(it) - } ?: ResponseEntity.notFound().build() + + return seo.render(page) + ?.let { + ResponseEntity + .ok() + .contentType(MediaType("text", "html", Charsets.UTF_8)) + .body(it) + } ?: frontendIndex.frontend() } } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/stats/StatsController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/stats/StatsController.kt index 6862598..4c87bea 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/stats/StatsController.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/stats/StatsController.kt @@ -23,6 +23,7 @@ class StatsController( if (!properties.publicStats && authKey?.hasPermission("stats:read") != true && user?.isAdmin != true) { throw PermissionsDeniedException() } + return statsService.get() } } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsConfiguration.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsConfiguration.kt index 677eb3d..fbb9dc5 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsConfiguration.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsConfiguration.kt @@ -4,16 +4,17 @@ import de.interaapps.pastefy.config.PastefyProperties import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration -import org.springframework.beans.factory.annotation.Qualifier import org.springframework.jdbc.core.JdbcTemplate import org.springframework.jdbc.datasource.DriverManagerDataSource -import javax.sql.DataSource @Configuration @ConditionalOnProperty(prefix = "pastefy.analytics", name = ["enabled"], havingValue = "true") class AnalyticsConfiguration { @Bean - fun analyticsDataSource(properties: PastefyProperties): DataSource { + fun analyticsJdbcTemplate(properties: PastefyProperties): JdbcTemplate = + JdbcTemplate(analyticsDataSource(properties)) + + private fun analyticsDataSource(properties: PastefyProperties): DriverManagerDataSource { require(properties.analytics.jdbcUrl.isNotBlank()) { "pastefy.analytics.jdbc-url is required when analytics are enabled" } require(properties.analytics.ipHashSalt.isNotBlank()) { "pastefy.analytics.ip-hash-salt is required when analytics are enabled" } return DriverManagerDataSource().apply { @@ -23,8 +24,4 @@ class AnalyticsConfiguration { password = properties.analytics.password } } - - @Bean - fun analyticsJdbcTemplate(@Qualifier("analyticsDataSource") analyticsDataSource: DataSource): JdbcTemplate = - JdbcTemplate(analyticsDataSource) } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/ClickHouseFlywayConfiguration.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/ClickHouseFlywayConfiguration.kt index a5832a6..458d683 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/ClickHouseFlywayConfiguration.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/ClickHouseFlywayConfiguration.kt @@ -10,9 +10,9 @@ import org.springframework.jdbc.datasource.DriverManagerDataSource @Configuration @ConditionalOnProperty(prefix = "pastefy.analytics.migrations", name = ["enabled"], havingValue = "true") class ClickHouseFlywayConfiguration { - @Bean(initMethod = "migrate") - fun clickHouseFlyway(properties: PastefyProperties): Flyway = - Flyway.configure() + @Bean + fun clickHouseFlyway(properties: PastefyProperties): Flyway { + val flyway = Flyway.configure() .dataSource( DriverManagerDataSource().apply { require(properties.analytics.jdbcUrl.isNotBlank()) { @@ -33,6 +33,12 @@ class ClickHouseFlywayConfiguration { ), ) .load() + if (properties.analytics.migrations.repairFailedHistory) { + flyway.repair() + } + flyway.migrate() + return flyway + } private fun identifier(value: String): String = value.takeIf { it.matches(Regex("[A-Za-z_][A-Za-z0-9_]*")) } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteDocument.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteDocument.kt index 92a2079..e2d5f88 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteDocument.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteDocument.kt @@ -4,7 +4,10 @@ import de.interaapps.pastefy.enums.PasteType import de.interaapps.pastefy.enums.PasteVisibility import de.interaapps.pastefy.enums.StorageType import org.springframework.data.annotation.Id +import org.springframework.data.elasticsearch.annotations.DateFormat import org.springframework.data.elasticsearch.annotations.Document +import org.springframework.data.elasticsearch.annotations.Field +import org.springframework.data.elasticsearch.annotations.FieldType import org.springframework.data.elasticsearch.annotations.WriteTypeHint import java.time.Instant @@ -22,9 +25,16 @@ data class ElasticPasteDocument( val folder: String? = null, val type: PasteType = PasteType.PASTE, val visibility: PasteVisibility = PasteVisibility.UNLISTED, + + @Field(type = FieldType.Date, format = [DateFormat.epoch_millis]) val expireAt: Instant? = null, + + @Field(type = FieldType.Date, format = [DateFormat.epoch_millis]) val createdAt: Instant? = null, + + @Field(type = FieldType.Date, format = [DateFormat.epoch_millis]) val updatedAt: Instant? = null, + val storageType: StorageType = StorageType.DATABASE, val version: Int = 0, val engagementScore: Int = 0, diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/seeding/LocalTestingSeeder.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/seeding/LocalTestingSeeder.kt index 67540ff..d2c2dd3 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/seeding/LocalTestingSeeder.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/seeding/LocalTestingSeeder.kt @@ -30,6 +30,7 @@ import de.interaapps.pastefy.repositories.PublicPasteEngagementRepository import de.interaapps.pastefy.repositories.SharedPasteRepository import de.interaapps.pastefy.repositories.TagListingRepository import de.interaapps.pastefy.repositories.UserRepository +import de.interaapps.pastefy.service.PasteService import org.slf4j.LoggerFactory import org.springframework.boot.ApplicationArguments import org.springframework.boot.ApplicationRunner @@ -48,6 +49,7 @@ class LocalTestingSeeder( private val authKeys: AuthKeyRepository, private val folders: FolderRepository, private val pastes: PasteRepository, + private val pastesService: PasteService, private val pasteTags: PasteTagRepository, private val pasteStars: PasteStarRepository, private val comments: PasteCommentRepository, @@ -85,7 +87,7 @@ class LocalTestingSeeder( type = User.Type.ADMIN, provider = User.AuthenticationProvider.INTERAAPPS, authId = "seed-interaapps-admin", - avatar = "https://pastefy.local/avatars/admin.png", + avatar = "https://accounts.interaapps.de/avatars/A.png", ) state.user = user( id = USER_ID, @@ -95,7 +97,7 @@ class LocalTestingSeeder( type = User.Type.USER, provider = User.AuthenticationProvider.GITHUB, authId = "seed-github-user", - avatar = "https://pastefy.local/avatars/user.png", + avatar = "https://accounts.interaapps.de/avatars/U.png", ) state.blocked = user( id = BLOCKED_ID, @@ -136,7 +138,7 @@ class LocalTestingSeeder( type = User.Type.USER, provider = providers[index % providers.size], authId = "seed-generated-user-$number", - avatar = "https://pastefy.local/avatars/user-$number.png", + avatar = "https://accounts.interaapps.de/avatars/${number.toString().substring(1)}.png", ) } } @@ -185,7 +187,7 @@ class LocalTestingSeeder( private fun seedPastes(state: SeedState) { state.publicPaste = paste( key = PUBLIC_PASTE_KEY, - title = "Public Kotlin example", + title = "Public Kotlin example.kt", content = """ fun main() { println("Hello from Pastefy seed data") @@ -205,7 +207,7 @@ class LocalTestingSeeder( ) state.unlistedPaste = paste( key = UNLISTED_PASTE_KEY, - title = "Unlisted JSON fixture", + title = "Unlisted JSON fixture.json", content = """{"environment":"local","seeded":true,"items":[1,2,3]}""", userId = ADMIN_ID, folder = ADMIN_FOLDER_KEY, @@ -384,19 +386,18 @@ class LocalTestingSeeder( ): Paste { val existing = pastes.findByKey(key) if (existing != null) return existing - return pastes.save( - Paste( - key = key, - title = title, - userId = userId, - folder = folder, - encrypted = encrypted, - type = type, - visibility = visibility, - storageType = StorageType.DATABASE, - expireAt = expireAt, - ).apply { setDatabaseContent(content) } - ) + + return pastesService.save(Paste( + key = key, + title = title, + userId = userId, + folder = folder, + encrypted = encrypted, + type = type, + visibility = visibility, + storageType = StorageType.DATABASE, + expireAt = expireAt, + ).apply { setDatabaseContent(content) }) } private fun tags(paste: String, vararg tags: String) { diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/FrontendIndexService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/FrontendIndexService.kt index fb1c09e..211c55c 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/FrontendIndexService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/FrontendIndexService.kt @@ -2,7 +2,10 @@ package de.interaapps.pastefy.service import de.interaapps.pastefy.config.PastefyProperties import org.slf4j.LoggerFactory +import org.springframework.cache.annotation.Cacheable import org.springframework.core.io.ClassPathResource +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity import org.springframework.stereotype.Service @Service @@ -21,6 +24,12 @@ class FrontendIndexService( LOGGER.warn("Unable to load static/index.html for frontend serving", it) }.getOrNull() + @Cacheable + fun frontend(): ResponseEntity { + val html = html ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok().contentType(MediaType("text", "html", Charsets.UTF_8)).body(html) + } + companion object { private val LOGGER = LoggerFactory.getLogger(FrontendIndexService::class.java) } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQueryParser.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQueryParser.kt index 603d205..2b29766 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQueryParser.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQueryParser.kt @@ -35,9 +35,12 @@ class LegacyPasteQueryParser( response.setHeader("PAGINATION_PAGE", (page - 1).toString()) val filters = mutableListOf() + parseClientFilter(request)?.let(filters::add) + request.getParameter("folder")?.takeIf(String::isNotBlank) ?.let { filters += LegacyFieldFilter("folder", LegacyFilterOperator.EQ, it) } + visibility?.let { filters += LegacyFieldFilter("visibility", LegacyFilterOperator.EQ, it.name) } encrypted?.let { filters += LegacyFieldFilter("encrypted", LegacyFilterOperator.EQ, it.toString()) } userId?.let { filters += LegacyFieldFilter("userId", LegacyFilterOperator.EQ, it) } @@ -84,20 +87,21 @@ class LegacyPasteQueryParser( } request.getParameter("filter")?.trim()?.takeIf { it.startsWith("{") }?.let { - return parseJsonObject(objectMapper.readTree(it)).let { filter -> - LegacyFilterGroup(LegacyGroupOperator.AND, listOf(filter)) - } + return LegacyFilterGroup(LegacyGroupOperator.AND, listOf(parseJsonObject(objectMapper.readTree(it)))) } val formFilter = mutableMapOf() + request.parameterMap.forEach { (name, values) -> val tokens = BRACKET_TOKEN.findAll(name).map { it.groupValues[1] }.toList() if (tokens.firstOrNull() == "filter" && tokens.size > 1) { insert(formFilter, tokens.drop(1), values.map(String::trim).filter(String::isNotEmpty)) } } + if (formFilter.isEmpty()) return null - return parseObject(formFilter).let { LegacyFilterGroup(LegacyGroupOperator.AND, listOf(it)) } + + return LegacyFilterGroup(LegacyGroupOperator.AND, listOf(parseObject(formFilter))) } private fun insert(target: MutableMap, path: List, values: List) { diff --git a/backend-kt/src/main/resources/application-local.properties b/backend-kt/src/main/resources/application-local.properties index db5f719..8a8146e 100644 --- a/backend-kt/src/main/resources/application-local.properties +++ b/backend-kt/src/main/resources/application-local.properties @@ -1,6 +1,7 @@ # Local development profile: seed deterministic test data into the configured database. # The seeder is idempotent and only inserts missing records. pastefy.seeding.enabled=${PASTEFY_SEEDING_ENABLED:true} +spring.liquibase.enabled=${LIQUIBASE_ENABLED:true} # Local docker-compose services in backend-kt/docker-compose.yaml. pastefy.redis.enabled=${PASTEFY_REDIS_ENABLED:true} @@ -32,3 +33,4 @@ pastefy.analytics.user=${ANALYTICS_CLICKHOUSE_USER:pastefy} pastefy.analytics.password=${ANALYTICS_CLICKHOUSE_PASSWORD:pastefy} pastefy.analytics.ip-hash-salt=${ANALYTICS_IP_HASH_SALT:local-development-ip-hash-salt} pastefy.analytics.migrations.enabled=${ANALYTICS_CLICKHOUSE_MIGRATIONS_ENABLED:true} +pastefy.analytics.migrations.repair-failed-history=${ANALYTICS_CLICKHOUSE_REPAIR_FAILED_HISTORY:true} diff --git a/backend-kt/src/main/resources/application.properties b/backend-kt/src/main/resources/application.properties index 7457f95..6b8df08 100644 --- a/backend-kt/src/main/resources/application.properties +++ b/backend-kt/src/main/resources/application.properties @@ -70,6 +70,7 @@ spring.data.redis.port=${REDIS_PORT:6379} spring.data.redis.password=${REDIS_PASSWORD:} spring.data.redis.connect-timeout=${REDIS_CONNECT_TIMEOUT:1s} spring.data.redis.timeout=${REDIS_TIMEOUT:1s} +spring.data.redis.repositories.enabled=false pastefy.s3.enabled=${PASTEFY_S3_ENABLED:false} pastefy.s3.endpoint=${MINIO_SERVER:} @@ -93,6 +94,7 @@ spring.elasticsearch.username=${ELASTICSEARCH_USER:} spring.elasticsearch.password=${ELASTICSEARCH_PASSWORD:} spring.elasticsearch.connection-timeout=${ELASTICSEARCH_CONNECTION_TIMEOUT:2s} spring.elasticsearch.socket-timeout=${ELASTICSEARCH_SOCKET_TIMEOUT:5s} +spring.data.elasticsearch.repositories.enabled=false pastefy.ai.enabled=${PASTEFY_AI_ENABLED:false} pastefy.ai.provider=${PASTEFY_AI_PROVIDER:} @@ -128,6 +130,7 @@ pastefy.analytics.geo-ip-mmdb-path=${ANALYTICS_GEOIP_MMDB_PATH:} pastefy.analytics.track-bots=${ANALYTICS_TRACK_BOTS:true} # Flyway owns ClickHouse schema changes. Enable only in the deployment step that applies migrations. pastefy.analytics.migrations.enabled=${ANALYTICS_CLICKHOUSE_MIGRATIONS_ENABLED:false} +pastefy.analytics.migrations.repair-failed-history=${ANALYTICS_CLICKHOUSE_REPAIR_FAILED_HISTORY:false} # Local test fixtures. The seeder is disabled by default and does not run with the prod Spring profile. pastefy.seeding.enabled=${PASTEFY_SEEDING_ENABLED:false} diff --git a/backend-kt/src/main/resources/db/migration/clickhouse/V1__create_analytics_visits.sql b/backend-kt/src/main/resources/db/migration/clickhouse/V1__create_analytics_visits.sql index 85f7039..8a4a36a 100644 --- a/backend-kt/src/main/resources/db/migration/clickhouse/V1__create_analytics_visits.sql +++ b/backend-kt/src/main/resources/db/migration/clickhouse/V1__create_analytics_visits.sql @@ -20,4 +20,4 @@ CREATE TABLE IF NOT EXISTS `${analyticsDatabase}`.`${analyticsTable}` ENGINE = MergeTree PARTITION BY toYYYYMM(visited_at) ORDER BY (paste_key, visited_at) -TTL visited_at + INTERVAL ${retentionDays} DAY DELETE; +TTL visited_at + INTERVAL ${retentionDays} DAY DELETE; \ No newline at end of file diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/controller/ControllerHttpTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/controller/ControllerHttpTest.kt index 80474ce..e3a22a1 100644 --- a/backend-kt/src/test/kotlin/de/interaapps/pastefy/controller/ControllerHttpTest.kt +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/controller/ControllerHttpTest.kt @@ -7,7 +7,7 @@ import de.interaapps.pastefy.auth.annotations.CurrentUser import de.interaapps.pastefy.auth.oauth.OAuth2ProviderRegistry import de.interaapps.pastefy.config.PastefyProperties import de.interaapps.pastefy.controller.public.PublicUserController -import de.interaapps.pastefy.controller.pastes.PasteMetaSSRController +import de.interaapps.pastefy.controller.seo.PasteMetaSSRController import de.interaapps.pastefy.controller.pastes.PasteRawController import de.interaapps.pastefy.controller.stats.StatsController import de.interaapps.pastefy.controller.user.UserController From 78e87b85b927a9d6cac510fecb4ce871f18d045a Mon Sep 17 00:00:00 2001 From: juliangojani Date: Fri, 5 Jun 2026 00:07:08 +0200 Subject: [PATCH 10/22] Initial spring-boot-kotlin --- backend-kt/development/starquery/.gitignore | 2 ++ .../pastefy/infrastructure/seeding/LocalTestingSeeder.kt | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 backend-kt/development/starquery/.gitignore diff --git a/backend-kt/development/starquery/.gitignore b/backend-kt/development/starquery/.gitignore new file mode 100644 index 0000000..1066f8c --- /dev/null +++ b/backend-kt/development/starquery/.gitignore @@ -0,0 +1,2 @@ +starquery-meta.sqlite-shm +starquery-meta.sqlite-wal \ No newline at end of file diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/seeding/LocalTestingSeeder.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/seeding/LocalTestingSeeder.kt index d2c2dd3..aaa9f7d 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/seeding/LocalTestingSeeder.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/seeding/LocalTestingSeeder.kt @@ -107,6 +107,7 @@ class LocalTestingSeeder( type = User.Type.BLOCKED, provider = User.AuthenticationProvider.GOOGLE, authId = "seed-google-blocked", + avatar = "https://accounts.interaapps.de/avatars/B.png", ) state.awaiting = user( id = AWAITING_ID, @@ -116,6 +117,7 @@ class LocalTestingSeeder( type = User.Type.AWAITING_ACCESS, provider = User.AuthenticationProvider.DISCORD, authId = "seed-discord-awaiting", + avatar = "https://accounts.interaapps.de/avatars/W.png", ) } @@ -138,7 +140,7 @@ class LocalTestingSeeder( type = User.Type.USER, provider = providers[index % providers.size], authId = "seed-generated-user-$number", - avatar = "https://accounts.interaapps.de/avatars/${number.toString().substring(1)}.png", + avatar = "https://accounts.interaapps.de/avatars/${number.toString().take(1).uppercase()}.png", ) } } From 29ef9ba8b6d8726c46de0076035315b6731a008e Mon Sep 17 00:00:00 2001 From: juliangojani Date: Fri, 5 Jun 2026 00:07:12 +0200 Subject: [PATCH 11/22] Initial spring-boot-kotlin --- .../starquery/starquery-meta.sqlite | Bin 94208 -> 94208 bytes .../starquery/starquery-meta.sqlite-shm | Bin 32768 -> 32768 bytes .../starquery/starquery-meta.sqlite-wal | Bin 0 -> 49472 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/backend-kt/development/starquery/starquery-meta.sqlite b/backend-kt/development/starquery/starquery-meta.sqlite index 1f45c60bd87abbd9b272763e0adc1808aa72b2ca..252be83bee4d80d821571d294e31694fb0078bef 100644 GIT binary patch delta 33 pcmZp8z}oPDb;EsoPE#vG6FoCijciS?YiEI>BVcw?ipw>TpM69Y3)4=V#313Lo;11AF)12+TD#>S7v E0NX?^X#fBK delta 80 zcmZo@U}|V!;+1%$%K!t66Awy@TChv7nNGgVi7p`mlYpuI4+Il)WHw&V@ZNYKzytuQ Cp%jGx diff --git a/backend-kt/development/starquery/starquery-meta.sqlite-wal b/backend-kt/development/starquery/starquery-meta.sqlite-wal index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..a3fda5f094cb634d18cf4ec11905da97c587692e 100644 GIT binary patch literal 49472 zcmeI)KWGzC9Ki7uq$pb3t))5?#6`LA?%ln2dBK3_R9qYkN?gqSgFxFLQQHnG7OLPZ z1yNg27pEekh(Z@Bx)l_cjva)8)WJGv6+QnH&O+hlyKhL~@e%^>li&2^a^BVMsgkua zP%8D6em_23oL!x}(|3PidgANA<&U*wYe(|x-`wArztWiK-Kl$$&91%crR2#20R#|0 z009ILKmY**5I_KdTnS9=>e^GU@AD2`>*=r6YL(b)YS#!W^qZ08R-(YF*uHQ1!u5=Z zRov9RCwv)%zHONxdE2$MvHaL*ORL!PMMXMM5O<XY`Oc*<^NWM|{DhQoOs>qK-C`9Bb-3+yfA|sWIrDuC0bc_-~=-FC@ zfmU*)9W+OzklGTSCEQceu1Zl=qHL7Y_`9FH^=DsIs;*l$Mr_&N{Jh&pe%SxI!21)O z`IR4YpXma*8hH+b00IagfB*srAbt`lZz`x~EPT$koVm7z7YN z009ILKmY**5I_KdltAISfXRNnz`Fx4UwnJ`<`!KbH83m@KmY**5I_I{1Q0*~0R(a- zP_!=K$?Vq)bk98K?O!_T(FJlf@*D;M1Q0*~0R#|0009ILKp-VhxGvy`%=Z^K_i6F@ zdgbOCT_80uED=Be0R#|0009ILKmY**awSl>F5qVWAHmZ3xyAiY7aq|Cay9ZC1_1;R ZKmY**5I_I{1Q0+VB~Z97ptkP Date: Fri, 5 Jun 2026 00:19:49 +0200 Subject: [PATCH 12/22] Initial spring-boot-kotlin --- .../starquery/starquery-meta.sqlite | Bin 94208 -> 94208 bytes .../starquery/starquery-meta.sqlite-shm | Bin 32768 -> 32768 bytes .../starquery/starquery-meta.sqlite-wal | Bin 49472 -> 0 bytes .../resources/application-local.properties | 1 + 4 files changed, 1 insertion(+) diff --git a/backend-kt/development/starquery/starquery-meta.sqlite b/backend-kt/development/starquery/starquery-meta.sqlite index 252be83bee4d80d821571d294e31694fb0078bef..6e1309f3995684273bd69ad34f9199f528742d25 100644 GIT binary patch delta 33 pcmZp8z}oPDb;EsoPD?98OFeT_v#9A8{24`?|JrZ=YtP7R007)`3|jyI delta 33 pcmZp8z}oPDb;EsoPE#vG6FoCijciS?YiEI>BVcw?ipw>TpM69Y3)4=V#313Lo;11AF)12+TD#>S7v E0NX?^X#fBK diff --git a/backend-kt/development/starquery/starquery-meta.sqlite-wal b/backend-kt/development/starquery/starquery-meta.sqlite-wal index a3fda5f094cb634d18cf4ec11905da97c587692e..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 GIT binary patch literal 0 HcmV?d00001 literal 49472 zcmeI)KWGzC9Ki7uq$pb3t))5?#6`LA?%ln2dBK3_R9qYkN?gqSgFxFLQQHnG7OLPZ z1yNg27pEekh(Z@Bx)l_cjva)8)WJGv6+QnH&O+hlyKhL~@e%^>li&2^a^BVMsgkua zP%8D6em_23oL!x}(|3PidgANA<&U*wYe(|x-`wArztWiK-Kl$$&91%crR2#20R#|0 z009ILKmY**5I_KdTnS9=>e^GU@AD2`>*=r6YL(b)YS#!W^qZ08R-(YF*uHQ1!u5=Z zRov9RCwv)%zHONxdE2$MvHaL*ORL!PMMXMM5O<XY`Oc*<^NWM|{DhQoOs>qK-C`9Bb-3+yfA|sWIrDuC0bc_-~=-FC@ zfmU*)9W+OzklGTSCEQceu1Zl=qHL7Y_`9FH^=DsIs;*l$Mr_&N{Jh&pe%SxI!21)O z`IR4YpXma*8hH+b00IagfB*srAbt`lZz`x~EPT$koVm7z7YN z009ILKmY**5I_KdltAISfXRNnz`Fx4UwnJ`<`!KbH83m@KmY**5I_I{1Q0*~0R(a- zP_!=K$?Vq)bk98K?O!_T(FJlf@*D;M1Q0*~0R#|0009ILKp-VhxGvy`%=Z^K_i6F@ zdgbOCT_80uED=Be0R#|0009ILKmY**awSl>F5qVWAHmZ3xyAiY7aq|Cay9ZC1_1;R ZKmY**5I_I{1Q0+VB~Z97ptkP Date: Fri, 5 Jun 2026 01:10:55 +0200 Subject: [PATCH 13/22] Initial spring-boot-kotlin --- .../config/AiEnvironmentPostProcessor.kt | 31 ++++++++++ .../pastefy/config/PastefyProperties.kt | 4 ++ .../pastefy/config/WebConfiguration.kt | 14 +++++ .../infrastructure/ai/AiEnabledCondition.kt | 28 +++++++++ .../pastefy/infrastructure/ai/PasteAI.kt | 4 +- .../infrastructure/ai/PasteAIInfoService.kt | 4 +- .../analytics/AnalyticsConfiguration.kt | 2 +- .../ClickHouseFlywayConfiguration.kt | 2 +- .../analytics/ClickHouseJdbcUrls.kt | 16 +++++ .../elastic/ElasticPasteQueryAdapter.kt | 5 +- .../elastic/ElasticsearchConfiguration.kt | 21 +++++++ .../jobs/BackgroundJobConfiguration.kt | 5 +- .../jobs/BackgroundJobService.kt | 5 +- .../pastefy/service/PasteResponseMapper.kt | 13 ++++- .../de/interaapps/pastefy/util/StringUtil.kt | 8 +++ .../main/resources/META-INF/spring.factories | 2 + .../resources/application-local.properties | 1 + .../src/main/resources/application.properties | 58 ++++++++++--------- frontend/src/stores/tags-store.ts | 17 +++++- 19 files changed, 195 insertions(+), 45 deletions(-) create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/config/AiEnvironmentPostProcessor.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/AiEnabledCondition.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/ClickHouseJdbcUrls.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticsearchConfiguration.kt create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/util/StringUtil.kt create mode 100644 backend-kt/src/main/resources/META-INF/spring.factories diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/AiEnvironmentPostProcessor.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/AiEnvironmentPostProcessor.kt new file mode 100644 index 0000000..7228ac9 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/AiEnvironmentPostProcessor.kt @@ -0,0 +1,31 @@ +package de.interaapps.pastefy.config + +import org.springframework.boot.SpringApplication +import org.springframework.boot.env.EnvironmentPostProcessor +import org.springframework.core.Ordered +import org.springframework.core.env.ConfigurableEnvironment +import org.springframework.core.env.MapPropertySource + +class AiEnvironmentPostProcessor : EnvironmentPostProcessor, Ordered { + override fun postProcessEnvironment(environment: ConfigurableEnvironment, application: SpringApplication) { + if (environment.hasText("SPRING_AI_MODEL_CHAT")) return + + val provider = environment.getProperty("AI_PROVIDER").orEmpty().trim().lowercase() + val chatModel = when { + provider == "anthropic" -> "anthropic" + provider == "google" || provider == "google-genai" -> "google-genai" + environment.hasText("AI_ANTHROPIC_TOKEN") -> "anthropic" + environment.hasText("AI_GOOGLE_TOKEN") -> "google-genai" + else -> return + } + + environment.propertySources.addFirst( + MapPropertySource("pastefy-ai-env-compat", mapOf("spring.ai.model.chat" to chatModel)) + ) + } + + override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE + 10 + + private fun org.springframework.core.env.Environment.hasText(name: String): Boolean = + getProperty(name)?.isNotBlank() == true +} diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/PastefyProperties.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/PastefyProperties.kt index fb4a8a4..744d822 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/PastefyProperties.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/PastefyProperties.kt @@ -4,6 +4,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties("pastefy") data class PastefyProperties( + var cors: String = "", var loginRequiredRead: Boolean = false, var loginRequiredCreate: Boolean = false, var publicPastesEnabled: Boolean = true, @@ -60,6 +61,7 @@ data class PastefyProperties( data class Elasticsearch( var enabled: Boolean = false, + var apiKey: String = "", var indexName: String = "pastefy_pastes_current", var indexPrefix: String = "pastefy_pastes", var legacyIndexName: String = "pastefy_pastes", @@ -101,6 +103,8 @@ data class PastefyProperties( var batchSize: Int = 1_000, var queueCapacity: Int = 100_000, var flushIntervalMillis: Long = 1_000, + var httpConnectTimeoutMillis: Long = 2_000, + var httpRequestTimeoutMillis: Long = 5_000, var ipHashSalt: String = "", var ipSource: String = "direct", var ipHeader: String = "", diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/WebConfiguration.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/WebConfiguration.kt index 00075d9..40666fe 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/WebConfiguration.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/WebConfiguration.kt @@ -12,6 +12,7 @@ import java.time.Duration @Configuration class WebConfiguration( private val authInterceptor: AuthInterceptor, + private val properties: PastefyProperties, ) : WebMvcConfigurer { override fun addInterceptors(registry: InterceptorRegistry) { registry.addInterceptor(authInterceptor) @@ -21,6 +22,19 @@ class WebConfiguration( resolvers += AuthArgumentResolver() } + override fun addCorsMappings(registry: org.springframework.web.servlet.config.annotation.CorsRegistry) { + val origins = properties.cors.split(',') + .map(String::trim) + .filter(String::isNotBlank) + if (origins.isEmpty()) return + + registry.addMapping("/**") + .allowedOriginPatterns(*origins.toTypedArray()) + .allowedMethods("*") + .allowedHeaders("*") + .allowCredentials(false) + } + override fun addResourceHandlers(registry: ResourceHandlerRegistry) { registry.addResourceHandler("/assets/**") .addResourceLocations("classpath:/static/assets/") diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/AiEnabledCondition.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/AiEnabledCondition.kt new file mode 100644 index 0000000..f0050c2 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/AiEnabledCondition.kt @@ -0,0 +1,28 @@ +package de.interaapps.pastefy.infrastructure.ai + +import org.springframework.context.annotation.Condition +import org.springframework.context.annotation.ConditionContext +import org.springframework.core.type.AnnotatedTypeMetadata + +class AiEnabledCondition : Condition { + override fun matches(context: ConditionContext, metadata: AnnotatedTypeMetadata): Boolean { + val environment = context.environment + val explicitlyEnabled = environment.getProperty("pastefy.ai.enabled", Boolean::class.java, false) + val hasLegacyConfig = environment.hasText("AI_PROVIDER") || + environment.hasText("AI_ANTHROPIC_TOKEN") || + environment.hasText("AI_GOOGLE_TOKEN") + if (!explicitlyEnabled && !hasLegacyConfig) return false + + val provider = environment.getProperty("pastefy.ai.provider").orEmpty().lowercase() + return when (provider) { + "anthropic" -> environment.hasText("AI_ANTHROPIC_TOKEN") || + environment.hasText("spring.ai.anthropic.api-key") + "google", "google-genai" -> environment.hasText("AI_GOOGLE_TOKEN") || + environment.hasText("spring.ai.google.genai.api-key") + else -> environment.hasText("SPRING_AI_MODEL_CHAT") + } + } + + private fun org.springframework.core.env.Environment.hasText(name: String): Boolean = + getProperty(name)?.isNotBlank() == true +} diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/PasteAI.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/PasteAI.kt index e778ddd..fbeafda 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/PasteAI.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/PasteAI.kt @@ -5,11 +5,11 @@ import de.interaapps.pastefy.config.PastefyProperties import de.interaapps.pastefy.entities.Paste import org.springframework.ai.chat.client.ChatClient import org.springframework.ai.chat.model.ChatModel -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Conditional import org.springframework.stereotype.Service @Service -@ConditionalOnProperty(prefix = "pastefy.ai", name = ["enabled"], havingValue = "true") +@Conditional(AiEnabledCondition::class) class PasteAI( chatModel: ChatModel, private val objectMapper: ObjectMapper, diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/PasteAIInfoService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/PasteAIInfoService.kt index 5644232..a497868 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/PasteAIInfoService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/PasteAIInfoService.kt @@ -12,12 +12,12 @@ import de.interaapps.pastefy.repositories.PasteRepository import de.interaapps.pastefy.repositories.PublicPasteEngagementRepository import de.interaapps.pastefy.service.PasteService import org.slf4j.LoggerFactory -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Conditional import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service @Service -@ConditionalOnProperty(prefix = "pastefy.ai", name = ["enabled"], havingValue = "true") +@Conditional(AiEnabledCondition::class) class PasteAIInfoService( private val pasteAI: PasteAI, private val pasteService: PasteService, diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsConfiguration.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsConfiguration.kt index fbb9dc5..9a910eb 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsConfiguration.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsConfiguration.kt @@ -19,7 +19,7 @@ class AnalyticsConfiguration { require(properties.analytics.ipHashSalt.isNotBlank()) { "pastefy.analytics.ip-hash-salt is required when analytics are enabled" } return DriverManagerDataSource().apply { setDriverClassName("com.clickhouse.jdbc.ClickHouseDriver") - url = properties.analytics.jdbcUrl + url = clickHouseJdbcUrl(properties.analytics.jdbcUrl, properties.analytics.database) username = properties.analytics.user password = properties.analytics.password } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/ClickHouseFlywayConfiguration.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/ClickHouseFlywayConfiguration.kt index 458d683..5df4234 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/ClickHouseFlywayConfiguration.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/ClickHouseFlywayConfiguration.kt @@ -19,7 +19,7 @@ class ClickHouseFlywayConfiguration { "pastefy.analytics.jdbc-url is required when ClickHouse migrations are enabled" } setDriverClassName("com.clickhouse.jdbc.ClickHouseDriver") - url = properties.analytics.jdbcUrl + url = clickHouseJdbcUrl(properties.analytics.jdbcUrl, properties.analytics.database) username = properties.analytics.user password = properties.analytics.password }, diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/ClickHouseJdbcUrls.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/ClickHouseJdbcUrls.kt new file mode 100644 index 0000000..86841de --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/ClickHouseJdbcUrls.kt @@ -0,0 +1,16 @@ +package de.interaapps.pastefy.infrastructure.analytics + +import java.net.URI + +fun clickHouseJdbcUrl(raw: String, database: String): String { + val value = raw.trim() + if (value.startsWith("jdbc:", ignoreCase = true)) return value + val uri = URI.create(value) + require(uri.scheme == "http" || uri.scheme == "https") { + "ClickHouse URL must be a jdbc:clickhouse, http or https URL" + } + val host = requireNotNull(uri.host) { "ClickHouse URL must contain a host" } + val port = if (uri.port > 0) ":${uri.port}" else "" + val ssl = if (uri.scheme == "https") "?ssl=true" else "" + return "jdbc:clickhouse://$host$port/$database$ssl" +} diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteQueryAdapter.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteQueryAdapter.kt index e183c71..f16992c 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteQueryAdapter.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteQueryAdapter.kt @@ -13,6 +13,7 @@ import de.interaapps.pastefy.service.query.LegacyFilterGroup import de.interaapps.pastefy.service.query.LegacyFilterOperator import de.interaapps.pastefy.service.query.LegacyGroupOperator import de.interaapps.pastefy.service.query.LegacyPasteQuery +import de.interaapps.pastefy.util.shorten import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort @@ -162,15 +163,13 @@ class ElasticPasteQueryAdapter( forkedFrom = document.forkedFrom, visibility = document.visibility, folder = document.folder, + tags = document.tags, type = document.type, user = document.user?.let { PublicUserDto(id = it.id, name = it.uniqueName, displayName = it.name, avatar = it.avatar) }, ) - private fun String?.shorten(): String? = - if (this != null && length > 303) take(300) + "..." else this - private fun normalizeField(field: String): String = FIELD_ALIASES[field] ?: field companion object { diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticsearchConfiguration.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticsearchConfiguration.kt new file mode 100644 index 0000000..575b8c0 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticsearchConfiguration.kt @@ -0,0 +1,21 @@ +package de.interaapps.pastefy.infrastructure.elastic + +import de.interaapps.pastefy.config.PastefyProperties +import org.apache.http.message.BasicHeader +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.autoconfigure.elasticsearch.RestClientBuilderCustomizer +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +@ConditionalOnProperty(prefix = "pastefy.elasticsearch", name = ["enabled"], havingValue = "true") +class ElasticsearchConfiguration { + @Bean + fun elasticsearchApiKeyCustomizer(properties: PastefyProperties): RestClientBuilderCustomizer = + RestClientBuilderCustomizer { builder -> + val elastic = properties.elasticsearch + if (elastic.apiKey.isNotBlank()) { + builder.setDefaultHeaders(arrayOf(BasicHeader("Authorization", "ApiKey ${elastic.apiKey}"))) + } + } +} diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/jobs/BackgroundJobConfiguration.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/jobs/BackgroundJobConfiguration.kt index 10a6f25..4318fd8 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/jobs/BackgroundJobConfiguration.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/jobs/BackgroundJobConfiguration.kt @@ -1,13 +1,14 @@ package de.interaapps.pastefy.infrastructure.jobs import de.interaapps.pastefy.config.PastefyProperties -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import de.interaapps.pastefy.infrastructure.ai.AiEnabledCondition import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Conditional import org.springframework.context.annotation.Configuration import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor @Configuration -@ConditionalOnProperty(prefix = "pastefy.ai", name = ["enabled"], havingValue = "true") +@Conditional(AiEnabledCondition::class) class BackgroundJobConfiguration { @Bean fun backgroundJobExecutor(properties: PastefyProperties): ThreadPoolTaskExecutor = diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/jobs/BackgroundJobService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/jobs/BackgroundJobService.kt index 5b2b6ed..4ffbe54 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/jobs/BackgroundJobService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/jobs/BackgroundJobService.kt @@ -2,10 +2,11 @@ package de.interaapps.pastefy.infrastructure.jobs import de.interaapps.pastefy.config.PastefyProperties import de.interaapps.pastefy.entities.BackgroundJob +import de.interaapps.pastefy.infrastructure.ai.AiEnabledCondition import de.interaapps.pastefy.repositories.BackgroundJobRepository import org.slf4j.LoggerFactory import org.springframework.beans.factory.ObjectProvider -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Conditional import org.springframework.dao.DataIntegrityViolationException import org.springframework.scheduling.annotation.Scheduled import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor @@ -17,7 +18,7 @@ import java.util.UUID import java.util.concurrent.atomic.AtomicInteger @Service -@ConditionalOnProperty(prefix = "pastefy.ai", name = ["enabled"], havingValue = "true") +@Conditional(AiEnabledCondition::class) class BackgroundJobService( private val repository: BackgroundJobRepository, private val handlers: ObjectProvider, diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteResponseMapper.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteResponseMapper.kt index 43cb4ed..de194e1 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteResponseMapper.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteResponseMapper.kt @@ -11,6 +11,7 @@ import de.interaapps.pastefy.enums.PasteVisibility import de.interaapps.pastefy.repositories.PasteAIInfoRepository import de.interaapps.pastefy.repositories.PasteTagRepository import de.interaapps.pastefy.repositories.UserRepository +import de.interaapps.pastefy.util.shorten import org.springframework.stereotype.Service @Service @@ -32,9 +33,15 @@ class PasteResponseMapper( withAiInfo: Boolean = false, shortenContent: Boolean = false, ): PasteResponse { - val content = pasteService.getContent(paste).orEmpty().let { - if (shortenContent && it.length > 303) it.take(300) + "..." else it - } + val content = pasteService.getContent(paste) + .orEmpty() + .let { raw -> + if (shortenContent) { + raw.shorten() + } else { + raw + } + } return PasteResponse( exists = true, id = paste.key, diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/util/StringUtil.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/util/StringUtil.kt new file mode 100644 index 0000000..5368b28 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/util/StringUtil.kt @@ -0,0 +1,8 @@ +package de.interaapps.pastefy.util + +fun String?.shorten(): String? { + return this?.lineSequence()?.take(7)?.joinToString("\n") { line -> + if (line.length > 40) line.take(37) + "..." else line + } + ?: return this +} \ No newline at end of file diff --git a/backend-kt/src/main/resources/META-INF/spring.factories b/backend-kt/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..ecd1af4 --- /dev/null +++ b/backend-kt/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ +de.interaapps.pastefy.config.AiEnvironmentPostProcessor diff --git a/backend-kt/src/main/resources/application-local.properties b/backend-kt/src/main/resources/application-local.properties index 8151061..e082f39 100644 --- a/backend-kt/src/main/resources/application-local.properties +++ b/backend-kt/src/main/resources/application-local.properties @@ -4,6 +4,7 @@ pastefy.seeding.enabled=${PASTEFY_SEEDING_ENABLED:true} spring.liquibase.enabled=${LIQUIBASE_ENABLED:true} pastefy.meta-tags-enabled=${PASTEFY_META_TAGS:true} +pastefy.cors=${HTTP_SERVER_CORS:*} # Local docker-compose services in backend-kt/docker-compose.yaml. pastefy.redis.enabled=${PASTEFY_REDIS_ENABLED:true} pastefy.redis.cache-after-accesses=${PASTEFY_REDIS_CACHE_AFTER_ACCESSES:1} diff --git a/backend-kt/src/main/resources/application.properties b/backend-kt/src/main/resources/application.properties index 6b8df08..087f4a2 100644 --- a/backend-kt/src/main/resources/application.properties +++ b/backend-kt/src/main/resources/application.properties @@ -1,8 +1,8 @@ spring.application.name=pastefy-api -spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/mydatabase?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC} -spring.datasource.username=${SPRING_DATASOURCE_USERNAME:myuser} -spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:secret} +spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:mysql://${DATABASE_HOST:localhost}:${DATABASE_PORT:3306}/${DATABASE_NAME:pastefy}?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC&cachePrepStmts=${DATABASE_CUSTOMPARAMS_CACHE_PREP_STMTS:true}&prepStmtCacheSize=${DATABASE_CUSTOMPARAMS_PREP_STMT_CACHE_SIZE:250}&prepStmtCacheSqlLimit=${DATABASE_CUSTOMPARAMS_PREP_STMT_CACHE_SQL_LIMIT:2048}&useServerPrepStmts=${DATABASE_CUSTOMPARAMS_USE_SERVER_PREP_STMTS:true}&cacheResultSetMetadata=${DATABASE_CUSTOMPARAMS_CACHE_RESULT_SET_METADATA:true}&cacheServerConfiguration=${DATABASE_CUSTOMPARAMS_CACHE_SERVER_CONFIGURATION:true}&maintainTimeStats=${DATABASE_CUSTOMPARAMS_MAINTAIN_TIME_STATS:true}} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME:${DATABASE_USER:pastefy}} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:${DATABASE_PASSWORD:pastefy}} spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver # Schema changes are handled externally. The API only verifies the deployed schema. @@ -13,7 +13,7 @@ spring.jpa.properties.hibernate.format_sql=false spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect # Liquibase owns the MySQL schema lifecycle. Enable it explicitly during deployment. -spring.liquibase.enabled=${LIQUIBASE_ENABLED:false} +spring.liquibase.enabled=${LIQUIBASE_ENABLED:${PASTEFY_AUTOMIGRATE:false}} spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.yaml # Boot Flyway auto-configuration targets the primary datasource. ClickHouse Flyway is configured separately below. spring.flyway.enabled=false @@ -21,12 +21,13 @@ spring.flyway.enabled=false # Pastefy's public API contract uses snake_case for request and response JSON. # Springdoc derives /v3/api-docs schemas from the same Jackson naming strategy. spring.jackson.property-naming-strategy=SNAKE_CASE -server.port=${SERVER_PORT:8080} +server.port=${HTTP_SERVER_PORT:${SERVER_PORT:8080}} server.forward-headers-strategy=${SERVER_FORWARD_HEADERS_STRATEGY:NATIVE} -pastefy.login-required-read=${PASTEFY_LOGIN_REQUIRED_READ:false} -pastefy.login-required-create=${PASTEFY_LOGIN_REQUIRED_CREATE:false} -pastefy.public-pastes-enabled=${PASTEFY_PUBLIC_PASTES_ENABLED:true} +pastefy.cors=${HTTP_SERVER_CORS:} +pastefy.login-required-read=${PASTEFY_LOGIN_REQUIRED_READ:${PASTEFY_LOGIN_REQUIRED:false}} +pastefy.login-required-create=${PASTEFY_LOGIN_REQUIRED_CREATE:${PASTEFY_LOGIN_REQUIRED:false}} +pastefy.public-pastes-enabled=${PASTEFY_PUBLIC_PASTES_ENABLED:${PASTEFY_PUBLIC_PASTES:true}} pastefy.public-stats=${PASTEFY_PUBLIC_STATS:false} pastefy.meta-tags-enabled=${PASTEFY_META_TAGS:false} pastefy.meta-tags-preview-length=${PASTEFY_META_TAGS_PREVIEW_LENGTH:4096} @@ -34,9 +35,9 @@ pastefy.server-name=${SERVER_NAME:http://localhost} pastefy.list-pastes=${PASTEFY_LIST_PASTES:false} pastefy.pagination-page-limit=${PASTEFY_PAGINATION_PAGE_LIMIT:50} pastefy.encryption-default=${PASTEFY_ENCRYPTION_DEFAULT:false} -pastefy.custom-logo=${PASTEFY_CUSTOM_LOGO:} -pastefy.custom-name=${PASTEFY_CUSTOM_NAME:} -pastefy.custom-footer=${PASTEFY_CUSTOM_FOOTER:} +pastefy.custom-logo=${PASTEFY_CUSTOM_LOGO:${PASTEFY_INFO_CUSTOM_LOGO:}} +pastefy.custom-name=${PASTEFY_CUSTOM_NAME:${PASTEFY_INFO_CUSTOM_NAME:}} +pastefy.custom-footer=${PASTEFY_CUSTOM_FOOTER:${PASTEFY_INFO_CUSTOM_FOOTER:}} pastefy.custom-header=${PASTEFY_CUSTOM_HEADER:} pastefy.custom-body=${PASTEFY_CUSTOM_BODY:} pastefy.grant-access-required=${PASTEFY_GRANT_ACCESS_REQUIRED:false} @@ -54,9 +55,9 @@ pastefy.oauth.discord.client-id=${OAUTH2_DISCORD_CLIENT_ID:} pastefy.oauth.discord.client-secret=${OAUTH2_DISCORD_CLIENT_SECRET:} pastefy.oauth.oidc.client-id=${OAUTH2_OIDC_CLIENT_ID:} pastefy.oauth.oidc.client-secret=${OAUTH2_OIDC_CLIENT_SECRET:} -pastefy.oauth.oidc.authorization-endpoint=${OAUTH2_OIDC_AUTHORIZATION_ENDPOINT:} +pastefy.oauth.oidc.authorization-endpoint=${OAUTH2_OIDC_AUTHORIZATION_ENDPOINT:${OAUTH2_OIDC_AUTH_ENDPOINT:}} pastefy.oauth.oidc.token-endpoint=${OAUTH2_OIDC_TOKEN_ENDPOINT:} -pastefy.oauth.oidc.user-info-endpoint=${OAUTH2_OIDC_USER_INFO_ENDPOINT:} +pastefy.oauth.oidc.user-info-endpoint=${OAUTH2_OIDC_USER_INFO_ENDPOINT:${OAUTH2_OIDC_USERINFO_ENDPOINT:}} pastefy.rate-limiter.enabled=${PASTEFY_RATE_LIMITER_ENABLED:true} pastefy.rate-limiter.window-millis=${PASTEFY_RATE_LIMITER_WINDOW_MILLIS:5000} pastefy.rate-limiter.limit=${PASTEFY_RATE_LIMITER_LIMIT:5} @@ -82,6 +83,7 @@ pastefy.s3.paste-size-threshold=${MINIO_PASTESIZE_THRESHOLD:-1} pastefy.s3.create-bucket=${MINIO_CREATE_BUCKET:false} pastefy.elasticsearch.enabled=${PASTEFY_ELASTICSEARCH_ENABLED:false} +pastefy.elasticsearch.api-key=${ELASTICSEARCH_API_KEY:} pastefy.elasticsearch.index-name=${PASTEFY_ELASTICSEARCH_INDEX_NAME:pastefy_pastes_current} pastefy.elasticsearch.index-prefix=${PASTEFY_ELASTICSEARCH_INDEX_PREFIX:pastefy_pastes} pastefy.elasticsearch.legacy-index-name=${PASTEFY_ELASTICSEARCH_LEGACY_INDEX_NAME:pastefy_pastes} @@ -97,24 +99,24 @@ spring.elasticsearch.socket-timeout=${ELASTICSEARCH_SOCKET_TIMEOUT:5s} spring.data.elasticsearch.repositories.enabled=false pastefy.ai.enabled=${PASTEFY_AI_ENABLED:false} -pastefy.ai.provider=${PASTEFY_AI_PROVIDER:} -pastefy.ai.model=${PASTEFY_AI_MODEL:} -pastefy.ai.engagement-threshold=${PASTEFY_AI_ENGAGEMENT_THRESHOLD:100} -pastefy.ai.jobs.workers=${PASTEFY_AI_JOBS_WORKERS:2} -pastefy.ai.jobs.poll-interval-millis=${PASTEFY_AI_JOBS_POLL_INTERVAL_MILLIS:5000} -pastefy.ai.jobs.lease-seconds=${PASTEFY_AI_JOBS_LEASE_SECONDS:120} -pastefy.ai.jobs.max-attempts=${PASTEFY_AI_JOBS_MAX_ATTEMPTS:3} -pastefy.ai.jobs.retry-delay-seconds=${PASTEFY_AI_JOBS_RETRY_DELAY_SECONDS:60} -pastefy.ai.jobs.sweeper-enabled=${PASTEFY_AI_JOBS_SWEEPER_ENABLED:false} -pastefy.ai.jobs.sweep-interval-millis=${PASTEFY_AI_JOBS_SWEEP_INTERVAL_MILLIS:300000} -spring.ai.model.chat=${SPRING_AI_MODEL_CHAT:none} +pastefy.ai.provider=${PASTEFY_AI_PROVIDER:${AI_PROVIDER:}} +pastefy.ai.model=${PASTEFY_AI_MODEL:${AI_MODEL:}} +pastefy.ai.engagement-threshold=${PASTEFY_AI_ENGAGEMENT_THRESHOLD:${AI_ENGAGEMENT_THRESHOLD:100}} +pastefy.ai.jobs.workers=${PASTEFY_AI_JOBS_WORKERS:${AI_JOB_WORKERS:2}} +pastefy.ai.jobs.poll-interval-millis=${PASTEFY_AI_JOBS_POLL_INTERVAL_MILLIS:${AI_JOB_POLL_INTERVAL_MILLIS:5000}} +pastefy.ai.jobs.lease-seconds=${PASTEFY_AI_JOBS_LEASE_SECONDS:${AI_JOB_LEASE_SECONDS:120}} +pastefy.ai.jobs.max-attempts=${PASTEFY_AI_JOBS_MAX_ATTEMPTS:${AI_JOB_MAX_ATTEMPTS:3}} +pastefy.ai.jobs.retry-delay-seconds=${PASTEFY_AI_JOBS_RETRY_DELAY_SECONDS:${AI_JOB_RETRY_DELAY_SECONDS:60}} +pastefy.ai.jobs.sweeper-enabled=${PASTEFY_AI_JOBS_SWEEPER_ENABLED:${AI_JOB_SWEEPER_ENABLED:false}} +pastefy.ai.jobs.sweep-interval-millis=${PASTEFY_AI_JOBS_SWEEP_INTERVAL_MILLIS:${AI_JOB_SWEEP_INTERVAL_MILLIS:300000}} spring.ai.anthropic.api-key=${AI_ANTHROPIC_TOKEN:} spring.ai.anthropic.chat.options.model=${AI_ANTHROPIC_MODEL:claude-3-5-haiku-latest} spring.ai.google.genai.api-key=${AI_GOOGLE_TOKEN:} -spring.ai.google.genai.chat.options.model=${AI_GOOGLE_MODEL:gemini-2.0-flash} +spring.ai.google.genai.chat.options.model=${AI_GOOGLE_MODEL:gemini-2.5-flash-lite} +spring.ai.model.chat=${SPRING_AI_MODEL_CHAT:${AI_PROVIDER:none}} pastefy.analytics.enabled=${PASTEFY_ANALYTICS_ENABLED:false} -pastefy.analytics.jdbc-url=${ANALYTICS_CLICKHOUSE_JDBC_URL:} +pastefy.analytics.jdbc-url=${ANALYTICS_CLICKHOUSE_JDBC_URL:${ANALYTICS_CLICKHOUSE_URL:}} pastefy.analytics.database=${ANALYTICS_CLICKHOUSE_DATABASE:default} pastefy.analytics.table=${ANALYTICS_CLICKHOUSE_TABLE:pastefy_analytics_visits} pastefy.analytics.user=${ANALYTICS_CLICKHOUSE_USER:default} @@ -123,13 +125,15 @@ pastefy.analytics.retention-days=${ANALYTICS_RETENTION_DAYS:90} pastefy.analytics.batch-size=${ANALYTICS_BATCH_SIZE:1000} pastefy.analytics.queue-capacity=${ANALYTICS_QUEUE_CAPACITY:100000} pastefy.analytics.flush-interval-millis=${ANALYTICS_FLUSH_INTERVAL_MILLIS:1000} +pastefy.analytics.http-connect-timeout-millis=${ANALYTICS_HTTP_CONNECT_TIMEOUT_MILLIS:2000} +pastefy.analytics.http-request-timeout-millis=${ANALYTICS_HTTP_REQUEST_TIMEOUT_MILLIS:5000} pastefy.analytics.ip-hash-salt=${ANALYTICS_IP_HASH_SALT:} pastefy.analytics.ip-source=${ANALYTICS_IP_SOURCE:direct} pastefy.analytics.ip-header=${ANALYTICS_IP_HEADER:} pastefy.analytics.geo-ip-mmdb-path=${ANALYTICS_GEOIP_MMDB_PATH:} pastefy.analytics.track-bots=${ANALYTICS_TRACK_BOTS:true} # Flyway owns ClickHouse schema changes. Enable only in the deployment step that applies migrations. -pastefy.analytics.migrations.enabled=${ANALYTICS_CLICKHOUSE_MIGRATIONS_ENABLED:false} +pastefy.analytics.migrations.enabled=${ANALYTICS_CLICKHOUSE_MIGRATIONS_ENABLED:${ANALYTICS_CLICKHOUSE_AUTOMIGRATE:false}} pastefy.analytics.migrations.repair-failed-history=${ANALYTICS_CLICKHOUSE_REPAIR_FAILED_HISTORY:false} # Local test fixtures. The seeder is disabled by default and does not run with the prod Spring profile. diff --git a/frontend/src/stores/tags-store.ts b/frontend/src/stores/tags-store.ts index d7bca86..deef387 100644 --- a/frontend/src/stores/tags-store.ts +++ b/frontend/src/stores/tags-store.ts @@ -6,10 +6,23 @@ import type { Tag } from '@/types/tags.ts' export const useTagsStore = defineStore('tag-cache', () => { const tagsCache = ref>({}) + const fetching = ref([]) + const fetchIfNeeded = async (tag: string) => { - if (!tagsCache.value[tag]) + if (fetching.value.includes(tag)) return + + fetching.value.push(tag) + + if (!tagsCache.value[tag]) { tagsCache.value[tag] = (await client.get(`/api/v2/public/tags/${tag}`)).data as Tag + } + fetching.value.splice(fetching.value.indexOf(tag), 1) + } + + const fetchMultipleTagsIfNeeded = (tags?: string[], max = 99) => { + let i = 0 + return tags?.filter(() => ++i < max).forEach(fetchIfNeeded) } - return { tagsCache, fetchIfNeeded } + return { tagsCache, fetchIfNeeded, fetchMultipleTagsIfNeeded } }) From 4d592df10714cca657b80729198d68bf2134e18e Mon Sep 17 00:00:00 2001 From: juliangojani Date: Fri, 5 Jun 2026 14:43:26 +0200 Subject: [PATCH 14/22] New Paste card --- backend-kt/.idea/dataSources.local.xml | 6 +- .../dataSources/data_sources_history.xml | 6 +- .../pastefy/config/PastefyProperties.kt | 1 + .../pastefy/dto/pastes/PasteResponse.kt | 3 + .../analytics/AnalyticsService.kt | 12 ++ .../elastic/ElasticPasteQueryAdapter.kt | 14 +- .../repositories/PasteCommentRepository.kt | 6 + .../repositories/PasteStarRepository.kt | 10 ++ .../pastefy/service/FolderService.kt | 6 +- .../pastefy/service/PasteCommentService.kt | 3 + .../pastefy/service/PasteMetricsService.kt | 106 +++++++++++ .../pastefy/service/PasteQueryService.kt | 35 ++-- .../pastefy/service/PasteResponseMapper.kt | 6 + .../interaapps/pastefy/service/UserService.kt | 3 + .../de/interaapps/pastefy/util/StringUtil.kt | 8 +- .../src/main/resources/application.properties | 1 + .../pastefy/service/FolderServiceTest.kt | 1 + .../service/PasteCommentServiceTest.kt | 1 + frontend/src/components/SmallCardTag.vue | 19 ++ .../src/components/lists/PasteExploreCard.vue | 166 ++++++++++++++++++ frontend/src/components/lists/PasteList.vue | 11 +- frontend/src/types/paste.ts | 4 + frontend/src/views/ExploreView.vue | 14 +- 23 files changed, 405 insertions(+), 37 deletions(-) create mode 100644 backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteMetricsService.kt create mode 100644 frontend/src/components/SmallCardTag.vue create mode 100644 frontend/src/components/lists/PasteExploreCard.vue diff --git a/backend-kt/.idea/dataSources.local.xml b/backend-kt/.idea/dataSources.local.xml index 4c06b24..316e072 100644 --- a/backend-kt/.idea/dataSources.local.xml +++ b/backend-kt/.idea/dataSources.local.xml @@ -5,7 +5,6 @@ $ ` - true master_key @@ -17,9 +16,7 @@ - - true - + master_key @@ -31,7 +28,6 @@ ` - true master_key diff --git a/backend-kt/.idea/dataSources/data_sources_history.xml b/backend-kt/.idea/dataSources/data_sources_history.xml index 5e424ad..18d0ceb 100644 --- a/backend-kt/.idea/dataSources/data_sources_history.xml +++ b/backend-kt/.idea/dataSources/data_sources_history.xml @@ -4,7 +4,6 @@ $ ` - true mysql.8 @@ -23,9 +22,7 @@ - - true - + redis true @@ -44,7 +41,6 @@ ` - true clickhouse diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/PastefyProperties.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/PastefyProperties.kt index 744d822..6d527c5 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/PastefyProperties.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/PastefyProperties.kt @@ -45,6 +45,7 @@ data class PastefyProperties( var enabled: Boolean = false, var contentTtlSeconds: Long = 1_800, var accessCountTtlSeconds: Long = 1_800, + var metricsTtlSeconds: Long = 30, var cacheAfterAccesses: Long = 10, ) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/PasteResponse.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/PasteResponse.kt index 4acacfa..8d423c7 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/PasteResponse.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/PasteResponse.kt @@ -26,6 +26,9 @@ data class PasteResponse( var user: PublicUserDto? = null, var starred: Boolean? = null, + var starCount: Long = 0, + var commentCount: Long = 0, + var viewCount: Long = 0, var aiInfo: PasteAiInfoResponse? = null, var exists: Boolean = true, diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsService.kt index 2611b20..e7fd46a 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsService.kt @@ -118,6 +118,18 @@ class AnalyticsService( return response } + fun countVisitsByPaste(pasteKeys: Collection): Map { + val keys = pasteKeys.filter(String::isNotBlank).distinct() + if (keys.isEmpty()) return emptyMap() + val placeholders = keys.joinToString(",") { "?" } + return jdbc.queryForList( + "SELECT toString(paste_key) AS paste_key, count() AS visits FROM $table WHERE paste_key IN ($placeholders) GROUP BY paste_key", + *keys.toTypedArray(), + ).associate { + it["paste_key"].toString().trimEnd('\u0000') to (it["visits"] as Number).toLong() + } + } + @Scheduled(fixedDelayString = "\${pastefy.analytics.flush-interval-millis:1000}") fun flush() { do { diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteQueryAdapter.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteQueryAdapter.kt index f16992c..21c9d8a 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteQueryAdapter.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteQueryAdapter.kt @@ -7,6 +7,8 @@ import co.elastic.clients.elasticsearch._types.query_dsl.Query as ElasticQuery import de.interaapps.pastefy.config.PastefyProperties import de.interaapps.pastefy.dto.pastes.PasteResponse import de.interaapps.pastefy.dto.user.PublicUserDto +import de.interaapps.pastefy.service.PasteMetrics +import de.interaapps.pastefy.service.PasteMetricsService import de.interaapps.pastefy.service.query.LegacyFieldFilter import de.interaapps.pastefy.service.query.LegacyFilter import de.interaapps.pastefy.service.query.LegacyFilterGroup @@ -27,6 +29,7 @@ import org.springframework.stereotype.Component class ElasticPasteQueryAdapter( private val operations: ElasticsearchOperations, private val properties: PastefyProperties, + private val pasteMetricsService: PasteMetricsService, ) { private val indexCoordinates: IndexCoordinates get() = IndexCoordinates.of(properties.elasticsearch.indexName) @@ -38,9 +41,11 @@ class ElasticPasteQueryAdapter( .withSort(toSort(query)) .build() - return operations.search(nativeQuery, ElasticPasteDocument::class.java, indexCoordinates) + val documents = operations.search(nativeQuery, ElasticPasteDocument::class.java, indexCoordinates) .searchHits - .map { map(it.content, query) } + .map { it.content } + val metrics = pasteMetricsService.getMetrics(documents.map { it.key }) + return documents.map { map(it, query, metrics[it.key]) } } private fun elasticQuery(query: LegacyPasteQuery): ElasticQuery = ElasticQuery.of { root -> @@ -149,7 +154,7 @@ class ElasticPasteQueryAdapter( return if (orders.isEmpty()) Sort.unsorted() else Sort.by(orders) } - private fun map(document: ElasticPasteDocument, query: LegacyPasteQuery): PasteResponse = + private fun map(document: ElasticPasteDocument, query: LegacyPasteQuery, metrics: PasteMetrics?): PasteResponse = PasteResponse( exists = true, id = document.key, @@ -168,6 +173,9 @@ class ElasticPasteQueryAdapter( user = document.user?.let { PublicUserDto(id = it.id, name = it.uniqueName, displayName = it.name, avatar = it.avatar) }, + starCount = metrics?.starCount ?: 0, + commentCount = metrics?.commentCount ?: 0, + viewCount = metrics?.viewCount ?: 0, ) private fun normalizeField(field: String): String = FIELD_ALIASES[field] ?: field diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteCommentRepository.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteCommentRepository.kt index 8f148c5..5e361a6 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteCommentRepository.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteCommentRepository.kt @@ -3,6 +3,8 @@ package de.interaapps.pastefy.repositories import de.interaapps.pastefy.entities.PasteComment import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param interface PasteCommentRepository : JpaRepository { fun findAllByPaste(paste: String): List @@ -15,6 +17,10 @@ interface PasteCommentRepository : JpaRepository { fun findAllByPasteAndLineFromIsNotNullOrderByCreatedAtAsc(paste: String): List fun findAllByParentIdOrderByCreatedAtAsc(parentId: Int): List + + @Query("select c.paste as paste, count(c) as count from PasteComment c where c.paste in :pastes group by c.paste") + fun countGroupedByPaste(@Param("pastes") pastes: Collection): List + fun deleteByParentId(parentId: Int) fun deleteByPaste(paste: String) fun deleteByUserId(userId: String) diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteStarRepository.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteStarRepository.kt index 576ed05..9d94e99 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteStarRepository.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteStarRepository.kt @@ -2,6 +2,8 @@ package de.interaapps.pastefy.repositories import de.interaapps.pastefy.entities.PasteStar import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param interface PasteStarRepository : JpaRepository { @@ -11,8 +13,16 @@ interface PasteStarRepository : JpaRepository { fun countByPaste(paste: String): Int + @Query("select s.paste as paste, count(s) as count from PasteStar s where s.paste in :pastes group by s.paste") + fun countGroupedByPaste(@Param("pastes") pastes: Collection): List + fun findAllByPaste(paste: String): List fun findAllByUserId(userId: String): List fun deleteByPaste(paste: String) } + +interface PasteCountProjection { + val paste: String + val count: Long +} diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/FolderService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/FolderService.kt index 7939888..adc0bb2 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/FolderService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/FolderService.kt @@ -23,6 +23,7 @@ class FolderService( private val pasteRepository: PasteRepository, private val pasteService: PasteService, private val pasteResponseMapper: PasteResponseMapper, + private val pasteMetricsService: PasteMetricsService, private val properties: PastefyProperties, ) { @Transactional @@ -81,7 +82,10 @@ class FolderService( val pastes = if (fetchChildren && fetchPastes) { pasteRepository.findAllByFolderOrderByUpdatedAtDesc(folder.key) .filter { showPrivate || !it.isPrivate } - .map { pasteResponseMapper.map(it) } + .let { folderPastes -> + val metrics = pasteMetricsService.getMetrics(folderPastes.map { it.key }) + folderPastes.map { pasteResponseMapper.map(it, metrics = metrics[it.key]) } + } } else null return FolderResponse( exists = true, diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteCommentService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteCommentService.kt index 4d2f29c..cc08291 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteCommentService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteCommentService.kt @@ -20,6 +20,7 @@ class PasteCommentService( private val pasteService: PasteService, private val commentRepository: PasteCommentRepository, private val userRepository: UserRepository, + private val pasteMetricsService: PasteMetricsService, ) { fun list(pasteId: String, user: User?, page: Int, pageLimit: Int, line: Int?): List { pasteService.getAccessiblePasteOrFail(pasteId, user) @@ -69,6 +70,7 @@ class PasteCommentService( lineTo = request.lineTo, ), ) + pasteMetricsService.invalidate(pasteId) return map(saved, fetchReplies = false) } @@ -80,6 +82,7 @@ class PasteCommentService( if (!user.isAdmin && user.id != comment.userId && user.id != paste.userId) throw PermissionsDeniedException() commentRepository.deleteByParentId(commentId) commentRepository.delete(comment) + pasteMetricsService.invalidate(pasteId) } fun validate(request: CreatePasteCommentRequest) { diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteMetricsService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteMetricsService.kt new file mode 100644 index 0000000..1609188 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteMetricsService.kt @@ -0,0 +1,106 @@ +package de.interaapps.pastefy.service + +import com.fasterxml.jackson.databind.ObjectMapper +import de.interaapps.pastefy.config.PastefyProperties +import de.interaapps.pastefy.infrastructure.analytics.AnalyticsService +import de.interaapps.pastefy.repositories.PasteCommentRepository +import de.interaapps.pastefy.repositories.PasteStarRepository +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.ObjectProvider +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.stereotype.Service +import java.time.Duration + +data class PasteMetrics( + val starCount: Long = 0, + val commentCount: Long = 0, + val viewCount: Long = 0, +) + +@Service +class PasteMetricsService( + private val pasteStarRepository: PasteStarRepository, + private val pasteCommentRepository: PasteCommentRepository, + private val analyticsProvider: ObjectProvider, + private val redisProvider: ObjectProvider, + private val objectMapper: ObjectMapper, + private val properties: PastefyProperties, +) { + fun getMetrics(pasteKeys: Collection): Map { + val keys = pasteKeys.filter(String::isNotBlank).distinct() + if (keys.isEmpty()) return emptyMap() + + val cached = readCached(keys) + val missing = keys.filterNot(cached::containsKey) + if (missing.isEmpty()) return cached + + val loaded = loadMetrics(missing) + writeCached(loaded) + return buildMap { + keys.forEach { key -> + put(key, cached[key] ?: loaded[key] ?: PasteMetrics()) + } + } + } + + fun getMetrics(pasteKey: String): PasteMetrics = getMetrics(listOf(pasteKey))[pasteKey] ?: PasteMetrics() + + fun invalidate(pasteKey: String) { + if (!properties.redis.enabled) return + runCatching { + redisProvider.ifAvailable?.delete(cacheKey(pasteKey)) + }.onFailure { + LOGGER.warn("Unable to evict paste metrics cache for paste {}", pasteKey, it) + } + } + + private fun loadMetrics(keys: List): Map { + val stars = pasteStarRepository.countGroupedByPaste(keys).associate { it.paste to it.count } + val comments = pasteCommentRepository.countGroupedByPaste(keys).associate { it.paste to it.count } + val views = analyticsProvider.ifAvailable?.countVisitsByPaste(keys) ?: emptyMap() + return keys.associateWith { key -> + PasteMetrics( + starCount = stars[key] ?: 0, + commentCount = comments[key] ?: 0, + viewCount = views[key] ?: 0, + ) + } + } + + private fun readCached(keys: List): Map { + if (!properties.redis.enabled) return emptyMap() + val redis = redisProvider.ifAvailable ?: return emptyMap() + return runCatching { + val cacheKeys = keys.map(::cacheKey) + + val values = redis.opsForValue().multiGet(cacheKeys) ?: emptyList() + + keys.zip(values).mapNotNull { (pasteKey, value) -> + value?.let { pasteKey to objectMapper.readValue(it, PasteMetrics::class.java) } + }.toMap() + }.onFailure { + LOGGER.warn("Unable to read paste metrics cache", it) + }.getOrDefault(emptyMap()) + } + + private fun writeCached(metrics: Map) { + if (!properties.redis.enabled || metrics.isEmpty()) return + + val redis = redisProvider.ifAvailable ?: return + val ttl = Duration.ofSeconds(properties.redis.metricsTtlSeconds) + + runCatching { + metrics.forEach { (pasteKey, value) -> + redis.opsForValue().set(cacheKey(pasteKey), objectMapper.writeValueAsString(value), ttl) + } + }.onFailure { + LOGGER.warn("Unable to write paste metrics cache", it) + } + } + + private fun cacheKey(pasteKey: String) = "paste:$pasteKey:metrics" + + companion object { + private val LOGGER = LoggerFactory.getLogger(PasteMetricsService::class.java) + } +} diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteQueryService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteQueryService.kt index 94e82bf..d38ec20 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteQueryService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteQueryService.kt @@ -21,6 +21,7 @@ class PasteQueryService( private val parser: LegacyPasteQueryParser, private val jpa: JpaPasteQueryAdapter, private val mapper: PasteResponseMapper, + private val pasteMetricsService: PasteMetricsService, private val elasticProvider: ObjectProvider, ) { fun list( @@ -44,15 +45,19 @@ class PasteQueryService( starredBy = starredBy, ) return elasticProvider.ifAvailable?.find(query) - ?: jpa.find(query).map { - mapper.map( - it, - user, - fetchStar = user != null, - fetchUser = true, - withAiInfo = query.withAiInfo, - shortenContent = query.shortenContent, - ) + ?: jpa.find(query).let { pastes -> + val metrics = pasteMetricsService.getMetrics(pastes.map { it.key }) + pastes.map { + mapper.map( + it, + user, + fetchStar = user != null, + fetchUser = true, + withAiInfo = query.withAiInfo, + shortenContent = query.shortenContent, + metrics = metrics[it.key], + ) + } } } @@ -79,8 +84,16 @@ class PasteQueryService( additionalFilters = additionalFilters, ) return elasticProvider.ifAvailable?.find(query) - ?: jpa.find(query).map { - mapper.map(it, shortenContent = query.shortenContent, withAiInfo = query.withAiInfo) + ?: jpa.find(query).let { pastes -> + val metrics = pasteMetricsService.getMetrics(pastes.map { it.key }) + pastes.map { + mapper.map( + it, + shortenContent = query.shortenContent, + withAiInfo = query.withAiInfo, + metrics = metrics[it.key], + ) + } } } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteResponseMapper.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteResponseMapper.kt index de194e1..f81e9f2 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteResponseMapper.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteResponseMapper.kt @@ -21,6 +21,7 @@ class PasteResponseMapper( private val userRepository: UserRepository, private val userService: UserService, private val aiInfoRepository: PasteAIInfoRepository, + private val pasteMetricsService: PasteMetricsService, properties: PastefyProperties, ) { private val serverName = properties.serverName.trimEnd('/') @@ -32,7 +33,9 @@ class PasteResponseMapper( fetchUser: Boolean = false, withAiInfo: Boolean = false, shortenContent: Boolean = false, + metrics: PasteMetrics? = null, ): PasteResponse { + val resolvedMetrics = metrics ?: pasteMetricsService.getMetrics(paste.key) val content = pasteService.getContent(paste) .orEmpty() .let { raw -> @@ -59,6 +62,9 @@ class PasteResponseMapper( tags = pasteTagRepository.findAllByPaste(paste.key).map { it.tag }, user = if (fetchUser) paste.userId?.let(userRepository::findById)?.orElse(null)?.toPublicDto() else null, starred = if (fetchStar && currentUser != null) userService.hasStarred(currentUser, paste) else null, + starCount = resolvedMetrics.starCount, + commentCount = resolvedMetrics.commentCount, + viewCount = resolvedMetrics.viewCount, aiInfo = if (withAiInfo) paste.id?.let(aiInfoRepository::findById)?.orElse(null)?.let { PasteAiInfoResponse( dangerous = it.dangerous, diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/UserService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/UserService.kt index d0b2531..f43b6f0 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/UserService.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/UserService.kt @@ -29,6 +29,7 @@ class UserService( private val sharedPasteRepository: SharedPasteRepository, private val elasticProvider: ObjectProvider, private val pasteService: PasteService, + private val pasteMetricsService: PasteMetricsService, ) { fun get(id: String): User? { @@ -100,12 +101,14 @@ class UserService( ) pasteStarRepository.save(pasteStar) + pasteMetricsService.invalidate(paste.key) elasticProvider.ifAvailable?.updateStars(paste) } @Transactional fun unstar(user: User, paste: Paste) { pasteStarRepository.deleteByPasteAndUserId(paste.key, user.id) + pasteMetricsService.invalidate(paste.key) elasticProvider.ifAvailable?.updateStars(paste) } diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/util/StringUtil.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/util/StringUtil.kt index 5368b28..62e68d6 100644 --- a/backend-kt/src/main/kotlin/de/interaapps/pastefy/util/StringUtil.kt +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/util/StringUtil.kt @@ -1,7 +1,13 @@ package de.interaapps.pastefy.util fun String?.shorten(): String? { - return this?.lineSequence()?.take(7)?.joinToString("\n") { line -> + val lineSequence = this?.lineSequence() + + if (lineSequence?.count() == 1) { + return if (this!!.length > 303) this.take(300) + "..." else this + } + + return lineSequence?.take(8)?.joinToString("\n") { line -> if (line.length > 40) line.take(37) + "..." else line } ?: return this diff --git a/backend-kt/src/main/resources/application.properties b/backend-kt/src/main/resources/application.properties index 087f4a2..18f1e45 100644 --- a/backend-kt/src/main/resources/application.properties +++ b/backend-kt/src/main/resources/application.properties @@ -65,6 +65,7 @@ pastefy.rate-limiter.limit=${PASTEFY_RATE_LIMITER_LIMIT:5} pastefy.redis.enabled=${PASTEFY_REDIS_ENABLED:false} pastefy.redis.content-ttl-seconds=${PASTEFY_REDIS_CONTENT_TTL_SECONDS:1800} pastefy.redis.access-count-ttl-seconds=${PASTEFY_REDIS_ACCESS_COUNT_TTL_SECONDS:1800} +pastefy.redis.metrics-ttl-seconds=${PASTEFY_REDIS_METRICS_TTL_SECONDS:30} pastefy.redis.cache-after-accesses=${PASTEFY_REDIS_CACHE_AFTER_ACCESSES:10} spring.data.redis.host=${REDIS_HOST:localhost} spring.data.redis.port=${REDIS_PORT:6379} diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/FolderServiceTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/FolderServiceTest.kt index e66bef0..1e642cd 100644 --- a/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/FolderServiceTest.kt +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/FolderServiceTest.kt @@ -17,6 +17,7 @@ class FolderServiceTest { mock(PasteRepository::class.java), mock(PasteService::class.java), mock(PasteResponseMapper::class.java), + mock(PasteMetricsService::class.java), PastefyProperties(listPastes = false), ) diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/PasteCommentServiceTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/PasteCommentServiceTest.kt index 1d53171..22ba6d0 100644 --- a/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/PasteCommentServiceTest.kt +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/PasteCommentServiceTest.kt @@ -15,6 +15,7 @@ class PasteCommentServiceTest { mock(PasteService::class.java), mock(PasteCommentRepository::class.java), mock(UserRepository::class.java), + mock(PasteMetricsService::class.java), ) @Test diff --git a/frontend/src/components/SmallCardTag.vue b/frontend/src/components/SmallCardTag.vue new file mode 100644 index 0000000..d3b9cb9 --- /dev/null +++ b/frontend/src/components/SmallCardTag.vue @@ -0,0 +1,19 @@ + + diff --git a/frontend/src/components/lists/PasteExploreCard.vue b/frontend/src/components/lists/PasteExploreCard.vue new file mode 100644 index 0000000..f422a6e --- /dev/null +++ b/frontend/src/components/lists/PasteExploreCard.vue @@ -0,0 +1,166 @@ + + diff --git a/frontend/src/components/lists/PasteList.vue b/frontend/src/components/lists/PasteList.vue index 05f16e8..47e7b84 100644 --- a/frontend/src/components/lists/PasteList.vue +++ b/frontend/src/components/lists/PasteList.vue @@ -2,7 +2,7 @@ import { useAsyncState } from '@vueuse/core' import { client } from '@/main.ts' import type { Paste } from '@/types/paste.ts' -import PasteCard from '@/components/lists/PasteCard.vue' +import PasteExploreCard from '@/components/lists/PasteExploreCard.vue' import ErrorContainer from '@/components/ErrorContainer.vue' import LoadingContainer from '@/components/LoadingContainer.vue' import Pagination from '@/components/Pagination.vue' @@ -66,9 +66,12 @@ watch( From c29a1ce406fdac2f0441dd6dced1eeebf44c483c Mon Sep 17 00:00:00 2001 From: juliangojani Date: Fri, 5 Jun 2026 14:43:32 +0200 Subject: [PATCH 15/22] New Paste card --- backend-kt/.gitignore | 1 + frontend/src/components/Highlighted.vue | 6 ++++-- frontend/src/components/paste/PasteCommentLinePreview.vue | 2 ++ frontend/src/views/PublicUserView.vue | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/backend-kt/.gitignore b/backend-kt/.gitignore index cb4d618..4a3a4f0 100644 --- a/backend-kt/.gitignore +++ b/backend-kt/.gitignore @@ -17,6 +17,7 @@ out/ !**/src/test/**/out/ src/main/resources/static +src/main/resources/application-prod.properties ### Kotlin ### .kotlin diff --git a/frontend/src/components/Highlighted.vue b/frontend/src/components/Highlighted.vue index 7b6ced7..f444d9e 100644 --- a/frontend/src/components/Highlighted.vue +++ b/frontend/src/components/Highlighted.vue @@ -20,6 +20,7 @@ const props = defineProps<{ getCopyContents?: () => Promise lineCommentMarkers?: PasteCommentMarker[] enableLineComments?: boolean + small?: boolean }>() const emit = defineEmits<{ @@ -31,6 +32,7 @@ const markerByLine = computed( () => new Map(props.lineCommentMarkers?.map((marker) => [marker.line, marker]) || []), ) const lineCount = computed(() => { + if (!props.contents) return 0 let count = 1 for (let index = 0; index < props.contents.length; index++) { if (props.contents.charCodeAt(index) === 10) count++ @@ -135,8 +137,8 @@ onMounted(async () => {