` kompatibel ersetzen.
+- [ ] Meta-Tags, Canonical URL, OpenGraph, Twitter Cards, Paste-Vorschau, Tags, Autor und AI-Beschreibung übernehmen.
+- [ ] `PASTEFY_META_TAGS`, `PASTEFY_META_TAGS_PREVIEW_LENGTH`, `SERVER_NAME`, `PASTEFY_CUSTOM_HEADER` und `PASTEFY_CUSTOM_BODY` unterstützen.
+- [ ] SPA-Fallback für unbekannte Nicht-API-Pfade nachbauen.
+- [ ] `/assets/**` mit `Cache-Control: public, max-age=604800, immutable` ausliefern.
+- [ ] Unbekannte `/api/**`-Pfade weiterhin als 404 behandeln.
+
+## Auth und OAuth2
+
+- [ ] Header-Auswertung exakt prüfen: `x-auth-key`, Bearer und Basic Auth inklusive Legacy-Priorität.
+- [ ] Anonyme Admin-Aufrufe wieder mit 401 statt 403 beantworten: Legacy-`admin` ruft zuerst `auth` auf.
+- [ ] Prüfen, ob die historische Abhängigkeit von mindestens einem konfigurierten OAuth2-Provider beim Auth-Key-Login beibehalten oder bewusst korrigiert werden soll.
+- [ ] `@Authenticated`, `@AdminRoute`, `@RejectBlocked`, `@RejectAwaitingAccess`, `@LoginRequiredForRead`, `@LoginRequiredForCreate` und `@PublicPastesEnabled` routeweise gegen die alten `@With`-Middlewares auditieren.
+- [ ] Permission-Verhalten exakt prüfen: Nicht-`ACCESS_TOKEN`-Keys dürfen alle Scopes; `ACCESS_TOKEN`-Keys nur exakte oder Namespace-Scopes.
+- [x] `POST /api/v2/auth/iaea` implementieren: InteraApps-App-Credentials prüfen, User suchen, `ACCESS_TOKEN` mit `appScopeList` erzeugen und Key-String liefern.
+- [ ] OAuth-Provider InteraApps, Google, GitHub, Twitch, Discord und OIDC end-to-end gegen die Legacy-Profile mappen.
+- [ ] Provider-Defaults und Scopes übernehmen, insbesondere InteraApps `user:read contacts.accepted:read`.
+- [ ] Login-Callback kompatibel machen: User nach `authId` und Provider finden oder erstellen, eindeutigen `uniqueName` bilden, Profildaten bei jedem Login aktualisieren, `AWAITING_ACCESS` beachten und `USER`-AuthKey mit Access-/Refresh-Token speichern.
+- [ ] OAuth-State-Schutz beibehalten, ohne die Legacy-Routen oder Redirects zu brechen.
+- [ ] Redirect nach Login exakt `/auth?key=
` liefern.
+- [ ] Legacy-Env-Namen als Aliase unterstützen oder dokumentiert migrieren: `OAUTH2_*`, `PASTEFY_LOGIN_REQUIRED`, `PASTEFY_GRANT_ACCESS_REQUIRED`.
+
+## Analytics
+
+- [ ] Die bereits implementierten ClickHouse-Queries gegen das alte Backend verifizieren.
+- [ ] `GET /api/v2/analytics/admin` auf identische Auth-, Admin- und Permission-Semantik prüfen.
+- [ ] `GET /api/v2/analytics/pastes/{id}` auf Owner-/Admin-Regeln und Filter `paste_key` prüfen.
+- [ ] `GET /api/v2/analytics/user` auf Filter `paste_user_id` prüfen.
+- [ ] Query-Parameter exakt unterstützen: `from`, `to`, `interval`, `group_by`, `include_summary`, `include_breakdown` sowie alle Legacy-Filterfelder.
+- [ ] Analytics-Response gegen die Snake-Case-Feldnamen des API-Vertrags prüfen: `total_visits`, `unique_visitors`, `bot_visits`, `bot_tracking_enabled`, `series`, `breakdown`.
+- [x] PAGE-Tracking an `GET /api/v2/paste/{id}?from_frontend=true` anbinden.
+- [x] RAW-Tracking an `GET /{id}/raw` anbinden.
+- [ ] Bounded Queue, Requeue bei ClickHouse-Ausfall, Drop-Zähler, Flush beim Shutdown und optionale GeoIP-Datenbank unter Last testen.
+- [ ] Flyway-ClickHouse-Migration und Analytics-Service auf dieselben Tabellen-, TTL- und Identifier-Regeln festlegen.
+
+## AI, Engagement und Hintergrundjobs
+
+- [ ] Engagement-Service ergänzen: atomar erstellen/inkrementieren, Elasticsearch aktualisieren und AI-Enqueue prüfen.
+- [x] Engagement-Hooks an GET-Paste, Raw-Paste und Fork-Erstellung anbinden.
+- [x] AI-Info-Service an Engagement-Schwelle und manuellen Admin-Trigger anbinden.
+- [ ] AI-Metadaten-Jobs gegen Race Conditions testen: Paste-Version vor und nach Modellaufruf prüfen, veraltete Jobs neu einreihen.
+- [ ] Legacy-`generateTags` für `CreatePasteRequest.ai=true` mit Spring AI portieren.
+- [ ] Legacy-`generateTagDescription` portieren und beim erstmaligen Erstellen eines Tags asynchron ausführen.
+- [ ] AI-Provider- und Modellnamen in gespeicherten Metadaten stabil halten.
+- [ ] Job-Leasing, Retry-Backoff, Max-Attempts und Sweeper mit mehreren App-Instanzen testen.
+- [ ] Fehler- und Metrikpfad für dauerhaft fehlgeschlagene Jobs ergänzen.
+
+## Speicherung, Cache und Suche
+
+### S3 / MinIO
+
+- [ ] S3-Referenzformat gegen bestehende Production-Daten prüfen und Legacy-JSON mit `server`, `bucket`, `objectName`, `region`, `etag` weiterhin lesen.
+- [ ] Objektpfade exakt kompatibel halten: `pastes/////contents.txt`.
+- [ ] Storage-Tags `userId` und `paste` übernehmen.
+- [ ] Schwellenwert `MINIO_PASTESIZE_THRESHOLD` kompatibel anwenden.
+- [ ] Fehlerverhalten und Transaktionsgrenzen prüfen, damit DB-Referenz und S3-Objekt nicht auseinanderlaufen.
+
+### Redis
+
+- [ ] Redis-Key-Format kompatibel halten: `paste:` und `paste::accessCount`.
+- [ ] TTL `30 Minuten` und Cache-Schwelle `> 10` Zugriffe gegen Legacy-Verhalten prüfen.
+- [ ] Cache bei Änderungen und Löschungen invalidieren.
+- [ ] Redis-Ausfälle weiterhin degradieren lassen, ohne Requests fehlschlagen zu lassen.
+
+### Elasticsearch
+
+- [ ] Gemeinsamen Query-Service an `ElasticPasteService` anbinden.
+- [ ] Alle Dokumentfelder und Nested-User-Felder gegen das alte Mapping prüfen.
+- [ ] Updates für Tags, Engagement, Stars und User sicher auslösen.
+- [ ] Startup-Migrationen mit parallelen Instanzen testen; Reindexing und Alias-Swap dürfen keine Daten verlieren.
+- [ ] Schreibvorgänge während eines Reindex-Laufs absichern: Wartungsmodus, Dual-Write oder finaler Sync vor Alias-Swap festlegen.
+- [ ] Backfill für `indexed_in_elastic=false` als Spring-Boot-Job oder Admin-Command bereitstellen.
+- [ ] Rollback-Strategie für versionierte Indizes dokumentieren.
+
+## Datenbank und periodische Jobs
+
+- [ ] Liquibase-Changesets für das bestehende MySQL-Production-Schema erstellen, ohne bestehende Daten zu verändern.
+- [ ] Liquibase-Baseline-/Changelog-Strategie für bereits laufende Installationen dokumentieren.
+- [ ] Entity-Nullability gegen das reale Production-Schema auditieren. Mehrere Legacy-Spalten sind nullable, während Kotlin-Entities aktuell `nullable=false` setzen.
+- [ ] `AuthKey.scopes` gegen das Production-Schema prüfen: Dort ist `scopes` ein `TEXT`, nicht zwingend ein natives MySQL-`JSON`.
+- [ ] ClickHouse-Flyway-Migrationen mit dem realen ClickHouse-Treiber und einer leeren Datenbank testen.
+- [ ] Scheduled Job zum Löschen abgelaufener Pastes jede Minute portieren.
+- [ ] Gelöschte Pastes vollständig aus SQL, Redis, S3 und Elasticsearch entfernen.
+
+## CLI- und Betriebs-Parity
+
+- [ ] Entscheiden, welche alten CLI-Kommandos als Spring Boot `ApplicationRunner`, Admin-Job oder separate Commands benötigt werden.
+- [ ] Ersatz für `automigrate` bereitstellen: Liquibase-Deployment-Schritt.
+- [ ] Ersatz für `automigrateelastic` bereitstellen: Kotlin-Startup-Migration oder gezielt startbarer Migrationsmodus.
+- [ ] `syncelastic` als kontrollierten Backfill portieren.
+- [ ] `syncminio` als kontrollierten Backfill portieren.
+- [ ] `movesmallfilestodb` als kontrollierten Rückmigration-Job portieren.
+- [ ] Health-, Readiness- und Liveness-Endpunkte ergänzen, insbesondere für MySQL, Redis, S3, Elasticsearch und ClickHouse.
+- [ ] Observability ergänzen: Job-Fehler, Analytics-Drops, Elasticsearch-Backfill, S3-Fehler und OAuth-Fehler messen.
+- [ ] Graceful Shutdown für Analytics-Flush und Hintergrundjobs prüfen.
+
+## Plugin-System
+
+- [ ] Plugin-Erkennung aus `plugins/`, `plugin.json`, Backend-Lifecycle-Hooks und Custom-Config-Mappings portieren.
+- [ ] Frontend-Plugin-Assets unter `/assets/plugins/` ausliefern.
+- [ ] Plugin-Entrypoints in das ausgelieferte Frontend-HTML injizieren.
+
+## Verifikation vor Umschaltung
+
+- [ ] Eine Contract-Test-Suite erstellen, die altes und neues Backend mit denselben Requests aufruft und Status, Header, Content-Type und JSON-Struktur vergleicht.
+- [ ] Golden Tests für alle oben genannten Routen ergänzen, inklusive anonymer, eingeloggter, Admin-, Blocked- und Awaiting-Access-Fälle.
+- [ ] Golden Tests für Query-Parameter, Pagination-Header, Null-Felder, Defaultwerte und Fehlerantworten ergänzen.
+- [ ] Tests mit Elasticsearch deaktiviert und aktiviert ausführen.
+- [ ] Tests mit Redis deaktiviert und aktiviert ausführen.
+- [ ] Tests mit S3 deaktiviert und aktiviert ausführen.
+- [ ] Tests für OAuth-Redirect-URLs und Callback-Routen ergänzen.
+- [ ] Tests für bestehende Production-S3-Referenzen, MySQL-Daten und Elasticsearch-Legacy-Indizes ergänzen.
+- [ ] Erst nach grüner Contract-Suite Traffic auf `backend-kt` umschalten.
+
+## Tests
+- [ ] Unit Tests
+ - [ ] Visibility Rules
+ - [ ] Expiry Logic
+ - [ ] Paste Key Generation
+ - [ ] Rate Limiting
+ - [ ] Permission Checks
+ - [ ] Auth implementation
+- [ ] Integration Tests using Testcontainers
+ - [ ] S3/MinIO Integration
+ - [ ] Redis Caching
+ - [ ] Elasticsearch Queries
+ - [ ] ClickHouse Analytics
+ - [x] MySQL Database Interactions
+ - [ ] OAuth2 Flows with Mock Providers
+- [x] HTTP-Smoke-Tests für Snake-Case-Stats, App-Info, Public-User und Legacy-Raw-404 ergänzen.
+- [ ] API-Contract-Tests für alle Routen ergänzen.
diff --git a/backend-kt/build.gradle.kts b/backend-kt/build.gradle.kts
new file mode 100644
index 00000000..b6c06b00
--- /dev/null
+++ b/backend-kt/build.gradle.kts
@@ -0,0 +1,99 @@
+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.1.7"
+
+dependencies {
+ implementation("org.springframework.boot:spring-boot-starter-data-jpa")
+ implementation("org.springframework.boot:spring-boot-starter-data-elasticsearch")
+ implementation("org.springframework.boot:spring-boot-starter-data-redis")
+ implementation("org.springframework.boot:spring-boot-starter-jdbc")
+ implementation("org.springframework.boot:spring-boot-starter-cache")
+ implementation("org.springframework.boot:spring-boot-starter-mail")
+ implementation("org.apache.commons:commons-pool2")
+ // 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("com.github.ben-manes.caffeine:caffeine")
+ implementation("org.jetbrains.kotlin:kotlin-reflect")
+ implementation("com.squareup.okhttp3:okhttp:4.11.0")
+ implementation("org.springframework.ai:spring-ai-starter-model-anthropic")
+ implementation("org.springframework.ai:spring-ai-starter-model-google-genai")
+ implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13")
+ implementation("com.vladmihalcea:hibernate-types-60:2.21.1")
+
+ implementation("io.minio:minio:8.5.5")
+ implementation("com.clickhouse:clickhouse-jdbc:0.9.8:all")
+ implementation("com.maxmind.geoip2:geoip2:4.3.1")
+ implementation("org.flywaydb:flyway-core:11.9.0")
+ implementation("org.flywaydb:flyway-database-clickhouse:10.24.0")
+ implementation("org.liquibase:liquibase-core")
+
+ implementation("org.apache.xmlgraphics:batik-codec: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()
+}
diff --git a/backend-kt/development/starquery/.gitignore b/backend-kt/development/starquery/.gitignore
new file mode 100644
index 00000000..1066f8c2
--- /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/development/starquery/starquery-meta.sqlite b/backend-kt/development/starquery/starquery-meta.sqlite
new file mode 100644
index 00000000..1477a327
Binary files /dev/null and b/backend-kt/development/starquery/starquery-meta.sqlite differ
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 00000000..fe9ac284
Binary files /dev/null and b/backend-kt/development/starquery/starquery-meta.sqlite-shm differ
diff --git a/backend-kt/development/starquery/starquery-meta.sqlite-wal b/backend-kt/development/starquery/starquery-meta.sqlite-wal
new file mode 100644
index 00000000..e69de29b
diff --git a/backend-kt/docker-compose.yaml b/backend-kt/docker-compose.yaml
new file mode 100644
index 00000000..a64b6bfb
--- /dev/null
+++ b/backend-kt/docker-compose.yaml
@@ -0,0 +1,142 @@
+services:
+ mysql:
+ image: 'mysql:8.4'
+ mem_limit: 768m
+ memswap_limit: 768m
+ cpus: 0.75
+ 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'
+ command:
+ - '--performance-schema=OFF'
+ - '--innodb-buffer-pool-size=256M'
+ - '--max-connections=40'
+ - '--table-open-cache=256'
+ healthcheck:
+ test: ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'myuser', '-psecret']
+ interval: 10s
+ timeout: 5s
+ retries: 10
+
+ redis:
+ image: 'redis:7.4-alpine'
+ mem_limit: 128m
+ memswap_limit: 128m
+ cpus: 0.25
+ ports:
+ - '6379:6379'
+ volumes:
+ - 'pastefy-test-redis-data:/data'
+ command:
+ - 'redis-server'
+ - '--appendonly'
+ - 'yes'
+ - '--maxmemory'
+ - '64mb'
+ - '--maxmemory-policy'
+ - 'allkeys-lru'
+ healthcheck:
+ test: ['CMD', 'redis-cli', 'ping']
+ interval: 10s
+ timeout: 5s
+ retries: 10
+
+ minio:
+ image: 'minio/minio:RELEASE.2025-04-22T22-12-26Z'
+ mem_limit: 256m
+ memswap_limit: 256m
+ cpus: 0.25
+ 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'
+ mem_limit: 1536m
+ memswap_limit: 1536m
+ cpus: 1.0
+ environment:
+ - 'discovery.type=single-node'
+ - 'xpack.security.enabled=false'
+ - 'xpack.security.http.ssl.enabled=false'
+ - 'ES_JAVA_OPTS=-Xms768m -Xmx768m'
+ 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:26.5'
+ mem_limit: 768m
+ memswap_limit: 768m
+ cpus: 0.75
+ labels:
+ org.springframework.boot.ignore: true
+ org.springframework.boot.service-connection: none
+ environment:
+ - 'CLICKHOUSE_DB=pastefy'
+ - 'CLICKHOUSE_USER=pastefy'
+ - 'CLICKHOUSE_PASSWORD=pastefy'
+ - 'CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1'
+ - 'CLICKHOUSE_MAX_MEMORY_USAGE=536870912'
+ 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
+ starquery:
+ image: interaapps/starquery:latest
+ environment:
+ HOST: 0.0.0.0
+ PORT: 8080
+ STARQUERY_MODE: hosted
+ STARQUERY_SERVER_NAME: Pastefy Test Starquery
+ STARQUERY_META_DRIVER: sqlite
+ STARQUERY_META_SQLITE_PATH: /var/lib/starquery/starquery-meta.sqlite
+ ports:
+ - '8081:8080'
+ volumes:
+ - ./development/starquery:/var/lib/starquery
+
+
+volumes:
+ pastefy-test-mysql-data:
+ 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
\ No newline at end of file
diff --git a/backend-kt/gradle.properties b/backend-kt/gradle.properties
new file mode 100644
index 00000000..7fc6f1ff
--- /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 00000000..61285a65
Binary files /dev/null and b/backend-kt/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/backend-kt/gradle/wrapper/gradle-wrapper.properties b/backend-kt/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..19a6bdeb
--- /dev/null
+++ b/backend-kt/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/backend-kt/gradlew b/backend-kt/gradlew
new file mode 100755
index 00000000..adff685a
--- /dev/null
+++ b/backend-kt/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# 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
+
+
+
+# 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
+ 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
+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" )
+
+ 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
+
+
+# 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" \
+ -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.
+#
+# 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 00000000..c4bdd3ab
--- /dev/null
+++ b/backend-kt/gradlew.bat
@@ -0,0 +1,93 @@
+@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
+@rem SPDX-License-Identifier: Apache-2.0
+@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=.
+@rem This is normally unused
+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% equ 0 goto execute
+
+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
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+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
+
+
+
+@rem Execute Gradle
+"%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% 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!
+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
+
+:omega
diff --git a/backend-kt/settings.gradle.kts b/backend-kt/settings.gradle.kts
new file mode 100644
index 00000000..07dd9841
--- /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 00000000..fdefaf17
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/Main.kt
@@ -0,0 +1,20 @@
+package de.interaapps.pastefy
+
+import de.interaapps.pastefy.config.PastefyProperties
+import org.springframework.boot.autoconfigure.SpringBootApplication
+import org.springframework.boot.context.properties.EnableConfigurationProperties
+import org.springframework.boot.runApplication
+import org.springframework.cache.annotation.EnableCaching
+import org.springframework.scheduling.annotation.EnableScheduling
+import org.springframework.scheduling.annotation.EnableAsync
+
+@SpringBootApplication
+@EnableCaching
+@EnableScheduling
+@EnableAsync
+@EnableConfigurationProperties(PastefyProperties::class)
+class ApiApplication
+
+fun main(args: Array) {
+ runApplication(*args)
+}
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
new file mode 100644
index 00000000..4a305910
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/AuthArgumentResolver.kt
@@ -0,0 +1,29 @@
+package de.interaapps.pastefy.auth
+
+import de.interaapps.pastefy.auth.annotations.CurrentAuthKey
+import de.interaapps.pastefy.auth.annotations.CurrentUser
+import de.interaapps.pastefy.entities.AuthKey
+import de.interaapps.pastefy.entities.User
+import jakarta.servlet.http.HttpServletRequest
+import org.springframework.core.MethodParameter
+import org.springframework.web.bind.support.WebDataBinderFactory
+import org.springframework.web.context.request.NativeWebRequest
+import org.springframework.web.method.support.HandlerMethodArgumentResolver
+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
+
+ override fun resolveArgument(
+ parameter: MethodParameter,
+ mavContainer: ModelAndViewContainer?,
+ webRequest: NativeWebRequest,
+ binderFactory: WebDataBinderFactory?,
+ ): Any? {
+ val request = webRequest.getNativeRequest(HttpServletRequest::class.java) ?: return null
+ val auth = request.getAttribute(RequestAuthAttributes.AUTH) as? RequestAuth ?: return null
+ return if (parameter.hasParameterAnnotation(CurrentUser::class.java)) auth.user else auth.authKey
+ }
+}
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 00000000..96a86c56
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/AuthFilter.kt
@@ -0,0 +1,50 @@
+package de.interaapps.pastefy.auth
+
+import de.interaapps.pastefy.repositories.AuthKeyRepository
+import de.interaapps.pastefy.repositories.UserRepository
+import jakarta.servlet.FilterChain
+import jakarta.servlet.http.HttpServletRequest
+import jakarta.servlet.http.HttpServletResponse
+import org.springframework.http.HttpHeaders
+import org.springframework.stereotype.Component
+import org.springframework.web.filter.OncePerRequestFilter
+import java.nio.charset.StandardCharsets
+import java.util.Base64
+
+@Component
+class AuthFilter(
+ private val authKeyRepository: AuthKeyRepository,
+ private val userRepository: UserRepository,
+) : OncePerRequestFilter() {
+ override fun doFilterInternal(
+ request: HttpServletRequest,
+ response: HttpServletResponse,
+ filterChain: FilterChain
+ ) {
+ extractAuthKey(request)
+ ?.let(authKeyRepository::findByKey)
+ ?.let { authKey ->
+ userRepository.findById(authKey.userId)
+ .orElse(null)
+ ?.let { user -> request.setAttribute(RequestAuthAttributes.AUTH, RequestAuth(authKey, user)) }
+ }
+
+ filterChain.doFilter(request, response)
+ }
+
+ private fun extractAuthKey(request: HttpServletRequest): String? {
+ request.getHeader("x-auth-key")?.takeIf { it.isNotBlank() }?.let { return it }
+ val authorization = request.getHeader(HttpHeaders.AUTHORIZATION) ?: return null
+ if (authorization.startsWith("Bearer ", ignoreCase = true)) {
+ return authorization.substring(7).trim().takeIf { it.isNotBlank() }
+ }
+ if (authorization.startsWith("Basic ", ignoreCase = true)) {
+ return runCatching {
+ String(Base64.getDecoder().decode(authorization.substring(6)), StandardCharsets.UTF_8)
+ .substringAfter(':', "")
+ .takeIf { it.isNotBlank() }
+ }.getOrNull()
+ }
+ return null
+ }
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/AuthInterceptor.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/AuthInterceptor.kt
new file mode 100644
index 00000000..ce7d0d51
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/AuthInterceptor.kt
@@ -0,0 +1,58 @@
+package de.interaapps.pastefy.auth
+
+import de.interaapps.pastefy.auth.annotations.*
+import de.interaapps.pastefy.auth.ratelimit.InMemoryRateLimiter
+import de.interaapps.pastefy.config.PastefyProperties
+import de.interaapps.pastefy.entities.User
+import de.interaapps.pastefy.exceptions.*
+import jakarta.servlet.http.HttpServletRequest
+import jakarta.servlet.http.HttpServletResponse
+import org.springframework.core.annotation.AnnotatedElementUtils
+import org.springframework.stereotype.Component
+import org.springframework.web.method.HandlerMethod
+import org.springframework.web.servlet.HandlerInterceptor
+
+@Component
+class AuthInterceptor(
+ private val properties: PastefyProperties,
+ private val rateLimiter: InMemoryRateLimiter,
+) : HandlerInterceptor {
+ override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
+ if (handler !is HandlerMethod) return true
+ val auth = request.getAttribute(RequestAuthAttributes.AUTH) as? RequestAuth
+
+ annotation(handler)?.let { annotation ->
+ if (properties.rateLimiter.enabled) {
+ val limit = annotation.limit.takeIf { it > 0 } ?: properties.rateLimiter.limit
+ val window = annotation.windowMillis.takeIf { it > 0 } ?: properties.rateLimiter.windowMillis
+ val key = "${handler.method.toGenericString()}:${request.remoteAddr}"
+ if (!rateLimiter.tryAcquire(key, limit, window)) throw TooManyRequestsException()
+ }
+ }
+
+ if (has(handler) && auth == null) throw AuthenticationException()
+ if (has(handler) && auth?.user?.type != User.Type.ADMIN) throw PermissionsDeniedException()
+ if (has(handler) && auth?.user?.type == User.Type.BLOCKED) throw BlockedException()
+ if (has(handler) && auth?.user?.type == User.Type.AWAITING_ACCESS) throw AwaitingAccessException()
+ if (has(handler) && properties.loginRequiredRead && auth?.user?.roleCheck() != true) {
+ throw AuthenticationException()
+ }
+ if (has(handler) && properties.loginRequiredCreate && auth?.user?.roleCheck() != true) {
+ throw AuthenticationException()
+ }
+ if (has(handler) && !properties.publicPastesEnabled) throw FeatureDisabledException()
+ annotation(handler)?.let { permission ->
+ auth?.authKey?.let { authKey ->
+ if (permission.anyOf.isNotEmpty()) authKey.checkPermissions(*permission.anyOf)
+ permission.allOf.forEach { authKey.checkPermissions(it) }
+ }
+ }
+ return true
+ }
+
+ private inline fun has(handler: HandlerMethod): Boolean = annotation(handler) != null
+
+ private inline fun annotation(handler: HandlerMethod): T? =
+ AnnotatedElementUtils.findMergedAnnotation(handler.method, T::class.java)
+ ?: AnnotatedElementUtils.findMergedAnnotation(handler.beanType, T::class.java)
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/RequestAuth.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/RequestAuth.kt
new file mode 100644
index 00000000..92fa7a76
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/RequestAuth.kt
@@ -0,0 +1,9 @@
+package de.interaapps.pastefy.auth
+
+import de.interaapps.pastefy.entities.AuthKey
+import de.interaapps.pastefy.entities.User
+
+data class RequestAuth(
+ val authKey: AuthKey,
+ val user: User,
+)
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/RequestAuthAttributes.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/RequestAuthAttributes.kt
new file mode 100644
index 00000000..aaf0320a
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/RequestAuthAttributes.kt
@@ -0,0 +1,5 @@
+package de.interaapps.pastefy.auth
+
+object RequestAuthAttributes {
+ const val AUTH = "pastefy.auth"
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/annotations/RouteAnnotations.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/annotations/RouteAnnotations.kt
new file mode 100644
index 00000000..5888ca7a
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/annotations/RouteAnnotations.kt
@@ -0,0 +1,51 @@
+package de.interaapps.pastefy.auth.annotations
+
+@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
+@Retention(AnnotationRetention.RUNTIME)
+annotation class Authenticated
+
+@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
+@Retention(AnnotationRetention.RUNTIME)
+annotation class AdminRoute
+
+@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
+@Retention(AnnotationRetention.RUNTIME)
+annotation class RejectBlocked
+
+@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
+@Retention(AnnotationRetention.RUNTIME)
+annotation class RejectAwaitingAccess
+
+@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
+@Retention(AnnotationRetention.RUNTIME)
+annotation class LoginRequiredForRead
+
+@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
+@Retention(AnnotationRetention.RUNTIME)
+annotation class LoginRequiredForCreate
+
+@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
+@Retention(AnnotationRetention.RUNTIME)
+annotation class PublicPastesEnabled
+
+@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
+@Retention(AnnotationRetention.RUNTIME)
+annotation class RequiresPermission(
+ vararg val anyOf: String,
+ val allOf: Array = [],
+)
+
+@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
+@Retention(AnnotationRetention.RUNTIME)
+annotation class RateLimited(
+ val limit: Int = -1,
+ val windowMillis: Long = -1,
+)
+
+@Target(AnnotationTarget.VALUE_PARAMETER)
+@Retention(AnnotationRetention.RUNTIME)
+annotation class CurrentUser
+
+@Target(AnnotationTarget.VALUE_PARAMETER)
+@Retention(AnnotationRetention.RUNTIME)
+annotation class CurrentAuthKey
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
new file mode 100644
index 00000000..7dad4ec9
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuth2LoginService.kt
@@ -0,0 +1,55 @@
+package de.interaapps.pastefy.auth.oauth
+
+import de.interaapps.pastefy.config.PastefyProperties
+import de.interaapps.pastefy.entities.AuthKey
+import de.interaapps.pastefy.entities.User
+import de.interaapps.pastefy.exceptions.OAuth2Exception
+import de.interaapps.pastefy.repositories.AuthKeyRepository
+import de.interaapps.pastefy.repositories.UserRepository
+import org.springframework.stereotype.Service
+import org.springframework.transaction.annotation.Transactional
+
+@Service
+class OAuth2LoginService(
+ private val properties: PastefyProperties,
+ private val userRepository: UserRepository,
+ private val authKeyRepository: AuthKeyRepository,
+) {
+ @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 user = userRepository.findByAuthIdAndAuthProvider(profile.id, authenticationProvider)
+ ?: User(
+ authId = profile.id,
+ authProvider = authenticationProvider,
+ uniqueName = availableUniqueName(profile.name),
+ type = if (properties.grantAccessRequired) User.Type.AWAITING_ACCESS else User.Type.USER,
+ )
+ user.name = profile.name
+ user.avatar = profile.avatar
+ user.email = profile.email
+ userRepository.save(user)
+ return authKeyRepository.save(
+ AuthKey(
+ userId = user.id,
+ accessToken = tokens.accessToken,
+ refreshToken = tokens.refreshToken,
+ type = AuthKey.Type.USER,
+ )
+ )
+ }
+
+ private fun availableUniqueName(name: String): String {
+ val base = name.replace(Regex("[^a-zA-Z0-9]"), "").take(33).ifBlank { "user" }
+ if (!userRepository.existsByUniqueName(base)) return base
+ var suffix = 1
+ while (true) {
+ val candidate = base.take(33 - suffix.toString().length) + suffix
+ if (!userRepository.existsByUniqueName(candidate)) return candidate
+ suffix++
+ }
+ }
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuth2Profile.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuth2Profile.kt
new file mode 100644
index 00000000..88f3d76a
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuth2Profile.kt
@@ -0,0 +1,13 @@
+package de.interaapps.pastefy.auth.oauth
+
+data class OAuth2Profile(
+ val id: String,
+ val name: String,
+ val email: String? = null,
+ val avatar: String? = null,
+)
+
+data class OAuth2Tokens(
+ val accessToken: String,
+ val refreshToken: String? = null,
+)
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuth2Provider.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuth2Provider.kt
new file mode 100644
index 00000000..d9e1fdbb
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuth2Provider.kt
@@ -0,0 +1,8 @@
+package de.interaapps.pastefy.auth.oauth
+
+interface OAuth2Provider {
+ val name: String
+ fun authorizationUrl(callbackUrl: String, state: String): String
+ fun exchangeCode(code: String, callbackUrl: String): OAuth2Tokens
+ fun loadProfile(tokens: OAuth2Tokens): OAuth2Profile
+}
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
new file mode 100644
index 00000000..96453e2e
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuth2ProviderRegistry.kt
@@ -0,0 +1,92 @@
+package de.interaapps.pastefy.auth.oauth
+
+import de.interaapps.pastefy.auth.oauth.providers.*
+import de.interaapps.pastefy.config.PastefyProperties
+import org.springframework.stereotype.Component
+
+@Component
+class OAuth2ProviderRegistry(
+ properties: PastefyProperties,
+ 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.oidc.takeIf { it.fullyConfigured }?.let {
+ add(
+ CustomOAuth2Provider(
+ it.clientId,
+ it.clientSecret,
+ it.authorizationEndpoint,
+ it.tokenEndpoint,
+ it.userInfoEndpoint,
+ it.scopes.orDefault("openid", "email", "profile"),
+ http
+ )
+ )
+ }
+ }.associateBy(OAuth2Provider::name)
+
+ init {
+ require(providers.isEmpty() || properties.oauthStateSecret.length >= 32) {
+ "pastefy.oauth-state-secret must contain at least 32 characters when OAuth2 is enabled"
+ }
+ }
+
+ fun get(name: String): OAuth2Provider? = providers[name]
+
+ fun names(): Set = providers.keys
+
+ private fun List.orDefault(vararg defaults: String): List = ifEmpty { defaults.toList() }
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuthHttpClient.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuthHttpClient.kt
new file mode 100644
index 00000000..46122364
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuthHttpClient.kt
@@ -0,0 +1,46 @@
+package de.interaapps.pastefy.auth.oauth
+
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.ObjectMapper
+import de.interaapps.pastefy.exceptions.OAuth2Exception
+import okhttp3.FormBody
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.springframework.stereotype.Component
+
+@Component
+class OAuthHttpClient(
+ private val client: OkHttpClient,
+ private val objectMapper: ObjectMapper,
+) {
+ fun get(url: String, headers: Map = emptyMap()): JsonNode =
+ execute(Request.Builder().url(url).applyHeaders(headers).get().build())
+
+ fun postForm(url: String, fields: Map, headers: Map = emptyMap()): JsonNode {
+ val body = FormBody.Builder().apply { fields.forEach { (name, value) -> add(name, value) } }.build()
+ return execute(Request.Builder().url(url).applyHeaders(headers).post(body).build())
+ }
+
+ fun postJson(url: String, fields: Map, headers: Map = emptyMap()): JsonNode {
+ val body = objectMapper.writeValueAsBytes(fields).toRequestBody(JSON)
+ return execute(Request.Builder().url(url).applyHeaders(headers).post(body).build())
+ }
+
+ private fun execute(request: Request): JsonNode = client.newCall(request).execute().use { response ->
+ val body = response.body?.string().orEmpty()
+ if (!response.isSuccessful) throw OAuth2Exception("OAuth2 provider request failed with status ${response.code}")
+ runCatching { objectMapper.readTree(body) }
+ .getOrElse { throw OAuth2Exception("OAuth2 provider returned invalid JSON") }
+ }
+
+ private fun Request.Builder.applyHeaders(headers: Map) = apply {
+ headers.forEach { (name, value) -> addHeader(name, value) }
+ header("User-Agent", "PastefyOAuth2Client/1")
+ }
+
+ companion object {
+ private val JSON = "application/json; charset=utf-8".toMediaType()
+ }
+}
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
new file mode 100644
index 00000000..14b085fe
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/OAuthStateService.kt
@@ -0,0 +1,49 @@
+package de.interaapps.pastefy.auth.oauth
+
+import de.interaapps.pastefy.config.PastefyProperties
+import de.interaapps.pastefy.exceptions.OAuth2Exception
+import de.interaapps.pastefy.util.RandomStrings
+import org.springframework.stereotype.Service
+import java.nio.charset.StandardCharsets
+import java.security.MessageDigest
+import java.time.Instant
+import java.util.Base64
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
+
+@Service
+class OAuthStateService(
+ private val properties: PastefyProperties,
+) {
+ fun create(): String {
+ val payload = "${Instant.now().epochSecond}.${RandomStrings.alphanumeric(48)}"
+ return "$payload.${sign(payload)}"
+ }
+
+ 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")
+ val payload = state.substringBeforeLast('.', "")
+ val signature = state.substringAfterLast('.', "")
+ 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")
+ }
+
+ private fun sign(payload: String): String {
+ val secret = properties.oauthStateSecret.takeIf { it.isNotBlank() }
+ ?: 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)))
+ }
+}
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
new file mode 100644
index 00000000..77e4f70b
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/CustomOAuth2Provider.kt
@@ -0,0 +1,49 @@
+package de.interaapps.pastefy.auth.oauth.providers
+
+import de.interaapps.pastefy.auth.oauth.*
+
+class CustomOAuth2Provider(
+ private val clientId: String = "",
+ private val clientSecret: String = "",
+ private val authorizationEndpoint: String = "",
+ private val tokenEndpoint: String = "",
+ private val userInfoEndpoint: String = "",
+ private val scopes: List = listOf("openid", "email", "profile"),
+ private val http: OAuthHttpClient? = null,
+) : OAuth2Provider {
+ override val name = "oidc"
+
+ 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
+ ),
+ )
+
+ 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
+ ),
+ ).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")
+ )
+ }
+
+ 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
new file mode 100644
index 00000000..955b121e
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/DiscordOAuth2Provider.kt
@@ -0,0 +1,48 @@
+package de.interaapps.pastefy.auth.oauth.providers
+
+import de.interaapps.pastefy.auth.oauth.*
+
+class DiscordOAuth2Provider(
+ private val clientId: String = "",
+ private val clientSecret: String = "",
+ private val scopes: List = listOf("email", "identify"),
+ private val http: OAuthHttpClient? = null,
+) : OAuth2Provider {
+ override val name = "discord"
+
+ 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
+ ),
+ )
+
+ 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(" ")
+ ),
+ ).tokens()
+
+ override fun loadProfile(tokens: OAuth2Tokens): OAuth2Profile {
+ 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)
+ }
+
+ private fun requireHttp() = requireNotNull(http) { "OAuth HTTP client is required" }
+}
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
new file mode 100644
index 00000000..d729eea2
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/GitHubOAuth2Provider.kt
@@ -0,0 +1,49 @@
+package de.interaapps.pastefy.auth.oauth.providers
+
+import de.interaapps.pastefy.auth.oauth.*
+
+class GitHubOAuth2Provider(
+ private val clientId: String = "",
+ private val clientSecret: String = "",
+ private val scopes: List = listOf("read:user", "user:email"),
+ private val http: OAuthHttpClient? = null,
+) : OAuth2Provider {
+ override val name = "github"
+
+ 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
+ ),
+ )
+
+ override fun exchangeCode(code: String, callbackUrl: String): OAuth2Tokens = requireHttp().postForm(
+ "https://github.com/login/oauth/access_token",
+ mapOf("client_id" to clientId, "client_secret" to clientSecret, "code" to code, "redirect_uri" to callbackUrl),
+ mapOf("Accept" to "application/json"),
+ ).tokens()
+
+ override fun loadProfile(tokens: OAuth2Tokens): OAuth2Profile {
+ 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 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")
+ )
+ }
+
+ 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
new file mode 100644
index 00000000..ce509269
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/GoogleOAuth2Provider.kt
@@ -0,0 +1,54 @@
+package de.interaapps.pastefy.auth.oauth.providers
+
+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 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
+ ),
+ )
+
+ 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
+ ),
+ ).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")
+ )
+ }
+
+ 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
new file mode 100644
index 00000000..d9e3d6b3
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/InteraAppsOAuth2Provider.kt
@@ -0,0 +1,49 @@
+package de.interaapps.pastefy.auth.oauth.providers
+
+import de.interaapps.pastefy.auth.oauth.*
+import de.interaapps.pastefy.exceptions.OAuth2Exception
+
+class InteraAppsOAuth2Provider(
+ private val clientId: String = "",
+ private val clientSecret: String = "",
+ private val scopes: List = listOf("user:read"),
+ private val http: OAuthHttpClient? = null,
+) : OAuth2Provider {
+ override val name = "interaapps"
+
+ 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
+ ),
+ )
+
+ override fun exchangeCode(code: String, callbackUrl: String): OAuth2Tokens {
+ val data = requireHttp().postJson(
+ "https://accounts.interaapps.de/api/v2/authorization/oauth2/access_token",
+ mapOf("client_id" to clientId, "client_secret" to clientSecret, "code" to code),
+ )
+ if (!data.path("success").asBoolean(false)) throw OAuth2Exception()
+ val returnedScopes = data.path("scope_list").map { it.asText() }.toSet()
+ if (!returnedScopes.containsAll(scopes)) throw OAuth2Exception("InteraApps OAuth2 response is missing requested scopes")
+ return data.tokens()
+ }
+
+ 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")
+ )
+ }
+
+ 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
new file mode 100644
index 00000000..4ae75852
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/OAuthProviderSupport.kt
@@ -0,0 +1,19 @@
+package de.interaapps.pastefy.auth.oauth.providers
+
+import com.fasterxml.jackson.databind.JsonNode
+import de.interaapps.pastefy.auth.oauth.OAuth2Tokens
+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()
+
+internal fun JsonNode.requiredText(name: String): String =
+ path(name).takeUnless { it.isMissingNode || it.isNull }?.asText()?.takeIf { it.isNotBlank() }
+ ?: throw OAuth2Exception("OAuth2 provider response is missing $name")
+
+internal fun JsonNode.optionalText(name: String): String? =
+ path(name).takeUnless { it.isMissingNode || it.isNull }?.asText()?.takeIf { it.isNotBlank() }
+
+internal fun JsonNode.tokens(): OAuth2Tokens = OAuth2Tokens(requiredText("access_token"), optionalText("refresh_token"))
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
new file mode 100644
index 00000000..0e9f9287
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/oauth/providers/TwitchOAuth2Provider.kt
@@ -0,0 +1,51 @@
+package de.interaapps.pastefy.auth.oauth.providers
+
+import de.interaapps.pastefy.auth.oauth.*
+
+class TwitchOAuth2Provider(
+ private val clientId: String = "",
+ private val clientSecret: String = "",
+ private val scopes: List = listOf("user:read:email"),
+ private val http: OAuthHttpClient? = null,
+) : OAuth2Provider {
+ override val name = "twitch"
+
+ 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
+ ),
+ )
+
+ 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
+ ),
+ ).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")
+ )
+ }
+
+ private fun requireHttp() = requireNotNull(http) { "OAuth HTTP client is required" }
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/ratelimit/InMemoryRateLimiter.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/ratelimit/InMemoryRateLimiter.kt
new file mode 100644
index 00000000..3677a38a
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/auth/ratelimit/InMemoryRateLimiter.kt
@@ -0,0 +1,45 @@
+package de.interaapps.pastefy.auth.ratelimit
+
+import org.springframework.scheduling.annotation.Scheduled
+import org.springframework.stereotype.Component
+import java.util.ArrayDeque
+import java.util.concurrent.ConcurrentHashMap
+
+@Component
+class InMemoryRateLimiter {
+ private val requests = ConcurrentHashMap()
+
+ fun tryAcquire(key: String, limit: Int, windowMillis: Long): Boolean {
+ require(limit > 0) { "Rate limit must be positive" }
+ require(windowMillis > 0) { "Rate-limit window must be positive" }
+ val now = System.currentTimeMillis()
+ val window = requests.computeIfAbsent(key) { Window() }
+ synchronized(window) {
+ window.removeExpired(now - windowMillis)
+ if (window.timestamps.size >= limit) return false
+ window.timestamps.addLast(now)
+ return true
+ }
+ }
+
+ @Scheduled(fixedDelay = 600_000)
+ fun removeInactiveWindows() {
+ val oldestUsefulTimestamp = System.currentTimeMillis() - 600_000
+ requests.entries.removeIf { (_, window) ->
+ synchronized(window) {
+ window.removeExpired(oldestUsefulTimestamp)
+ window.timestamps.isEmpty()
+ }
+ }
+ }
+
+ private class Window {
+ val timestamps = ArrayDeque()
+
+ fun removeExpired(minimumTimestamp: Long) {
+ while (timestamps.firstOrNull()?.let { it <= minimumTimestamp } == true) {
+ timestamps.removeFirst()
+ }
+ }
+ }
+}
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 00000000..7228ac96
--- /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/CacheConfiguration.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/CacheConfiguration.kt
new file mode 100644
index 00000000..cffc02a8
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/CacheConfiguration.kt
@@ -0,0 +1,84 @@
+package de.interaapps.pastefy.config
+
+import com.github.benmanes.caffeine.cache.Caffeine
+import org.springframework.beans.factory.ObjectProvider
+import org.springframework.cache.CacheManager
+import org.springframework.cache.caffeine.CaffeineCache
+import org.springframework.cache.support.SimpleCacheManager
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.data.redis.cache.RedisCacheConfiguration
+import org.springframework.data.redis.cache.RedisCacheManager
+import org.springframework.data.redis.connection.RedisConnectionFactory
+import org.springframework.data.redis.serializer.RedisSerializationContext
+import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer
+import java.time.Duration
+
+@Configuration
+class CacheConfiguration(
+ private val properties: PastefyProperties,
+) {
+ @Bean
+ fun cacheManager(
+ redisConnectionFactoryProvider: ObjectProvider,
+ ): CacheManager {
+ if (properties.redis.enabled) {
+ redisConnectionFactoryProvider.ifAvailable?.let { connectionFactory ->
+ return redisCacheManager(connectionFactory)
+ }
+ }
+
+ return caffeineCacheManager()
+ }
+
+ private fun redisCacheManager(connectionFactory: RedisConnectionFactory): CacheManager {
+ val cacheProperties = properties.cache
+ val defaultConfiguration = redisConfiguration(cacheProperties.defaultTtlSeconds)
+ return RedisCacheManager.builder(connectionFactory)
+ .cacheDefaults(defaultConfiguration)
+ .withInitialCacheConfigurations(
+ mapOf(
+ "app-info" to redisConfiguration(cacheProperties.defaultTtlSeconds),
+ "public-tag" to redisConfiguration(cacheProperties.defaultTtlSeconds),
+ "seo-pages" to redisConfiguration(cacheProperties.seoTtlSeconds),
+ ),
+ )
+ .disableCreateOnMissingCache()
+ .transactionAware()
+ .build()
+ }
+
+ private fun redisConfiguration(
+ ttlSeconds: Long,
+ ): RedisCacheConfiguration =
+ RedisCacheConfiguration.defaultCacheConfig()
+ .disableCachingNullValues()
+ .entryTtl(Duration.ofSeconds(ttlSeconds.coerceAtLeast(1)))
+ .serializeValuesWith(
+ RedisSerializationContext.SerializationPair.fromSerializer(GenericJackson2JsonRedisSerializer()),
+ )
+ .prefixCacheNameWith("pastefy:cache:")
+
+ private fun caffeineCacheManager(): CacheManager {
+ val cacheProperties = properties.cache
+ return SimpleCacheManager().apply {
+ setCaches(
+ listOf(
+ caffeineCache("app-info", cacheProperties.defaultTtlSeconds, 64),
+ caffeineCache("public-tag", cacheProperties.defaultTtlSeconds, cacheProperties.maxSize),
+ caffeineCache("seo-pages", cacheProperties.seoTtlSeconds, cacheProperties.maxSize),
+ ),
+ )
+ }
+ }
+
+ private fun caffeineCache(name: String, ttlSeconds: Long, maximumSize: Long): CaffeineCache =
+ CaffeineCache(
+ name,
+ Caffeine.newBuilder()
+ .expireAfterWrite(Duration.ofSeconds(ttlSeconds.coerceAtLeast(1)))
+ .maximumSize(maximumSize.coerceAtLeast(1))
+ .build(),
+ false,
+ )
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/FeatureStartupLogger.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/FeatureStartupLogger.kt
new file mode 100644
index 00000000..49fbb0ae
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/FeatureStartupLogger.kt
@@ -0,0 +1,100 @@
+package de.interaapps.pastefy.config
+
+import org.slf4j.LoggerFactory
+import org.springframework.boot.context.event.ApplicationReadyEvent
+import org.springframework.context.event.EventListener
+import org.springframework.core.env.Environment
+import org.springframework.stereotype.Component
+
+@Component
+class FeatureStartupLogger(
+ private val properties: PastefyProperties,
+ private val environment: Environment,
+) {
+ private val logger = LoggerFactory.getLogger(FeatureStartupLogger::class.java)
+
+ @EventListener(ApplicationReadyEvent::class)
+ fun logEnabledFeatures() {
+ val redisHost = environment.getProperty("spring.data.redis.host", "localhost")
+ val redisPort = environment.getProperty("spring.data.redis.port", "6379")
+ val redisMaxActive = environment.getProperty("spring.data.redis.lettuce.pool.max-active", "128")
+ val redisMaxIdle = environment.getProperty("spring.data.redis.lettuce.pool.max-idle", "64")
+ val redisMinIdle = environment.getProperty("spring.data.redis.lettuce.pool.min-idle", "16")
+ val s3Details =
+ "bucket=${properties.s3.bucket}, endpoint=${redactUrl(properties.s3.endpoint)}, pool=${properties.s3.pool.maxIdleConnections}"
+ val redisDetails = "host=$redisHost:$redisPort, pool=$redisMaxActive/$redisMaxIdle/$redisMinIdle"
+ val elasticDetails =
+ "index=${properties.elasticsearch.indexName}, pool=${properties.elasticsearch.pool.maxConnections}/${properties.elasticsearch.pool.maxConnectionsPerRoute}, migrations=${enabled(properties.elasticsearch.migrations.enabled)}"
+ val aiDetails = "provider=${blank(properties.ai.provider)}, model=${blank(properties.ai.model)}"
+ val analyticsDetails =
+ "table=${properties.analytics.database}.${properties.analytics.table}, pool=${properties.analytics.pool.maximumPoolSize}/${properties.analytics.pool.minimumIdle}, migrations=${enabled(properties.analytics.migrations.enabled)}"
+ val seoDetails =
+ "timeout=${enabled(properties.seo.renderTimeoutEnabled)} ${properties.seo.renderTimeoutMillis}ms, threads=${properties.seo.executorThreads}, queue=${properties.seo.executorQueueCapacity}"
+
+ logger.info(
+ """
+
+ Pastefy feature configuration
+ Profiles: ${profiles()}
+ Public API:
+ public pastes: ${enabled(properties.publicPastesEnabled)}
+ public stats: ${enabled(properties.publicStats)}
+ list pastes: ${enabled(properties.listPastes)}
+ meta tags / SEO SSR: ${enabled(properties.metaTagsEnabled)}${suffix(properties.metaTagsEnabled, seoDetails)}
+ login required read: ${enabled(properties.loginRequiredRead)}
+ login required create: ${enabled(properties.loginRequiredCreate)}
+ grant access required: ${enabled(properties.grantAccessRequired)}
+ Auth:
+ rate limiter: ${enabled(properties.rateLimiter.enabled)} (${properties.rateLimiter.limit} requests / ${properties.rateLimiter.windowMillis}ms)
+ oauth providers: ${oauthProviders()}
+ Storage and search:
+ S3: ${enabled(properties.s3.enabled)}${suffix(properties.s3.enabled, s3Details)}
+ Redis: ${enabled(properties.redis.enabled)}${suffix(properties.redis.enabled, redisDetails)}
+ Elasticsearch: ${enabled(properties.elasticsearch.enabled)}${suffix(properties.elasticsearch.enabled, elasticDetails)}
+ AI:
+ paste AI: ${enabled(properties.ai.enabled)}${suffix(properties.ai.enabled, aiDetails)}
+ AI job sweeper: ${enabled(properties.ai.jobs.sweeperEnabled)}
+ Analytics:
+ ClickHouse analytics: ${enabled(properties.analytics.enabled)}${suffix(properties.analytics.enabled, analyticsDetails)}
+ track bots: ${enabled(properties.analytics.trackBots)}
+ GeoIP: ${enabled(properties.analytics.geoIpMmdbPath.isNotBlank())}
+ Local tooling:
+ seeding: ${enabled(properties.seeding.enabled)}
+ """.trimIndent(),
+ )
+ }
+
+ private fun profiles(): String =
+ environment.activeProfiles.takeIf { it.isNotEmpty() }?.joinToString(", ") ?: "default"
+
+ private fun oauthProviders(): String {
+ val providers = listOfNotNull(
+ "interaapps".takeIf { properties.oauth.interaapps.enabled },
+ "google".takeIf { properties.oauth.google.enabled },
+ "github".takeIf { properties.oauth.github.enabled },
+ "twitch".takeIf { properties.oauth.twitch.enabled },
+ "discord".takeIf { properties.oauth.discord.enabled },
+ "oidc".takeIf { properties.oauth.oidc.fullyConfigured },
+ )
+ return providers.takeIf { it.isNotEmpty() }?.joinToString(", ") ?: "none"
+ }
+
+ private fun enabled(value: Boolean): String = if (value) "enabled" else "disabled"
+
+ private fun suffix(condition: Boolean, value: String): String = if (condition) " ($value)" else ""
+
+ private fun blank(value: String): String = value.takeIf(String::isNotBlank) ?: "not configured"
+
+ private fun redactUrl(value: String): String {
+ if (value.isBlank()) return "not configured"
+ return runCatching {
+ val uri = java.net.URI(value)
+ buildString {
+ append(uri.scheme ?: "unknown")
+ append("://")
+ append(uri.host ?: uri.authority ?: "unknown")
+ if (uri.port >= 0) append(":").append(uri.port)
+ }
+ }.getOrElse { "configured" }
+ }
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/HttpClientConfiguration.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/HttpClientConfiguration.kt
new file mode 100644
index 00000000..0eb27304
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/HttpClientConfiguration.kt
@@ -0,0 +1,18 @@
+package de.interaapps.pastefy.config
+
+import okhttp3.OkHttpClient
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import java.time.Duration
+
+@Configuration
+class HttpClientConfiguration {
+ @Bean
+ fun okHttpClient(): OkHttpClient = OkHttpClient.Builder()
+ .connectTimeout(Duration.ofSeconds(5))
+ .readTimeout(Duration.ofSeconds(10))
+ .writeTimeout(Duration.ofSeconds(10))
+ .callTimeout(Duration.ofSeconds(15))
+ .followRedirects(false)
+ .build()
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/OpenApiConfiguration.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/OpenApiConfiguration.kt
new file mode 100644
index 00000000..38e35e33
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/OpenApiConfiguration.kt
@@ -0,0 +1,14 @@
+package de.interaapps.pastefy.config
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import io.swagger.v3.core.converter.ModelConverter
+import io.swagger.v3.core.jackson.ModelResolver
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+
+@Configuration
+class OpenApiConfiguration {
+ @Bean
+ fun openApiModelResolver(objectMapper: ObjectMapper): ModelConverter =
+ ModelResolver(objectMapper)
+}
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
new file mode 100644
index 00000000..9b3c3c20
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/PastefyProperties.kt
@@ -0,0 +1,192 @@
+package de.interaapps.pastefy.config
+
+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,
+ var publicStats: Boolean = false,
+ var metaTagsEnabled: Boolean = false,
+ var metaTagsPreviewLength: Int = 4_096,
+ var serverName: String = "http://localhost",
+ var listPastes: Boolean = false,
+ var paginationPageLimit: Int = 50,
+ var encryptionDefault: Boolean = false,
+ 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(),
+ var rateLimiter: RateLimiter = RateLimiter(),
+ var seo: Seo = Seo(),
+ var cache: Cache = Cache(),
+ var redis: Redis = Redis(),
+ var s3: S3 = S3(),
+ 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,
+ var limit: Int = 5,
+ )
+
+ data class Seo(
+ var renderTimeoutEnabled: Boolean = true,
+ var renderTimeoutMillis: Long = 500,
+ var executorThreads: Int = 4,
+ var executorQueueCapacity: Int = 128,
+ )
+
+ data class Cache(
+ var defaultTtlSeconds: Long = 300,
+ var seoTtlSeconds: Long = 3_600,
+ var maxSize: Long = 10_000,
+ )
+
+ data class Redis(
+ var enabled: Boolean = false,
+ var contentTtlSeconds: Long = 1_800,
+ var accessCountTtlSeconds: Long = 1_800,
+ var metricsTtlSeconds: Long = 30,
+ var cacheAfterAccesses: Long = 10,
+ )
+
+ data class S3(
+ var enabled: Boolean = false,
+ var endpoint: String = "",
+ var accessKey: String = "",
+ var secretKey: String = "",
+ var bucket: String = "pastefy",
+ var region: String? = null,
+ var pasteSizeThreshold: Int = -1,
+ var createBucket: Boolean = false,
+ var pool: Pool = Pool(),
+ ) {
+ data class Pool(
+ var maxIdleConnections: Int = 50,
+ var keepAliveMinutes: Long = 2,
+ var connectTimeoutSeconds: Long = 4,
+ var readTimeoutSeconds: Long = 30,
+ var writeTimeoutSeconds: Long = 30,
+ var retryOnConnectionFailure: Boolean = true,
+ )
+ }
+
+ data class Elasticsearch(
+ var enabled: Boolean = false,
+ var apiKey: String = "",
+ var indexName: String = "pastefy_pastes",
+ var indexPrefix: String = "pastefy_pastes",
+ var legacyIndexName: String = "pastefy_pastes",
+ var numberOfShards: Int = 1,
+ var numberOfReplicas: Int = 1,
+ var migrations: Migrations = Migrations(),
+ var pool: Pool = Pool(),
+ ) {
+ data class Migrations(
+ var enabled: Boolean = true,
+ )
+
+ data class Pool(
+ var maxConnections: Int = 50,
+ var maxConnectionsPerRoute: Int = 50,
+ )
+ }
+
+ data class AI(
+ var enabled: Boolean = false,
+ var provider: String = "",
+ var model: String = "",
+ var engagementThreshold: Int = 100,
+ var jobs: Jobs = Jobs(),
+ ) {
+ data class Jobs(
+ var workers: Int = 2,
+ var pollIntervalMillis: Long = 5_000,
+ var leaseSeconds: Long = 120,
+ var maxAttempts: Int = 3,
+ var retryDelaySeconds: Long = 60,
+ var sweeperEnabled: Boolean = false,
+ var sweepIntervalMillis: Long = 300_000,
+ )
+ }
+
+ data class Analytics(
+ var enabled: Boolean = false,
+ var jdbcUrl: String = "",
+ var database: String = "default",
+ var table: String = "pastefy_analytics_visits",
+ var user: String = "default",
+ var password: String = "",
+ var retentionDays: Int = 90,
+ 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 pool: Pool = Pool(),
+ var ipHashSalt: String = "",
+ var ipSource: String = "direct",
+ var ipHeader: String = "",
+ var geoIpMmdbPath: String = "",
+ var trackBots: Boolean = true,
+ var migrations: Migrations = Migrations(),
+ ) {
+ data class Pool(
+ var maximumPoolSize: Int = 5,
+ var minimumIdle: Int = 1,
+ var connectionTimeoutMillis: Long = 5_000,
+ var idleTimeoutMillis: Long = 60_000,
+ var maxLifetimeMillis: Long = 600_000,
+ var validationTimeoutMillis: Long = 2_000,
+ )
+
+ data class Migrations(
+ var enabled: Boolean = false,
+ var repairFailedHistory: Boolean = false,
+ )
+ }
+
+ data class OAuth(
+ var callbackBaseUrl: String = "http://localhost:8080",
+ var interaapps: Client = Client(),
+ var google: Client = Client(),
+ var github: Client = Client(),
+ var twitch: Client = Client(),
+ var discord: Client = Client(),
+ var oidc: OidcClient = OidcClient(),
+ )
+
+ open class Client(
+ var clientId: String = "",
+ var clientSecret: String = "",
+ var scopes: List = emptyList(),
+ ) {
+ val enabled: Boolean get() = clientId.isNotBlank() && clientSecret.isNotBlank()
+ }
+
+ class OidcClient(
+ clientId: String = "",
+ clientSecret: String = "",
+ scopes: List = emptyList(),
+ var authorizationEndpoint: String = "",
+ var tokenEndpoint: String = "",
+ var userInfoEndpoint: String = "",
+ ) : Client(clientId, clientSecret, scopes) {
+ val fullyConfigured: Boolean
+ get() = enabled && authorizationEndpoint.isNotBlank() && tokenEndpoint.isNotBlank() && userInfoEndpoint.isNotBlank()
+ }
+}
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
new file mode 100644
index 00000000..4976df97
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/config/WebConfiguration.kt
@@ -0,0 +1,55 @@
+package de.interaapps.pastefy.config
+
+import de.interaapps.pastefy.auth.AuthArgumentResolver
+import de.interaapps.pastefy.auth.AuthInterceptor
+import org.springframework.context.annotation.Configuration
+import org.springframework.http.CacheControl
+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(
+ private val authInterceptor: AuthInterceptor,
+ private val properties: PastefyProperties,
+) : WebMvcConfigurer {
+ override fun addInterceptors(registry: InterceptorRegistry) {
+ registry.addInterceptor(authInterceptor)
+ }
+
+ override fun addArgumentResolvers(resolvers: MutableList) {
+ 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/")
+ .setCacheControl(
+ CacheControl.maxAge(Duration.ofDays(7))
+ .cachePublic()
+ .immutable(),
+ )
+
+ registry.addResourceHandler("/**")
+ .addResourceLocations("classpath:/static/")
+ .setCacheControl(
+ CacheControl.maxAge(Duration.ofHours(1))
+ .cachePublic(),
+ )
+ }
+}
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
new file mode 100644
index 00000000..8f1b04c2
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/AppController.kt
@@ -0,0 +1,36 @@
+package de.interaapps.pastefy.controller
+
+import de.interaapps.pastefy.config.PastefyProperties
+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
+
+@RestController
+@RequestMapping("/api/v2/app")
+class AppController(
+ private val properties: PastefyProperties,
+ private val aiProvider: ObjectProvider,
+ 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),
+ aiEnabled = aiProvider.ifAvailable != null,
+ analyticsEnabled = analyticsProvider.ifAvailable != null,
+ customFooter = properties.customFooter.split(',').mapNotNull {
+ val parts = it.split('=', limit = 2)
+ if (parts.size == 2) parts[0] to parts[1] else null
+ }.toMap(),
+ loginRequiredForRead = properties.loginRequiredRead,
+ loginRequiredForCreate = properties.loginRequiredCreate,
+ encryptionIsDefault = properties.encryptionDefault,
+ publicPastesEnabled = properties.publicPastesEnabled,
+ )
+}
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
new file mode 100644
index 00000000..94b5bc5a
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/FolderController.kt
@@ -0,0 +1,83 @@
+package de.interaapps.pastefy.controller
+
+import de.interaapps.pastefy.auth.annotations.*
+import de.interaapps.pastefy.dto.ActionResponse
+import de.interaapps.pastefy.dto.folder.CreateFolderRequest
+import de.interaapps.pastefy.dto.folder.CreateFolderResponse
+import de.interaapps.pastefy.dto.folder.FolderResponse
+import de.interaapps.pastefy.entities.AuthKey
+import de.interaapps.pastefy.entities.User
+import de.interaapps.pastefy.service.FolderService
+import jakarta.servlet.http.HttpServletRequest
+import jakarta.validation.Valid
+import org.springframework.web.bind.annotation.*
+
+@RestController
+@RequestMapping("/api/v2/folder")
+class FolderController(
+ private val folders: FolderService,
+) {
+ @PostMapping
+ @Authenticated
+ @RejectAwaitingAccess
+ @RejectBlocked
+ @RequiresPermission("folders:create")
+ 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 =
+ folders.list(request, user)
+
+ @GetMapping("/{id}")
+ @LoginRequiredForRead
+ @RejectAwaitingAccess
+ @RejectBlocked
+ @RequiresPermission("folders:read")
+ fun getFolder(
+ @PathVariable id: String,
+ request: HttpServletRequest,
+ @CurrentUser user: User?,
+ @CurrentAuthKey authKey: AuthKey?
+ ): FolderResponse {
+ val folder = folders.get(id)
+ return folders.map(
+ folder,
+ fetchChildren = true,
+ fetchSubChildren = !request.hasParameter("hide_children"),
+ fetchPastes = true,
+ showPrivate = user?.id == folder.userId,
+ )
+ }
+
+ @DeleteMapping("/{id}")
+ @Authenticated
+ @RejectAwaitingAccess
+ @RejectBlocked
+ @RequiresPermission("folders:delete")
+ 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)
+ return ActionResponse(success = true)
+ }
+ return ActionResponse()
+ }
+}
+
+private fun HttpServletRequest.hasParameter(name: String) = parameterMap.containsKey(name)
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
new file mode 100644
index 00000000..6f1a9139
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/FormDataPasteController.kt
@@ -0,0 +1,61 @@
+package de.interaapps.pastefy.controller
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import de.interaapps.pastefy.auth.annotations.*
+import de.interaapps.pastefy.dto.pastes.MultiPastesElement
+import de.interaapps.pastefy.entities.User
+import de.interaapps.pastefy.enums.PasteType
+import de.interaapps.pastefy.service.PasteCommandService
+import jakarta.servlet.http.HttpServletRequest
+import org.springframework.http.ResponseEntity
+import org.springframework.http.MediaType
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RestController
+import org.springframework.web.multipart.MultipartHttpServletRequest
+
+@RestController
+class FormDataPasteController(
+ private val commands: PasteCommandService,
+ private val objectMapper: ObjectMapper,
+) {
+ @PostMapping(path = ["/", "/api/asciicasts"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
+ @RateLimited
+ @LoginRequiredForCreate
+ @RejectAwaitingAccess
+ @RejectBlocked
+ fun createPaste(request: MultipartHttpServletRequest, @CurrentUser user: User?): ResponseEntity {
+ val files = request.fileMap.mapValues { (_, file) -> file.inputStream.bufferedReader().use { it.readText() } }
+ if (files.isEmpty()) return ResponseEntity.ok("No file given. curl -F 'f=@filename' pastefy.app\n")
+ val asciicast = request.requestURI == "/api/asciicasts"
+ val paste = if (files.size == 1) {
+ val (name, content) = files.entries.first()
+ commands.createUploaded(
+ title = if (asciicast) "$name.cast" else name,
+ content = content,
+ type = PasteType.PASTE,
+ user = user,
+ tags = if (asciicast) listOf("asciicast", "asciinema") else emptyList(),
+ )
+ } else {
+ commands.createUploaded(
+ title = null,
+ content = objectMapper.writeValueAsString(files.map { MultiPastesElement(it.key, it.value) }),
+ type = PasteType.MULTI_PASTE,
+ user = user,
+ )
+ }
+ 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}"
+ )
+ ),
+ )
+ } else {
+ ResponseEntity.ok("https://$host/${paste.key}\n")
+ }
+ }
+}
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 00000000..3d729adc
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/FrontendController.kt
@@ -0,0 +1,41 @@
+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.RequestMapping
+
+@Controller
+@Order(Ordered.LOWEST_PRECEDENCE)
+class FrontendController(
+ private val frontendIndex: FrontendIndexService,
+) : ErrorController {
+ @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/admin/AdminController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/admin/AdminController.kt
new file mode 100644
index 00000000..a5c3157c
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/admin/AdminController.kt
@@ -0,0 +1,77 @@
+package de.interaapps.pastefy.controller.admin
+
+import de.interaapps.pastefy.auth.annotations.*
+import de.interaapps.pastefy.dto.ActionResponse
+import de.interaapps.pastefy.dto.user.EditUserRequest
+import de.interaapps.pastefy.entities.AuthKey
+import de.interaapps.pastefy.entities.User
+import jakarta.servlet.http.HttpServletRequest
+import jakarta.validation.Valid
+import de.interaapps.pastefy.exceptions.NotFoundException
+import de.interaapps.pastefy.repositories.UserRepository
+import de.interaapps.pastefy.service.UserService
+import de.interaapps.pastefy.service.query.LegacyFilterSpecificationBuilder
+import de.interaapps.pastefy.service.query.LegacyFilterSpecificationBuilder.Companion.USER_FIELDS
+import org.springframework.data.domain.PageRequest
+import org.springframework.data.domain.Sort
+import org.springframework.data.jpa.domain.Specification
+import org.springframework.web.bind.annotation.*
+
+@RestController
+@RequestMapping("/api/v2/admin")
+@AdminRoute
+@Authenticated
+class AdminController(
+ private val repository: UserRepository,
+ private val users: UserService,
+ private val legacyFilters: LegacyFilterSpecificationBuilder,
+) {
+ @GetMapping("/users")
+ @RequiresPermission("admin.users:read")
+ fun getUsers(request: HttpServletRequest, @CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): List {
+ val page = request.getParameter("page")?.toIntOrNull()?.coerceAtLeast(1) ?: 1
+ val limit = request.getParameter("page_limit")?.toIntOrNull()?.coerceIn(1, 100) ?: 10
+ val search = request.getParameter("search")?.trim()?.lowercase()?.takeIf(String::isNotEmpty)
+ val searchSpecification = Specification { root, _, builder ->
+ search?.let {
+ builder.or(
+ builder.like(builder.lower(root.get("name")), "%$it%"),
+ builder.like(builder.lower(root.get("uniqueName")), "%$it%"),
+ builder.like(builder.lower(root.get("email")), "%$it%"),
+ )
+ } ?: builder.conjunction()
+ }
+ val specification = searchSpecification.and(legacyFilters.fromRequest(request, USER_FIELDS))
+ return repository.findAll(
+ specification,
+ PageRequest.of(page - 1, limit, Sort.by(Sort.Direction.DESC, "createdAt"))
+ ).content
+ }
+
+ @GetMapping("/users/{id}")
+ @RequiresPermission("admin.users:read")
+ fun getUser(@PathVariable id: String, @CurrentAuthKey authKey: AuthKey): User =
+ repository.findById(id).orElseThrow(::NotFoundException)
+
+ @DeleteMapping("/users/{id}")
+ @RequiresPermission("admin.users:delete")
+ fun removeUser(@PathVariable id: String, @CurrentAuthKey authKey: AuthKey): ActionResponse {
+ users.delete(repository.findById(id).orElseThrow(::NotFoundException))
+ return ActionResponse(success = true)
+ }
+
+ @PutMapping("/users/{id}")
+ @RequiresPermission("admin.users:edit")
+ 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 }
+ request.type?.let { user.type = it }
+ repository.save(user)
+ return ActionResponse(success = true)
+ }
+}
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
new file mode 100644
index 00000000..8b74c800
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/analytics/AnalyticsController.kt
@@ -0,0 +1,56 @@
+package de.interaapps.pastefy.controller.analytics
+
+import de.interaapps.pastefy.auth.annotations.*
+import de.interaapps.pastefy.dto.analytics.AnalyticsResponse
+import de.interaapps.pastefy.dto.analytics.AnalyticsQuery
+import de.interaapps.pastefy.entities.AuthKey
+import de.interaapps.pastefy.entities.User
+import jakarta.servlet.http.HttpServletRequest
+import de.interaapps.pastefy.exceptions.FeatureDisabledException
+import de.interaapps.pastefy.exceptions.PermissionsDeniedException
+import de.interaapps.pastefy.infrastructure.analytics.AnalyticsService
+import de.interaapps.pastefy.service.PasteService
+import org.springframework.beans.factory.ObjectProvider
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RestController
+
+@RestController
+@RequestMapping("/api/v2/analytics")
+@Authenticated
+class AnalyticsController(
+ private val analyticsProvider: ObjectProvider,
+ private val pasteService: PasteService,
+) {
+ @GetMapping("/admin")
+ @AdminRoute
+ @RequiresPermission("analytics:read")
+ 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 {
+ 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 })
+ }
+
+ @GetMapping("/user")
+ @RequiresPermission("analytics:read")
+ 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 =
+ (analyticsProvider.ifAvailable ?: throw FeatureDisabledException()).query(query)
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/auth/InteraAppsExternalAccessController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/auth/InteraAppsExternalAccessController.kt
new file mode 100644
index 00000000..8fffd36a
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/auth/InteraAppsExternalAccessController.kt
@@ -0,0 +1,19 @@
+package de.interaapps.pastefy.controller.auth
+
+import de.interaapps.pastefy.dto.auth.InteraAppsExternalAccessRequest
+import de.interaapps.pastefy.service.InteraAppsExternalAccessService
+import jakarta.validation.Valid
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestBody
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RestController
+
+@RestController
+@RequestMapping("/api/v2/auth")
+class InteraAppsExternalAccessController(
+ private val externalAccessService: InteraAppsExternalAccessService,
+) {
+ @PostMapping("/iaea")
+ fun externalAccess(@Valid @RequestBody request: InteraAppsExternalAccessRequest): String =
+ externalAccessService.issue(request)
+}
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
new file mode 100644
index 00000000..d1a5dcb3
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/auth/OAuth2Controller.kt
@@ -0,0 +1,78 @@
+package de.interaapps.pastefy.controller.auth
+
+import de.interaapps.pastefy.auth.oauth.OAuth2LoginService
+import de.interaapps.pastefy.auth.oauth.OAuth2ProviderRegistry
+import de.interaapps.pastefy.auth.oauth.OAuthStateService
+import de.interaapps.pastefy.config.PastefyProperties
+import de.interaapps.pastefy.exceptions.NotFoundException
+import de.interaapps.pastefy.exceptions.OAuth2Exception
+import jakarta.servlet.http.HttpServletRequest
+import org.springframework.http.HttpHeaders
+import org.springframework.http.HttpStatus
+import org.springframework.http.ResponseCookie
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.web.bind.annotation.RestController
+import java.net.URLEncoder
+import java.nio.charset.StandardCharsets
+
+@RestController
+@RequestMapping("/auth/oauth2", "/api/v2/auth/oauth2")
+class OAuth2Controller(
+ private val properties: PastefyProperties,
+ private val registry: OAuth2ProviderRegistry,
+ private val stateService: OAuthStateService,
+ private val loginService: OAuth2LoginService,
+) {
+ @GetMapping("/{provider}")
+ fun redirect(@PathVariable provider: String): ResponseEntity {
+ val oauth = registry.get(provider) ?: throw NotFoundException("OAuth2 provider not found")
+ val state = stateService.create()
+ return ResponseEntity.status(HttpStatus.FOUND)
+ .header(HttpHeaders.SET_COOKIE, stateCookie(state).toString())
+ .header(HttpHeaders.LOCATION, oauth.authorizationUrl(callbackUrl(provider), state))
+ .build()
+ }
+
+ @GetMapping("/{provider}/callback")
+ fun callback(
+ @PathVariable provider: String,
+ @RequestParam(required = false) code: String?,
+ @RequestParam(required = false) state: String?,
+ @RequestParam(required = false) error: String?,
+ request: HttpServletRequest,
+ ): ResponseEntity {
+ 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 authKey = loginService.login(oauth, tokens)
+ val encodedKey = URLEncoder.encode(authKey.key, StandardCharsets.UTF_8)
+ return ResponseEntity.status(HttpStatus.FOUND)
+ .header(HttpHeaders.SET_COOKIE, stateCookie("", 0).toString())
+ .header(HttpHeaders.LOCATION, "/auth?key=$encodedKey")
+ .build()
+ }
+
+ private fun callbackUrl(provider: String) =
+ "${properties.oauth.callbackBaseUrl.trimEnd('/')}/api/v2/auth/oauth2/$provider/callback"
+
+ private fun stateCookie(value: String, maxAgeSeconds: Long = 600): ResponseCookie =
+ ResponseCookie.from(STATE_COOKIE, value)
+ .httpOnly(true)
+ .secure(properties.oauth.callbackBaseUrl.startsWith("https://"))
+ .sameSite("Lax")
+ .path("/")
+ .maxAge(maxAgeSeconds)
+ .build()
+
+ companion object {
+ private const val STATE_COOKIE = "pastefy_oauth_state"
+ }
+}
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
new file mode 100644
index 00000000..4687877b
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteCommentsController.kt
@@ -0,0 +1,75 @@
+package de.interaapps.pastefy.controller.pastes
+
+import de.interaapps.pastefy.auth.annotations.*
+import de.interaapps.pastefy.dto.ActionResponse
+import de.interaapps.pastefy.dto.pastes.CreatePasteCommentRequest
+import de.interaapps.pastefy.dto.pastes.PasteCommentMarkerResponse
+import de.interaapps.pastefy.dto.pastes.PasteCommentResponse
+import de.interaapps.pastefy.entities.AuthKey
+import de.interaapps.pastefy.entities.User
+import de.interaapps.pastefy.service.PasteCommentService
+import jakarta.servlet.http.HttpServletRequest
+import org.springframework.web.bind.annotation.*
+
+@RestController
+@RequestMapping("/api/v2/paste")
+class PasteCommentsController(
+ private val comments: PasteCommentService,
+) {
+ @GetMapping("/{pasteId}/comments")
+ @LoginRequiredForRead
+ @RejectAwaitingAccess
+ @RejectBlocked
+ fun getComments(
+ @PathVariable pasteId: String,
+ request: HttpServletRequest,
+ @CurrentUser user: User?
+ ): List =
+ comments.list(
+ pasteId,
+ user,
+ request.positiveInt("page", 1),
+ request.positiveInt("page_limit", PasteCommentService.DEFAULT_PAGE_LIMIT),
+ request.getParameter("line")?.toIntOrNull()?.coerceAtLeast(1),
+ )
+
+ @GetMapping("/{pasteId}/comments/markers")
+ @LoginRequiredForRead
+ @RejectAwaitingAccess
+ @RejectBlocked
+ fun getLineMarkers(@PathVariable pasteId: String, @CurrentUser user: User?): List =
+ comments.markers(pasteId, user)
+
+ @PostMapping("/{pasteId}/comments")
+ @RateLimited
+ @Authenticated
+ @RejectAwaitingAccess
+ @RejectBlocked
+ @RequiresPermission("comments:create")
+ fun createComment(
+ @PathVariable pasteId: String,
+ @RequestBody request: CreatePasteCommentRequest,
+ @CurrentUser user: User,
+ @CurrentAuthKey authKey: AuthKey
+ ): PasteCommentResponse =
+ comments.create(pasteId, request, user)
+
+ @DeleteMapping("/{pasteId}/comments/{commentId}")
+ @RateLimited
+ @Authenticated
+ @RejectAwaitingAccess
+ @RejectBlocked
+ @RequiresPermission("comments:delete")
+ fun deleteComment(
+ @PathVariable pasteId: String,
+ @PathVariable commentId: Int,
+ @CurrentUser user: User,
+ @CurrentAuthKey authKey: AuthKey
+ ): ActionResponse {
+ comments.delete(pasteId, commentId, user)
+ return ActionResponse(success = true)
+ }
+}
+
+private fun HttpServletRequest.positiveInt(name: String, default: Int): Int =
+ getParameter(name)?.toIntOrNull()?.coerceAtLeast(1) ?: default
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 00000000..3451011b
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteController.kt
@@ -0,0 +1,161 @@
+package de.interaapps.pastefy.controller.pastes
+
+import de.interaapps.pastefy.auth.annotations.*
+import de.interaapps.pastefy.dto.ActionResponse
+import de.interaapps.pastefy.dto.pastes.AddFriendToPasteRequest
+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 de.interaapps.pastefy.entities.AuthKey
+import de.interaapps.pastefy.entities.User
+import de.interaapps.pastefy.exceptions.FeatureDisabledException
+import de.interaapps.pastefy.infrastructure.ai.PasteAIInfoService
+import de.interaapps.pastefy.infrastructure.analytics.AnalyticsService
+import de.interaapps.pastefy.service.PasteCommandService
+import de.interaapps.pastefy.service.PasteQueryService
+import de.interaapps.pastefy.service.PasteService
+import de.interaapps.pastefy.service.PublicPasteEngagementService
+import de.interaapps.pastefy.service.UserService
+import jakarta.servlet.http.HttpServletRequest
+import jakarta.servlet.http.HttpServletResponse
+import jakarta.validation.Valid
+import org.springframework.web.bind.annotation.*
+import org.springframework.beans.factory.ObjectProvider
+
+@RestController
+@RequestMapping("/api/v2/paste")
+class PasteController(
+ private val commands: PasteCommandService,
+ private val queries: PasteQueryService,
+ private val pasteService: PasteService,
+ private val users: UserService,
+ private val engagement: PublicPasteEngagementService,
+ private val analyticsProvider: ObjectProvider,
+ private val aiProvider: ObjectProvider,
+) {
+ @PostMapping
+ @RateLimited
+ @LoginRequiredForCreate
+ @RejectAwaitingAccess
+ @RejectBlocked
+ @RequiresPermission("pastes:create", "pastes:write")
+ fun createPaste(
+ @Valid @RequestBody request: CreatePasteRequest,
+ @CurrentUser user: User?,
+ @CurrentAuthKey authKey: AuthKey?,
+ servletRequest: HttpServletRequest,
+ ): CreatePasteResponse =
+ CreatePasteResponse(paste = queries.map(commands.create(request, user), servletRequest, user), success = true)
+
+ @GetMapping
+ @RequiresPermission("pastes:read")
+ fun getPastes(
+ request: HttpServletRequest,
+ response: HttpServletResponse,
+ @CurrentUser user: User?,
+ @CurrentAuthKey authKey: AuthKey?,
+ ): List = queries.list(request, response, user)
+
+ @GetMapping("/{id}")
+ @LoginRequiredForRead
+ @RejectAwaitingAccess
+ @RejectBlocked
+ fun getPaste(
+ @PathVariable id: String,
+ @RequestParam("from_frontend", defaultValue = "false") fromFrontend: Boolean,
+ @CurrentUser user: User?,
+ request: HttpServletRequest,
+ ): PasteResponse {
+ val paste = pasteService.getAccessiblePasteOrFail(id, user)
+ if (paste.isPublic && user?.id != paste.userId) {
+ engagement.addInterest(paste, if (fromFrontend) if (user == null) 5 else 4 else 2)
+ }
+ if (fromFrontend) analyticsProvider.ifAvailable?.track(request, paste, user, AnalyticsService.VisitType.PAGE)
+ return queries.map(paste, request, user)
+ }
+
+ @PutMapping("/{id}")
+ @Authenticated
+ @RejectAwaitingAccess
+ @RejectBlocked
+ @RequiresPermission("pastes:edit", "pastes:write")
+ fun putPaste(
+ @PathVariable id: String,
+ @Valid @RequestBody request: EditPasteRequest,
+ @CurrentUser user: User,
+ @CurrentAuthKey authKey: AuthKey,
+ ): ActionResponse {
+ commands.update(id, request, user)
+ return ActionResponse(success = true)
+ }
+
+ @DeleteMapping("/{id}")
+ @Authenticated
+ @RejectAwaitingAccess
+ @RejectBlocked
+ @RequiresPermission("pastes:delete")
+ fun deletePaste(
+ @PathVariable id: String,
+ @CurrentUser user: User,
+ @CurrentAuthKey authKey: AuthKey
+ ): ActionResponse {
+ commands.delete(id, user)
+ return ActionResponse(success = true)
+ }
+
+ @PostMapping("/{id}/star")
+ @Authenticated
+ @LoginRequiredForRead
+ @RejectAwaitingAccess
+ @RejectBlocked
+ @RequiresPermission("stars:create")
+ fun addStarToPaste(
+ @PathVariable id: String,
+ @CurrentUser user: User,
+ @CurrentAuthKey authKey: AuthKey
+ ): ActionResponse {
+ users.star(user, pasteService.getAccessiblePasteOrFail(id, user))
+ return ActionResponse(success = true)
+ }
+
+ @DeleteMapping("/{id}/star")
+ @Authenticated
+ @LoginRequiredForRead
+ @RejectAwaitingAccess
+ @RejectBlocked
+ @RequiresPermission("stars:delete")
+ fun removeStarFromPaste(
+ @PathVariable id: String,
+ @CurrentUser user: User,
+ @CurrentAuthKey authKey: AuthKey
+ ): ActionResponse {
+ users.unstar(user, pasteService.getAccessiblePasteOrFail(id, user))
+ return ActionResponse(success = true)
+ }
+
+ @PostMapping("/{id}/friend")
+ @Authenticated
+ @RejectAwaitingAccess
+ @RejectBlocked
+ 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 {
+ 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/PasteRawController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteRawController.kt
new file mode 100644
index 00000000..98918655
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteRawController.kt
@@ -0,0 +1,65 @@
+package de.interaapps.pastefy.controller.pastes
+
+import com.fasterxml.jackson.core.type.TypeReference
+import com.fasterxml.jackson.databind.ObjectMapper
+import de.interaapps.pastefy.auth.annotations.CurrentUser
+import de.interaapps.pastefy.auth.annotations.LoginRequiredForRead
+import de.interaapps.pastefy.dto.pastes.MultiPastesElement
+import de.interaapps.pastefy.entities.User
+import de.interaapps.pastefy.enums.PasteType
+import de.interaapps.pastefy.infrastructure.analytics.AnalyticsService
+import de.interaapps.pastefy.service.PasteService
+import de.interaapps.pastefy.service.PublicPasteEngagementService
+import jakarta.servlet.http.HttpServletRequest
+import org.springframework.beans.factory.ObjectProvider
+import org.springframework.http.HttpStatus
+import org.springframework.http.MediaType
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.RestController
+
+@RestController
+class PasteRawController(
+ private val pasteService: PasteService,
+ private val engagement: PublicPasteEngagementService,
+ private val analyticsProvider: ObjectProvider,
+ private val objectMapper: ObjectMapper,
+) {
+ @GetMapping(
+ "/{id}/raw",
+ produces = [MediaType.TEXT_PLAIN_VALUE]
+ )
+ @LoginRequiredForRead
+ fun getRaw(
+ @PathVariable id: String,
+ @CurrentUser user: User?,
+ 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)
+ }
+
+ private fun text(body: String, status: HttpStatus = HttpStatus.OK): ResponseEntity =
+ ResponseEntity.status(status)
+ .contentType(MediaType("text", "plain", Charsets.UTF_8))
+ .body(body)
+}
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 00000000..44a95342
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/pastes/PasteThumbnailController.kt
@@ -0,0 +1,32 @@
+package de.interaapps.pastefy.controller.pastes
+
+import de.interaapps.pastefy.auth.annotations.CurrentUser
+import de.interaapps.pastefy.auth.annotations.LoginRequiredForRead
+import de.interaapps.pastefy.auth.annotations.RateLimited
+import de.interaapps.pastefy.entities.User
+import de.interaapps.pastefy.exceptions.NotFoundException
+import de.interaapps.pastefy.service.PasteService
+import de.interaapps.pastefy.service.PasteThumbnailService
+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(
+ private val pasteService: PasteService,
+ private val thumbnails: PasteThumbnailService,
+) {
+ @GetMapping("/{id}/thumbnail.png")
+ @LoginRequiredForRead
+ @RateLimited(limit = 3, windowMillis = 5_000)
+ fun getThumbnail(@PathVariable id: String, @CurrentUser user: User?, response: HttpServletResponse) {
+ val paste = pasteService.get(id) ?: throw NotFoundException()
+ if (paste.isPrivate || paste.encrypted) throw NotFoundException()
+ val bytes = thumbnails.render(paste.title, pasteService.getContent(paste).orEmpty())
+ response.contentType = MediaType.IMAGE_PNG_VALUE
+ response.setContentLength(bytes.size)
+ response.outputStream.use { it.write(bytes) }
+ }
+}
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 00000000..a2a06e3c
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/PublicPastesController.kt
@@ -0,0 +1,32 @@
+package de.interaapps.pastefy.controller.public
+
+import de.interaapps.pastefy.auth.annotations.PublicPastesEnabled
+import de.interaapps.pastefy.dto.pastes.PasteResponse
+import jakarta.servlet.http.HttpServletRequest
+import jakarta.servlet.http.HttpServletResponse
+import de.interaapps.pastefy.enums.PasteVisibility
+import de.interaapps.pastefy.service.PasteQueryService
+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
+
+@RestController
+@RequestMapping("/api/v2/public-pastes")
+@PublicPastesEnabled
+class PublicPastesController(
+ private val queries: PasteQueryService,
+) {
+ @GetMapping
+ //@Cacheable()
+ fun getPublicPastes(request: HttpServletRequest, response: HttpServletResponse): List =
+ queries.list(request, response, null, guarded = false, visibility = PasteVisibility.PUBLIC)
+
+ @GetMapping("/trending")
+ fun getTrendingPastes(request: HttpServletRequest, response: HttpServletResponse): List =
+ queries.trending(request, response)
+
+ @GetMapping("/latest")
+ fun getLatestPastes(request: HttpServletRequest, response: HttpServletResponse): List =
+ queries.list(request, response, null, guarded = false, visibility = PasteVisibility.PUBLIC)
+}
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 00000000..78ccdc6c
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/PublicUserController.kt
@@ -0,0 +1,22 @@
+package de.interaapps.pastefy.controller.public
+
+import de.interaapps.pastefy.auth.annotations.PublicPastesEnabled
+import de.interaapps.pastefy.dto.user.PublicUserDto
+import de.interaapps.pastefy.exceptions.NotFoundException
+import de.interaapps.pastefy.service.UserService
+import de.interaapps.pastefy.service.toPublicDto
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RestController
+import org.springframework.web.bind.annotation.PathVariable
+
+@RestController
+@RequestMapping("/api/v2/public/user")
+@PublicPastesEnabled
+class PublicUserController(
+ private val users: UserService,
+) {
+ @GetMapping("/{name}")
+ fun getUser(@PathVariable name: String): PublicUserDto =
+ users.getByName(name)?.toPublicDto() ?: throw NotFoundException()
+}
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 00000000..7c73f225
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/public/TagsController.kt
@@ -0,0 +1,58 @@
+package de.interaapps.pastefy.controller.public
+
+import de.interaapps.pastefy.auth.annotations.PublicPastesEnabled
+import de.interaapps.pastefy.entities.TagListing
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.RequestMapping
+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 de.interaapps.pastefy.service.query.LegacyFilterSpecificationBuilder
+import de.interaapps.pastefy.service.query.LegacyFilterSpecificationBuilder.Companion.TAG_LISTING_FIELDS
+import org.springframework.cache.annotation.Cacheable
+import org.springframework.data.domain.PageRequest
+import org.springframework.data.domain.Sort
+import org.springframework.data.jpa.domain.Specification
+import jakarta.servlet.http.HttpServletRequest
+
+@RestController
+@RequestMapping("/api/v2/public/tags")
+@PublicPastesEnabled
+class TagsController(
+ private val repository: TagListingRepository,
+ private val tags: TagListingService,
+ private val legacyFilters: LegacyFilterSpecificationBuilder,
+) {
+ @GetMapping
+ fun getTags(
+ request: HttpServletRequest,
+ @RequestParam("search") search: String? = null,
+ @RequestParam("page", defaultValue = "1") page: Int,
+ @RequestParam("page_limit", defaultValue = "10") pageLimit: Int,
+ ): List {
+ val pageable = PageRequest.of(
+ page.coerceAtLeast(1) - 1,
+ pageLimit.coerceIn(1, 100),
+ Sort.by(Sort.Direction.DESC, "pasteCount"),
+ )
+ val searchSpecification = Specification { root, _, builder ->
+ search?.trim()?.takeIf(String::isNotEmpty)?.lowercase()?.let {
+ builder.or(
+ builder.like(builder.lower(root.get("tag")), "%$it%"),
+ builder.like(builder.lower(root.get("displayName")), "%$it%"),
+ builder.like(builder.lower(root.get("description")), "%$it%"),
+ )
+ } ?: builder.conjunction()
+ }
+ return repository.findAll(
+ searchSpecification.and(legacyFilters.fromRequest(request, TAG_LISTING_FIELDS)),
+ pageable,
+ ).content
+ }
+
+ @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/seo/ExploreMetaSSRController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/ExploreMetaSSRController.kt
new file mode 100644
index 00000000..71ada887
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/ExploreMetaSSRController.kt
@@ -0,0 +1,81 @@
+package de.interaapps.pastefy.controller.seo
+
+import de.interaapps.pastefy.config.PastefyProperties
+import de.interaapps.pastefy.repositories.TagListingRepository
+import de.interaapps.pastefy.service.FrontendIndexService
+import de.interaapps.pastefy.service.SeoPageCacheService
+import de.interaapps.pastefy.service.SeoPageContentService
+import de.interaapps.pastefy.service.SeoPasteListService
+import de.interaapps.pastefy.service.SeoRenderer
+import org.springframework.data.domain.PageRequest
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.RestController
+
+@RestController
+class ExploreMetaSSRController(
+ private val properties: PastefyProperties,
+ private val pasteLists: SeoPasteListService,
+ private val tagListings: TagListingRepository,
+ private val seo: SeoRenderer,
+ private val seoCache: SeoPageCacheService,
+ private val seoContent: SeoPageContentService,
+ private val frontendIndex: FrontendIndexService,
+) {
+ @GetMapping("/explore")
+ fun exploreMeta(): ResponseEntity {
+ if (!properties.publicPastesEnabled) return frontendIndex.frontend()
+
+ return seoCache.renderResponse("explore", { frontendIndex.frontend() }) {
+ val title = "Explore | Pastefy"
+ val description =
+ "Explore public pastes on Pastefy, discover popular tags, browse trending snippets and see the latest shared developer content."
+ val topTags = tagListings.findAllByOrderByPasteCountDesc(PageRequest.of(0, 4))
+ val recentTrending = pasteLists.recentTrending(6)
+ val latest = pasteLists.latestPublic(6)
+ val allTimeTrending = pasteLists.allTimeTrending(6)
+
+ val tagsContent = if (topTags.isEmpty()) {
+ seo.paragraph("No public tags have been listed yet.")
+ } else {
+ seo.unorderedList(
+ topTags.map { tag ->
+ val name = seo.normalizeText(tag.displayName, tag.tag)
+ val descriptionText = seo.normalizeText(tag.description, "Public pastes tagged \"$name\".")
+ buildString {
+ append(seo.link(seo.absoluteUrl("/tags/${seo.pathSegment(tag.tag)}"), name, "seo-tag"))
+ append(seo.paragraph(seo.truncate(descriptionText, 140)))
+ append(seo.definitionList(mapOf("Public pastes" to tag.pasteCount.toString())))
+ }
+ },
+ cssClass = "seo-tag-list",
+ )
+ }
+
+ seo.page("/explore", title, description)
+ .image("/thumbnail.png")
+ .content(
+ seo.mainContent(
+ seo.heading(1, "Explore public pastes"),
+ seo.paragraph(description),
+ seo.section("Popular tags", tagsContent),
+ seoContent.pasteResponseListSection(
+ "Trending",
+ recentTrending,
+ "There are no trending public pastes right now.",
+ ),
+ seoContent.pasteResponseListSection(
+ "Latest public pastes",
+ latest,
+ "There are no latest public pastes yet.",
+ ),
+ seoContent.pasteResponseListSection(
+ "All-time trending",
+ allTimeTrending,
+ "There are no all-time trending public pastes yet.",
+ ),
+ ),
+ )
+ }
+ }
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/HomeMetaSSRController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/HomeMetaSSRController.kt
new file mode 100644
index 00000000..648fce3b
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/HomeMetaSSRController.kt
@@ -0,0 +1,62 @@
+package de.interaapps.pastefy.controller.seo
+
+import de.interaapps.pastefy.service.FrontendIndexService
+import de.interaapps.pastefy.service.SeoPageCacheService
+import de.interaapps.pastefy.service.SeoRenderer
+import de.interaapps.pastefy.service.StatsService
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.RestController
+
+@RestController
+class HomeMetaSSRController(
+ private val seo: SeoRenderer,
+ private val seoCache: SeoPageCacheService,
+ private val stats: StatsService,
+ private val frontendIndex: FrontendIndexService,
+) {
+ @GetMapping(value = ["/", "/home"])
+ fun homeMeta(): ResponseEntity {
+ return seoCache.renderResponse("home", { frontendIndex.frontend() }) {
+ val appStats = runCatching { stats.get() }.getOrNull()
+ val title = "Pastefy • Share your code snippets"
+ val description =
+ "Pastefy is an open source pastebin and GitHub Gist alternative for sharing, organizing, previewing and protecting code snippets."
+
+ val highlights = listOf(
+ seo.link(seo.absoluteUrl("/"), "Share code snippets with syntax highlighting and raw access."),
+ seo.link(seo.absoluteUrl("/"), "Organize pastes in folders for web, mobile, scripts and other projects."),
+ seo.link(seo.absoluteUrl("/"), "Keep sensitive snippets private or encrypted when they should not be public."),
+ seo.link(seo.absoluteUrl("/tools"), "Preview, convert and inspect developer files with Pastefy tools."),
+ seo.link("https://docs.pastefy.app/api/", "Use the Pastefy API and client integrations."),
+ )
+
+ val statDetails = appStats?.let {
+ seo.definitionList(
+ mapOf(
+ "Created pastes" to it.createdPastes.toString(),
+ "Users" to it.userCount.toString(),
+ "Tags" to it.tagCount.toString(),
+ "Folders" to it.folderCount.toString(),
+ ),
+ )
+ }.orEmpty()
+
+ seo.page("/", title, description)
+ .image("/thumbnail.png")
+ .content(
+ seo.mainContent(
+ seo.heading(1, "Pastefy"),
+ seo.paragraph(description),
+ seo.section("What you can do", seo.unorderedList(highlights)),
+ seo.section("Platform", statDetails),
+ seo.section(
+ "Explore",
+ seo.paragraph("Browse public pastes, discover tags and search shared snippets from the Pastefy community."),
+ seo.link(seo.absoluteUrl("/explore"), "Explore public pastes"),
+ ),
+ ),
+ )
+ }
+ }
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/PasteMetaSSRController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/PasteMetaSSRController.kt
new file mode 100644
index 00000000..7c8346de
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/PasteMetaSSRController.kt
@@ -0,0 +1,101 @@
+package de.interaapps.pastefy.controller.seo
+
+import de.interaapps.pastefy.config.PastefyProperties
+import de.interaapps.pastefy.entities.Paste
+import de.interaapps.pastefy.repositories.PasteAIInfoRepository
+import de.interaapps.pastefy.service.FrontendIndexService
+import de.interaapps.pastefy.service.PasteService
+import de.interaapps.pastefy.service.SeoAuthor
+import de.interaapps.pastefy.service.SeoPageCacheService
+import de.interaapps.pastefy.service.SeoPageContentService
+import de.interaapps.pastefy.service.SeoRenderer
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.RestController
+
+@RestController
+class PasteMetaSSRController(
+ private val pasteService: PasteService,
+ private val aiInfoRepository: PasteAIInfoRepository,
+ private val properties: PastefyProperties,
+ private val seo: SeoRenderer,
+ private val seoCache: SeoPageCacheService,
+ private val seoContent: SeoPageContentService,
+ private val frontendIndex: FrontendIndexService,
+) {
+ @GetMapping("/{id:[A-Za-z0-9_-]+}")
+ fun getPasteMetaSSR(@PathVariable id: String): ResponseEntity {
+ if (!id.matches(Regex("^[A-Za-z0-9_-]{8}$"))) return frontendIndex.frontend()
+
+ return seoCache.renderResponse("paste:$id", { frontendIndex.frontend() }) {
+ val paste = pasteService.get(id) ?: return@renderResponse null
+
+ if (paste.isPrivate || paste.encrypted) return@renderResponse null
+
+ val aiInfo = paste.id?.let(aiInfoRepository::findById)?.orElse(null)
+
+ val title = seoContent.title(paste)
+ val author = if (paste.isPublic) seoContent.author(paste) 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) pasteSeoContent(
+ pasteService.getContent(paste, withCache = false).orEmpty(),
+ paste,
+ 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}")
+ }
+
+ page
+ }
+ }
+
+ private fun pasteSeoContent(
+ content: String,
+ paste: Paste,
+ title: String,
+ author: SeoAuthor?,
+ 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 aiHtml =
+ aiDescription?.takeIf(String::isNotBlank)?.let { "Description
${seo.escapeHtml(it)}
" }
+ .orEmpty()
+ val tags = seoContent.tagLinks(paste)
+
+ return seo.mainContent(
+ seo.heading(1, title, "title=\"paste-title\""),
+ authorHtml,
+ seo.paragraph("View and share code snippets on Pastefy."),
+ "${seo.escapeHtml(preview)}
",
+ seo.section("Paste details", seoContent.pasteMetadata(paste, author)),
+ seo.section("Tags", tags),
+ aiHtml,
+ )
+ }
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/TagMetaSSRController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/TagMetaSSRController.kt
new file mode 100644
index 00000000..238c89ae
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/TagMetaSSRController.kt
@@ -0,0 +1,51 @@
+package de.interaapps.pastefy.controller.seo
+
+import de.interaapps.pastefy.config.PastefyProperties
+import de.interaapps.pastefy.service.FrontendIndexService
+import de.interaapps.pastefy.service.SeoPageCacheService
+import de.interaapps.pastefy.service.SeoRenderer
+import de.interaapps.pastefy.service.TagListingService
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RestController
+
+@RestController
+@RequestMapping("/tags")
+class TagMetaSSRController(
+ private val properties: PastefyProperties,
+ private val tags: TagListingService,
+ private val seo: SeoRenderer,
+ private val seoCache: SeoPageCacheService,
+ private val frontendIndex: FrontendIndexService,
+) {
+ @GetMapping("/{tag}")
+ fun tagMeta(@PathVariable tag: String): ResponseEntity {
+ if (!properties.publicPastesEnabled || tag.isBlank()) return frontendIndex.frontend()
+
+ return seoCache.renderResponse("tag:${tag.trim().lowercase()}", { frontendIndex.frontend() }) {
+ val listing = tags.getOrCreate(tag)
+ val name = seo.truncate(listing.displayName?.takeIf(String::isNotBlank)?.trim() ?: listing.tag, 120)
+ val description = seo.truncate(
+ listing.description?.takeIf(String::isNotBlank)?.trim()
+ ?: "Explore public pastes tagged \"$name\" on Pastefy.",
+ 180,
+ )
+ val details = buildMap {
+ put("Public pastes", listing.pasteCount.toString())
+ listing.website?.takeIf(String::isNotBlank)?.let { put("Website", it) }
+ listing.icon?.takeIf(String::isNotBlank)?.let { put("Icon", it) }
+ }
+ seo.page("/tags/${seo.pathSegment(listing.tag)}", "$name | Pastefy", description)
+ .content(
+ seo.mainContent(
+ seo.heading(1, name),
+ seo.paragraph(description),
+ seo.definitionList(details),
+ ),
+ )
+ .image(listing.imageUrl)
+ }
+ }
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/UserMetaSSRController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/UserMetaSSRController.kt
new file mode 100644
index 00000000..759d8a8f
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/seo/UserMetaSSRController.kt
@@ -0,0 +1,68 @@
+package de.interaapps.pastefy.controller.seo
+
+import de.interaapps.pastefy.config.PastefyProperties
+import de.interaapps.pastefy.service.FrontendIndexService
+import de.interaapps.pastefy.service.SeoPageCacheService
+import de.interaapps.pastefy.service.SeoPageContentService
+import de.interaapps.pastefy.service.SeoPasteListService
+import de.interaapps.pastefy.service.SeoRenderer
+import de.interaapps.pastefy.service.UserService
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.RestController
+
+@RestController
+class UserMetaSSRController(
+ private val properties: PastefyProperties,
+ private val users: UserService,
+ private val pasteLists: SeoPasteListService,
+ private val seo: SeoRenderer,
+ private val seoCache: SeoPageCacheService,
+ private val seoContent: SeoPageContentService,
+ private val frontendIndex: FrontendIndexService,
+) {
+ @GetMapping("/@{name}")
+ fun userMeta(@PathVariable name: String): ResponseEntity {
+ if (!properties.publicPastesEnabled || name.isBlank()) return frontendIndex.frontend()
+
+ return seoCache.renderResponse("user:${name.trim().lowercase()}", { frontendIndex.frontend() }) {
+ val user = users.getByName(name) ?: return@renderResponse null
+ val username = seo.normalizeText(user.uniqueName, name)
+ val displayName = seo.normalizeText(user.name, username)
+ val title = "$displayName | Pastefy"
+ val description = seo.truncate(
+ "View @$username on Pastefy and browse public pastes shared by $displayName.",
+ 180,
+ )
+ val publicPastes = pasteLists.publicByUser(user.id, 9)
+
+ seo.page("/@${seo.pathSegment(username)}", title, description)
+ .type("profile")
+ .image(user.avatar)
+ .meta("author", "$displayName (@$username)")
+ .openGraph("profile:username", username)
+ .content(
+ seo.mainContent(
+ seo.heading(1, displayName),
+ seo.paragraph("@$username"),
+ seo.definitionList(
+ buildMap {
+ put("User id", user.id)
+ user.createdAt?.let { put("Joined", it.toString()) }
+ },
+ ),
+ seoContent.pasteResponseListSection(
+ "Public pastes",
+ publicPastes,
+ "This user has no public pastes yet.",
+ ),
+ seo.link(
+ seo.absoluteUrl("/search?search=%40${seo.pathSegment(username)}&scope=public&page=1"),
+ "Search this user's public pastes",
+ ),
+ ),
+ )
+ }
+ }
+}
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
new file mode 100644
index 00000000..4c87bea8
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/stats/StatsController.kt
@@ -0,0 +1,29 @@
+package de.interaapps.pastefy.controller.stats
+
+import de.interaapps.pastefy.auth.annotations.CurrentAuthKey
+import de.interaapps.pastefy.auth.annotations.CurrentUser
+import de.interaapps.pastefy.dto.app.StatsResponse
+import de.interaapps.pastefy.entities.AuthKey
+import de.interaapps.pastefy.entities.User
+import de.interaapps.pastefy.config.PastefyProperties
+import de.interaapps.pastefy.exceptions.PermissionsDeniedException
+import de.interaapps.pastefy.service.StatsService
+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/app/stats")
+class StatsController(
+ private val properties: PastefyProperties,
+ private val statsService: StatsService,
+) {
+ @GetMapping
+ fun stats(@CurrentAuthKey authKey: AuthKey?, @CurrentUser user: User?): StatsResponse {
+ 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/controller/user/ApiKeyController.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/user/ApiKeyController.kt
new file mode 100644
index 00000000..6d820d42
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/user/ApiKeyController.kt
@@ -0,0 +1,39 @@
+package de.interaapps.pastefy.controller.user
+
+import de.interaapps.pastefy.auth.annotations.*
+import de.interaapps.pastefy.dto.ActionResponse
+import de.interaapps.pastefy.dto.user.CreateAuthKeyResponse
+import de.interaapps.pastefy.entities.AuthKey
+import de.interaapps.pastefy.entities.User
+import de.interaapps.pastefy.repositories.AuthKeyRepository
+import org.springframework.web.bind.annotation.*
+
+@RestController
+@RequestMapping("/api/v2/user/keys")
+@Authenticated
+class ApiKeyController(
+ private val repository: AuthKeyRepository,
+) {
+ @PostMapping
+ @RequiresPermission("authkeys:create")
+ fun addKey(@CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): CreateAuthKeyResponse {
+ val created = repository.save(AuthKey(userId = user.id, type = AuthKey.Type.API))
+ return CreateAuthKeyResponse(key = created.key, success = true)
+ }
+
+ @GetMapping
+ @RequiresPermission("authkeys:read")
+ fun getKeys(@CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): List =
+ repository.findAllByUserIdAndType(user.id, AuthKey.Type.API).map(AuthKey::key)
+
+ @DeleteMapping("/{key}")
+ @RequiresPermission("authkeys:delete")
+ 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
new file mode 100644
index 00000000..7e255faa
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/user/NotificationController.kt
@@ -0,0 +1,41 @@
+package de.interaapps.pastefy.controller.user
+
+import de.interaapps.pastefy.auth.annotations.*
+import de.interaapps.pastefy.dto.ActionResponse
+import de.interaapps.pastefy.entities.AuthKey
+import de.interaapps.pastefy.entities.Notification
+import de.interaapps.pastefy.entities.User
+import de.interaapps.pastefy.service.NotificationService
+import jakarta.servlet.http.HttpServletRequest
+import org.springframework.web.bind.annotation.*
+
+@RestController
+@RequestMapping("/api/v2/user/notification")
+@Authenticated
+class NotificationController(
+ private val notifications: NotificationService,
+) {
+ @PostMapping
+ @RequiresPermission("notifications:create")
+ fun add(@CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): ActionResponse = ActionResponse()
+
+ @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")
+ )
+
+ @GetMapping("/readall")
+ @RequiresPermission("notifications:edit")
+ fun readAll(@CurrentUser user: User, @CurrentAuthKey authKey: AuthKey): ActionResponse {
+ notifications.readAll(user)
+ return ActionResponse(success = true)
+ }
+}
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
new file mode 100644
index 00000000..6fbce09b
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/controller/user/UserController.kt
@@ -0,0 +1,137 @@
+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
+import de.interaapps.pastefy.dto.user.UserResponse
+import de.interaapps.pastefy.entities.AuthKey
+import de.interaapps.pastefy.entities.User
+import jakarta.servlet.http.HttpServletRequest
+import jakarta.servlet.http.HttpServletResponse
+import de.interaapps.pastefy.repositories.PasteRepository
+import de.interaapps.pastefy.repositories.SharedPasteRepository
+import de.interaapps.pastefy.service.FolderService
+import de.interaapps.pastefy.service.PasteQueryService
+import de.interaapps.pastefy.service.UserService
+import org.springframework.data.domain.PageRequest
+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/user")
+class UserController(
+ private val users: UserService,
+ private val folders: FolderService,
+ 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 {
+ UserResponse(
+ loggedIn = true,
+ id = it.id,
+ name = it.uniqueName,
+ displayName = it.name,
+ color = "#f52966",
+ profilePicture = it.avatar,
+ authType = it.authProvider?.providerName,
+ authTypes = oauth2Providers.names().toList(),
+ type = it.type,
+ )
+ } ?: UserResponse(authTypes = oauth2Providers.names().toList())
+
+ @GetMapping("/overview")
+ @Authenticated
+ @RejectAwaitingAccess
+ @RejectBlocked
+ @RequiresPermission(allOf = ["pastes:read", "folders:read"])
+ 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)
+ )
+ .map { queries.map(it, request, user) },
+ folder = foldersForUser(request, user),
+ )
+ }
+
+ @GetMapping("/folders")
+ @Authenticated
+ @RejectAwaitingAccess
+ @RejectBlocked
+ @RequiresPermission("folders:read")
+ fun getFolders(
+ request: HttpServletRequest,
+ @CurrentUser user: User,
+ @CurrentAuthKey authKey: AuthKey
+ ): List =
+ foldersForUser(request, user)
+
+ @GetMapping("/pastes")
+ @Authenticated
+ @RejectAwaitingAccess
+ @RejectBlocked
+ @RequiresPermission("pastes:read")
+ 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")
+ @Authenticated
+ @RejectAwaitingAccess
+ @RejectBlocked
+ @RequiresPermission("sharedpastes:read")
+ 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
+ }
+ }
+ }
+
+ @GetMapping("/starred-pastes")
+ @Authenticated
+ @RejectAwaitingAccess
+ @RejectBlocked
+ @RequiresPermission("stars:read")
+ 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 =
+ users.getFolders(user).filter { it.parent == null }.map {
+ folders.map(
+ it,
+ fetchChildren = !request.parameterMap.containsKey("hide_children"),
+ fetchSubChildren = !request.parameterMap.containsKey("hide_sub_children"),
+ fetchPastes = !request.parameterMap.containsKey("hide_pastes"),
+ showPrivate = true,
+ )
+ }
+}
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 00000000..963c8eab
--- /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/ExceptionResponse.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/ExceptionResponse.kt
new file mode 100644
index 00000000..022e6488
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/ExceptionResponse.kt
@@ -0,0 +1,9 @@
+package de.interaapps.pastefy.dto
+
+data class ExceptionResponse(
+ val exception: String,
+ val message: String?,
+ val success: Boolean = false,
+ val error: Boolean = true,
+ val exists: 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
new file mode 100644
index 00000000..8146aa84
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/analytics/AnalyticsDtos.kt
@@ -0,0 +1,56 @@
+package de.interaapps.pastefy.dto.analytics
+
+import jakarta.servlet.http.HttpServletRequest
+import java.time.Instant
+import java.time.temporal.ChronoUnit
+
+data class AnalyticsResponse(
+ var totalVisits: Long = 0,
+ var uniqueVisitors: Long = 0,
+ var botVisits: Long = 0,
+ var botTrackingEnabled: Boolean = false,
+ val series: MutableList = mutableListOf(),
+ val breakdown: MutableList = mutableListOf(),
+)
+
+data class SeriesPoint(val bucket: String, val visits: Long, val uniqueVisitors: Long)
+data class BreakdownPoint(val value: String, val visits: Long, val uniqueVisitors: Long)
+
+data class AnalyticsQuery(
+ var from: Instant = Instant.now().minus(30, ChronoUnit.DAYS),
+ var to: Instant = Instant.now(),
+ var interval: String = "day",
+ var groupBy: String = "country",
+ var includeSummary: Boolean = true,
+ var includeBreakdown: Boolean = true,
+ val filters: MutableMap = linkedMapOf(),
+) {
+ companion object {
+ val FILTERS = setOf(
+ "paste_key", "paste_visibility", "paste_user_id", "visit_type", "country", "region", "city",
+ "visitor_user_id", "browser", "device_type", "os", "referer_host", "acquisition", "is_bot",
+ )
+
+ fun from(request: HttpServletRequest): AnalyticsQuery {
+ val result = AnalyticsQuery()
+ result.from = request.getParameter("from")?.toInstantOrNull() ?: result.from
+ result.to = request.getParameter("to")?.toInstantOrNull() ?: result.to
+ if (result.from.isAfter(result.to)) {
+ val from = result.from
+ result.from = result.to
+ result.to = from
+ }
+ 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)
+ FILTERS.forEach { field ->
+ request.getParameter(field)?.trim()?.takeIf(String::isNotBlank)?.let { result.filters[field] = it }
+ }
+ return result
+ }
+
+ private fun String.toInstantOrNull() = runCatching { Instant.parse(this) }.getOrNull()
+ }
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/app/AppDtos.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/app/AppDtos.kt
new file mode 100644
index 00000000..cd260c03
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/app/AppDtos.kt
@@ -0,0 +1,24 @@
+package de.interaapps.pastefy.dto.app
+
+data class AppInfoResponse(
+ val customLogo: String? = null,
+ val customName: String? = null,
+ val aiEnabled: Boolean = false,
+ val analyticsEnabled: Boolean = false,
+ val customFooter: Map = emptyMap(),
+ val loginRequiredForRead: Boolean = false,
+ val loginRequiredForCreate: Boolean = false,
+ val encryptionIsDefault: Boolean = false,
+ val publicPastesEnabled: Boolean = false,
+)
+
+data class StatsResponse(
+ val createdPastes: Int = 0,
+ val loggedInPastes: Int = 0,
+ val userCount: Int = 0,
+ val tagCount: Int = 0,
+ val folderCount: Int = 0,
+ val indexedPastes: Long? = null,
+ val s3Pastes: Int? = null,
+ val databasePastes: Int? = null,
+)
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/auth/InteraAppsExternalAccessRequest.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/auth/InteraAppsExternalAccessRequest.kt
new file mode 100644
index 00000000..169904ae
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/auth/InteraAppsExternalAccessRequest.kt
@@ -0,0 +1,13 @@
+package de.interaapps.pastefy.dto.auth
+
+import jakarta.validation.constraints.NotBlank
+
+data class InteraAppsExternalAccessRequest(
+ val provider: String? = null,
+ @field:NotBlank val appId: String,
+ @field:NotBlank val appSecret: String,
+ val scope: String? = null,
+ val scopeList: List = emptyList(),
+ val appScopeList: List = emptyList(),
+ @field:NotBlank val userId: String,
+)
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/folder/FolderDtos.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/folder/FolderDtos.kt
new file mode 100644
index 00000000..1c93ffc0
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/folder/FolderDtos.kt
@@ -0,0 +1,24 @@
+package de.interaapps.pastefy.dto.folder
+
+import de.interaapps.pastefy.dto.pastes.PasteResponse
+import jakarta.validation.constraints.NotBlank
+
+data class CreateFolderRequest(
+ @field:NotBlank val name: String,
+ val parent: String? = null,
+)
+
+data class FolderResponse(
+ val exists: Boolean = false,
+ val id: String? = null,
+ val name: String? = null,
+ val userId: String? = null,
+ val children: List? = null,
+ val pastes: List? = null,
+ val created: String = "0000-00-00 00:00:00",
+)
+
+data class CreateFolderResponse(
+ val folder: FolderResponse? = null,
+ 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 00000000..4269be9c
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/CreatePasteRequest.kt
@@ -0,0 +1,20 @@
+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? = null,
+ val expireAt: String? = null,
+ val forkedFrom: String? = null,
+ val tags: List? = null,
+ val visibility: PasteVisibility = PasteVisibility.UNLISTED,
+ val type: PasteType = PasteType.PASTE,
+ val ai: Boolean = false,
+)
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 00000000..42f59241
--- /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? = null,
+ 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 00000000..a065f2b7
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/EditPasteRequest.kt
@@ -0,0 +1,15 @@
+package de.interaapps.pastefy.dto.pastes
+
+import de.interaapps.pastefy.enums.PasteType
+import de.interaapps.pastefy.enums.PasteVisibility
+
+data class EditPasteRequest(
+ val title: String? = null,
+ val content: String? = null,
+ val encrypted: Boolean? = null,
+ val folder: String? = null,
+ val expireAt: String? = null,
+ val tags: List? = null,
+ val visibility: PasteVisibility? = null,
+ val type: PasteType? = null,
+)
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/PasteCommentDtos.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/PasteCommentDtos.kt
new file mode 100644
index 00000000..782b4d65
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/PasteCommentDtos.kt
@@ -0,0 +1,46 @@
+package de.interaapps.pastefy.dto.pastes
+
+import de.interaapps.pastefy.entities.AIWarning
+
+import de.interaapps.pastefy.dto.user.PublicUserDto
+
+data class CreatePasteCommentRequest(
+ val content: String? = null,
+ val parentId: Int? = null,
+ val lineFrom: Int? = null,
+ val lineTo: Int? = null,
+)
+
+data class PasteCommentResponse(
+ val id: Int,
+ val content: String,
+ val parentId: Int? = null,
+ val lineFrom: Int? = null,
+ val lineTo: Int? = null,
+ val createdAt: String,
+ val user: PublicUserDto? = null,
+ val replies: List = emptyList(),
+)
+
+data class PasteCommentMarkerResponse(
+ val line: Int,
+ val profiles: List = emptyList(),
+ val additionalProfiles: Int = 0,
+)
+
+data class AddFriendToPasteRequest(
+ val friend: String,
+)
+
+data class MultiPastesElement(
+ val name: String,
+ val contents: String,
+)
+
+data class PasteAiInfoResponse(
+ val dangerous: Boolean = false,
+ val suggestedFilename: String? = null,
+ val warnings: List? = null,
+ val tags: List? = null,
+ val description: String? = null,
+)
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 00000000..8d423c7d
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/pastes/PasteResponse.kt
@@ -0,0 +1,35 @@
+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? = null,
+ var content: String? = null,
+ var title: String? = null,
+
+ var visibility: PasteVisibility = PasteVisibility.UNLISTED,
+ var type: PasteType = PasteType.PASTE,
+
+ 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: List? = null,
+
+ 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/dto/user/PublicUserDto.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/user/PublicUserDto.kt
new file mode 100644
index 00000000..5afc9f1b
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/user/PublicUserDto.kt
@@ -0,0 +1,8 @@
+package de.interaapps.pastefy.dto.user
+
+data class PublicUserDto(
+ val id: String? = null,
+ val name: String? = null,
+ val displayName: String? = null,
+ val avatar: String? = null,
+)
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/user/UserDtos.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/user/UserDtos.kt
new file mode 100644
index 00000000..f031adf8
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/dto/user/UserDtos.kt
@@ -0,0 +1,34 @@
+package de.interaapps.pastefy.dto.user
+
+import de.interaapps.pastefy.dto.folder.FolderResponse
+import de.interaapps.pastefy.dto.pastes.PasteResponse
+import de.interaapps.pastefy.entities.User
+import jakarta.validation.constraints.Size
+
+data class UserResponse(
+ val loggedIn: Boolean = false,
+ val id: String? = null,
+ val name: String? = null,
+ val displayName: String? = null,
+ val color: String? = null,
+ val profilePicture: String? = null,
+ val authType: String? = null,
+ val authTypes: List = emptyList(),
+ val type: User.Type? = null,
+)
+
+data class UserPastesResponse(
+ val pastes: List = emptyList(),
+ val folder: List = emptyList(),
+)
+
+data class CreateAuthKeyResponse(
+ val key: String? = null,
+ val success: Boolean = false,
+)
+
+data class EditUserRequest(
+ @field:Size(min = 2, max = 255) val name: String? = null,
+ @field:Size(min = 2, max = 33) val uniqueName: String? = null,
+ val type: User.Type? = null,
+)
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/AuthKey.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/AuthKey.kt
new file mode 100644
index 00000000..0d143b85
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/AuthKey.kt
@@ -0,0 +1,78 @@
+package de.interaapps.pastefy.entities
+
+import de.interaapps.pastefy.exceptions.PermissionsDeniedException
+import de.interaapps.pastefy.util.RandomStrings
+import jakarta.persistence.*
+import com.vladmihalcea.hibernate.type.json.JsonType
+import org.hibernate.annotations.Type as HibernateType
+import java.time.Instant
+
+@Entity
+@Table(
+ name = "pastefy_auth_keys",
+ indexes = [
+ Index(name = "pastefy_auth_keys_key_index", columnList = "`key`"),
+ Index(name = "pastefy_auth_keys_user_id_index", columnList = "user_id"),
+ ],
+)
+class AuthKey(
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ var id: Int? = null,
+
+ @Column(name = "`key`", length = 60, nullable = true, unique = true)
+ var key: String = RandomStrings.alphanumeric(60),
+
+ @Column(length = 255)
+ var accessToken: String? = null,
+
+ @Column(length = 255)
+ var refreshToken: String? = null,
+
+ @Column(length = 8, nullable = true)
+ var userId: String = "",
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = true)
+ var type: Type = Type.USER,
+
+ @HibernateType(JsonType::class)
+ @Column(columnDefinition = "json")
+ var scopes: MutableList? = null,
+
+ @Column(nullable = false, updatable = false)
+ var createdAt: Instant? = null,
+
+ @Column(nullable = false)
+ var updatedAt: Instant? = null,
+) {
+ fun hasPermission(permission: String): Boolean {
+ if (permission.isBlank()) return true
+ return hasScope(permission) || hasScope(permission.substringBefore(':'))
+ }
+
+ fun checkPermissions(vararg permissions: String) {
+ if (permissions.none(::hasPermission)) throw PermissionsDeniedException()
+ }
+
+ fun addScope(scope: String): AuthKey = apply {
+ val currentScopes = scopes ?: mutableListOf().also { scopes = it }
+ currentScopes += scope
+ }
+
+ private fun hasScope(scope: String): Boolean = type != Type.ACCESS_TOKEN || scopes?.contains(scope) == true
+
+ @PrePersist
+ fun prePersist() {
+ val now = Instant.now()
+ if (createdAt == null) createdAt = now
+ updatedAt = now
+ }
+
+ @PreUpdate
+ fun preUpdate() {
+ updatedAt = Instant.now()
+ }
+
+ enum class Type { API, USER, ACCESS_TOKEN }
+}
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
new file mode 100644
index 00000000..33fb2709
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/BackgroundJob.kt
@@ -0,0 +1,44 @@
+package de.interaapps.pastefy.entities
+
+import jakarta.persistence.*
+import java.time.Instant
+
+@Entity
+@Table(
+ name = "pastefy_background_jobs",
+ indexes = [
+ Index(name = "pastefy_background_jobs_status_available_at_index", columnList = "status, available_at"),
+ Index(name = "pastefy_background_jobs_status_lease_until_index", columnList = "status, lease_until"),
+ Index(name = "pastefy_background_jobs_type_entity_id_index", columnList = "type, entity_id"),
+ ],
+)
+class BackgroundJob(
+ @Id @Column(name = "`key`", length = 255) var key: String = "",
+ @Enumerated(EnumType.STRING) @Column(nullable = false) var type: Type = Type.PASTE_AI_INFO,
+ @Column(nullable = false) var entityId: Int = 0,
+ @Column(nullable = false) var sourceVersion: Int = 0,
+ @Column(nullable = false) var promptVersion: Int = 0,
+ @Enumerated(EnumType.STRING) @Column(nullable = false) var status: Status = Status.PENDING,
+ @Column(nullable = false) var attempts: Int = 0,
+ var availableAt: Instant? = null,
+ var leaseUntil: Instant? = null,
+ @Column(length = 64) var leaseToken: String? = null,
+ @Column(length = 2048) var lastError: String? = null,
+ @Column(nullable = false, updatable = false) var createdAt: Instant? = null,
+ @Column(nullable = false) var updatedAt: Instant? = null,
+) {
+ @PrePersist
+ fun prePersist() {
+ val now = Instant.now()
+ if (createdAt == null) createdAt = now
+ updatedAt = 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/Folder.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/Folder.kt
new file mode 100644
index 00000000..80381d4e
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/Folder.kt
@@ -0,0 +1,49 @@
+package de.interaapps.pastefy.entities
+
+import de.interaapps.pastefy.util.RandomStrings
+import jakarta.persistence.*
+import java.time.Instant
+
+@Entity
+@Table(
+ name = "pastefy_folder",
+ indexes = [
+ Index(name = "pastefy_folder_key_index", columnList = "`key`"),
+ Index(name = "pastefy_folder_user_id_index", columnList = "user_id"),
+ ],
+)
+class Folder(
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ var id: Int? = null,
+
+ @Column(name = "`key`", length = 8, nullable = true, unique = true)
+ var key: String = RandomStrings.alphanumeric(8),
+
+ @Column(columnDefinition = "TEXT", nullable = true)
+ var name: String = "",
+
+ @Column(length = 8, nullable = true)
+ var userId: String = "",
+
+ @Column(columnDefinition = "TEXT")
+ var parent: String? = null,
+
+ @Column(nullable = false, updatable = false)
+ var createdAt: Instant? = null,
+
+ @Column(nullable = false)
+ var updatedAt: Instant? = null,
+) {
+ @PrePersist
+ fun prePersist() {
+ val now = Instant.now()
+ if (createdAt == null) createdAt = now
+ updatedAt = now
+ }
+
+ @PreUpdate
+ fun preUpdate() {
+ updatedAt = Instant.now()
+ }
+}
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
new file mode 100644
index 00000000..347922d4
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/Notification.kt
@@ -0,0 +1,36 @@
+package de.interaapps.pastefy.entities
+
+import jakarta.persistence.*
+import java.time.Instant
+
+@Entity
+@Table(
+ name = "pastefy_notification",
+ indexes = [
+ Index(name = "pastefy_notification_user_id_received_index", columnList = "user_id, received"),
+ ],
+)
+class Notification(
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ var id: Int? = null,
+ @Column(columnDefinition = "TEXT") var message: String? = null,
+ @Column(length = 8, nullable = true) var userId: String = "",
+ @Column(columnDefinition = "TEXT") var url: String? = null,
+ @Column(nullable = true) var alreadyRead: Boolean = false,
+ @Column(nullable = true) var received: Boolean = false,
+ @Column(nullable = false, updatable = false) var createdAt: Instant? = null,
+ @Column(nullable = false) var updatedAt: Instant? = null,
+) {
+ @PrePersist
+ fun prePersist() {
+ val now = Instant.now()
+ if (createdAt == null) createdAt = now
+ updatedAt = 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
new file mode 100644
index 00000000..b9e0cf8b
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/Paste.kt
@@ -0,0 +1,156 @@
+package de.interaapps.pastefy.entities
+
+import de.interaapps.pastefy.enums.PasteType
+import de.interaapps.pastefy.enums.PasteVisibility
+import de.interaapps.pastefy.enums.StorageType
+import de.interaapps.pastefy.util.RandomStrings
+import jakarta.persistence.*
+import java.nio.charset.StandardCharsets
+import java.security.MessageDigest
+import java.time.Instant
+import java.util.HexFormat
+
+@Entity
+@Table(
+ name = "pastefy_pastes",
+ indexes = [
+ Index(name = "pastefy_pastes_expire_at_index", columnList = "expire_at"),
+ Index(name = "pastefy_pastes_folder_index", columnList = "folder"),
+ Index(name = "pastefy_pastes_hash_index", columnList = "hash"),
+ Index(name = "pastefy_pastes_id_index", columnList = "id"),
+ Index(name = "pastefy_pastes_indexed_in_elastic_index", columnList = "indexed_in_elastic"),
+ Index(name = "pastefy_pastes_key_index", columnList = "`key`"),
+ Index(name = "pastefy_pastes_length_index", columnList = "length"),
+ Index(name = "pastefy_pastes_storage_type_index", columnList = "storage_type"),
+ Index(name = "pastefy_pastes_user_folder_index", columnList = "user_id, folder"),
+ Index(name = "pastefy_pastes_user_id_index", columnList = "user_id"),
+ Index(name = "pastefy_pastes_visibility_index", columnList = "visibility"),
+ ],
+)
+class Paste(
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ var id: Int? = null,
+
+ @Column(name = "`key`", length = 8, nullable = true, unique = true)
+ var key: String = RandomStrings.alphanumeric(8),
+
+ @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,
+
+ @Column(columnDefinition = "TEXT")
+ var title: String? = null,
+
+ @Lob
+ @Column(columnDefinition = "LONGTEXT")
+ var content: String? = null,
+
+ @Column(length = 8)
+ var userId: String? = null,
+
+ @Column(length = 8)
+ var forkedFrom: String? = null,
+
+ @Column(nullable = true)
+ var encrypted: Boolean = false,
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = true, columnDefinition = "ENUM('PASTE','MULTI_PASTE')")
+ var type: PasteType? = PasteType.PASTE,
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = true, columnDefinition = "ENUM('UNLISTED','PUBLIC','PRIVATE')")
+ var visibility: PasteVisibility? = PasteVisibility.UNLISTED,
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = true, columnDefinition = "ENUM('S3','DATABASE','HTTP')")
+ var storageType: StorageType? = StorageType.DATABASE,
+
+ @Column(nullable = true)
+ var version: Int? = 0,
+
+ @Column(nullable = true)
+ var indexedInElastic: Boolean = false,
+
+ @Column(nullable = true)
+ var length: Int? = 0,
+
+ @Column(length = 64)
+ var hash: String? = null,
+
+ ) {
+
+ @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
+ updateContentMetadata(content)
+ }
+
+ fun setStorageReference(reference: String?, storageType: StorageType) {
+ content = reference
+ this.storageType = storageType
+ }
+
+ @PrePersist
+ fun prePersist() {
+ val now = Instant.now()
+
+ if (createdAt == null) {
+ createdAt = now
+ }
+
+ updatedAt = now
+ version = version?.plus(1) ?: 1
+ indexedInElastic = false
+
+ if (key.isBlank()) {
+ key = RandomStrings.alphanumeric(8)
+ }
+
+ if (storageType == StorageType.DATABASE) {
+ updateContentMetadata(content)
+ }
+ }
+
+ @PreUpdate
+ fun preUpdate() {
+ updatedAt = Instant.now()
+ version = version?.plus(1) ?: 1
+ indexedInElastic = false
+
+ if (storageType == StorageType.DATABASE) {
+ updateContentMetadata(content)
+ }
+ }
+
+ private fun updateContentMetadata(content: String?) {
+ if (content == null) {
+ length = 0
+ hash = null
+ return
+ }
+ val bytes = content.toByteArray(StandardCharsets.UTF_8)
+ length = bytes.size
+ hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes))
+ }
+}
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
new file mode 100644
index 00000000..c12ab06a
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/PasteAIInfo.kt
@@ -0,0 +1,66 @@
+package de.interaapps.pastefy.entities
+
+import com.fasterxml.jackson.core.type.TypeReference
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import jakarta.persistence.*
+import java.time.Instant
+
+@Entity
+@Table(name = "pastefy_paste_ai_info")
+class PasteAIInfo(
+ @Id var pasteId: Int = 0,
+ @Column(nullable = true) var sourcePasteVersion: Int = 0,
+ @Column(nullable = true) var promptVersion: Int = 0,
+ @Column(length = 30) var provider: String? = null,
+ @Column(length = 100) var model: String? = null,
+ @Column(length = 500) var description: String? = null,
+ @Convert(converter = StringListJsonConverter::class)
+ @Column(length = 2048) var tagsJson: MutableList? = null,
+ @Convert(converter = AIWarningListJsonConverter::class)
+ @Column(length = 4096) var warningsJson: MutableList? = null,
+ @Column(nullable = true) var dangerous: Boolean = false,
+ @Column(nullable = true) var maxSeverity: Int = 0,
+ @Column(length = 255) var suggestedFilename: String? = null,
+ var generatedAt: Instant? = null,
+ @Column(nullable = false, updatable = false) var createdAt: Instant? = null,
+ @Column(nullable = false) var updatedAt: Instant? = null,
+) {
+ @PrePersist
+ fun prePersist() {
+ val now = Instant.now()
+ if (createdAt == null) createdAt = now
+ updatedAt = now
+ }
+
+ @PreUpdate
+ fun preUpdate() {
+ updatedAt = Instant.now()
+ }
+}
+
+data class AIWarning(
+ val description: String = "",
+ val severity: Int = 1,
+)
+
+@Converter
+class StringListJsonConverter : AttributeConverter?, String?> {
+ override fun convertToDatabaseColumn(attribute: MutableList?): String? =
+ attribute?.let(MAPPER::writeValueAsString)
+
+ override fun convertToEntityAttribute(dbData: String?): MutableList? =
+ dbData?.takeIf(String::isNotBlank)
+ ?.let { MAPPER.readValue(it, object : TypeReference>() {}) }
+}
+
+@Converter
+class AIWarningListJsonConverter : AttributeConverter?, String?> {
+ override fun convertToDatabaseColumn(attribute: MutableList?): String? =
+ attribute?.let(MAPPER::writeValueAsString)
+
+ override fun convertToEntityAttribute(dbData: String?): MutableList? =
+ 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
new file mode 100644
index 00000000..bf349a54
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/PasteComment.kt
@@ -0,0 +1,38 @@
+package de.interaapps.pastefy.entities
+
+import jakarta.persistence.*
+import java.time.Instant
+
+@Entity
+@Table(
+ name = "pastefy_paste_comments",
+ indexes = [
+ Index(name = "pastefy_paste_comments_parent_id_index", columnList = "parent_id"),
+ Index(name = "pastefy_paste_comments_paste_index", columnList = "paste"),
+ Index(name = "pastefy_paste_comments_paste_line_index", columnList = "paste, line_from"),
+ Index(name = "pastefy_paste_comments_user_id_index", columnList = "user_id"),
+ ],
+)
+class PasteComment(
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Int? = null,
+ @Column(length = 8, nullable = false) var paste: String = "",
+ @Column(length = 8, nullable = false) var userId: String = "",
+ @Column(length = 2000, nullable = false) var content: String = "",
+ var parentId: Int? = null,
+ var lineFrom: Int? = null,
+ var lineTo: Int? = null,
+ @Column(nullable = false, updatable = false) var createdAt: Instant? = null,
+ @Column(nullable = false) var updatedAt: Instant? = null,
+) {
+ @PrePersist
+ fun prePersist() {
+ val now = Instant.now()
+ if (createdAt == null) createdAt = now
+ updatedAt = now
+ }
+
+ @PreUpdate
+ fun preUpdate() {
+ updatedAt = Instant.now()
+ }
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/PasteStar.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/PasteStar.kt
new file mode 100644
index 00000000..cd6d8c8d
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/PasteStar.kt
@@ -0,0 +1,17 @@
+package de.interaapps.pastefy.entities
+
+import jakarta.persistence.*
+
+@Entity
+@Table(
+ name = "pastefy_paste_stars",
+ indexes = [
+ Index(name = "pastefy_paste_stars_paste_index", columnList = "paste"),
+ Index(name = "pastefy_paste_stars_paste_user_id_index", columnList = "paste, user_id"),
+ ],
+)
+class PasteStar(
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Int? = null,
+ @Column(length = 8, nullable = true) var paste: String = "",
+ @Column(length = 8, nullable = true) var userId: String = "",
+)
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/PasteTag.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/PasteTag.kt
new file mode 100644
index 00000000..86c33829
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/PasteTag.kt
@@ -0,0 +1,22 @@
+package de.interaapps.pastefy.entities
+
+import jakarta.persistence.*
+
+@Entity
+@Table(
+ name = "pastefy_paste_tags",
+ indexes = [
+ Index(name = "pastefy_paste_tags_paste_tag_index", columnList = "paste, tag"),
+ ],
+)
+class PasteTag(
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Int? = null,
+ @Column(length = 8, nullable = true) var paste: String = "",
+ @Column(length = 30, nullable = true) var tag: String = "",
+) {
+ @PrePersist
+ @PreUpdate
+ fun truncateTag() {
+ if (tag.length > 30) tag = tag.substring(0, 30)
+ }
+}
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
new file mode 100644
index 00000000..a58293f8
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/PublicPasteEngagement.kt
@@ -0,0 +1,32 @@
+package de.interaapps.pastefy.entities
+
+import jakarta.persistence.*
+import java.time.Instant
+
+@Entity
+@Table(
+ name = "pastefy_public_paste_engagements",
+ indexes = [
+ Index(name = "pastefy_public_paste_engagements_id_score_index", columnList = "paste_id, score"),
+ Index(name = "pastefy_public_paste_engagements_paste_id_index", columnList = "paste_id"),
+ ],
+)
+class PublicPasteEngagement(
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Int? = null,
+ @Column(nullable = true) var pasteId: Int = 0,
+ @Column(nullable = true) var score: Int = 0,
+ @Column(nullable = false, updatable = false) var createdAt: Instant? = null,
+ @Column(nullable = false) var updatedAt: Instant? = null,
+) {
+ @PrePersist
+ fun prePersist() {
+ val now = Instant.now()
+ if (createdAt == null) createdAt = now
+ updatedAt = 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
new file mode 100644
index 00000000..297ee361
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/SharedPaste.kt
@@ -0,0 +1,33 @@
+package de.interaapps.pastefy.entities
+
+import jakarta.persistence.*
+import java.time.Instant
+
+@Entity
+@Table(
+ name = "pastefy_shared_pastes",
+ indexes = [
+ Index(name = "pastefy_shared_pastes_target_id_index", columnList = "target_id"),
+ Index(name = "pastefy_shared_pastes_user_id_index", columnList = "user_id"),
+ ],
+)
+class SharedPaste(
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Int? = null,
+ @Column(length = 8, nullable = true) var userId: String = "",
+ @Column(length = 8, nullable = true) var targetId: String = "",
+ @Column(columnDefinition = "TEXT", nullable = true) var paste: String = "",
+ @Column(nullable = false, updatable = false) var createdAt: Instant? = null,
+ @Column(nullable = false) var updatedAt: Instant? = null,
+) {
+ @PrePersist
+ fun prePersist() {
+ val now = Instant.now()
+ if (createdAt == null) createdAt = now
+ updatedAt = now
+ }
+
+ @PreUpdate
+ fun preUpdate() {
+ updatedAt = Instant.now()
+ }
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/TagListing.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/TagListing.kt
new file mode 100644
index 00000000..55cf7ae7
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/TagListing.kt
@@ -0,0 +1,21 @@
+package de.interaapps.pastefy.entities
+
+import jakarta.persistence.*
+
+@Entity
+@Table(name = "pastefy_tag_listing")
+class TagListing(
+ @Id @Column(length = 30) var tag: String = "",
+ @Column(columnDefinition = "TEXT") var displayName: String? = null,
+ @Column(columnDefinition = "TEXT") var imageUrl: String? = null,
+ @Column(columnDefinition = "TEXT") var description: String? = null,
+ @Column(columnDefinition = "TEXT") var website: String? = null,
+ @Column(columnDefinition = "TEXT") var icon: String? = null,
+ @Column(nullable = true) var pasteCount: Int = 0,
+) {
+ @PrePersist
+ @PreUpdate
+ fun truncateTag() {
+ if (tag.length > 30) tag = tag.substring(0, 30)
+ }
+}
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 00000000..3777a652
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/entities/User.kt
@@ -0,0 +1,99 @@
+package de.interaapps.pastefy.entities
+
+import de.interaapps.pastefy.auth.oauth.OAuth2Provider
+import de.interaapps.pastefy.auth.oauth.providers.*
+import de.interaapps.pastefy.util.RandomStrings
+import jakarta.persistence.*
+import java.time.Instant
+
+@Entity
+@Table(name = "pastefy_users")
+class User(
+ @Id
+ @Column(length = 8)
+ var id: String = RandomStrings.alphanumeric(8),
+
+ @Column(columnDefinition = "TEXT")
+ var name: String? = null,
+
+ @Column(length = 33)
+ var uniqueName: String? = null,
+
+ @Column(name = "email", columnDefinition = "TEXT")
+ var email: String? = null,
+
+ @Column(columnDefinition = "TEXT")
+ var avatar: String? = null,
+
+ @Column(length = 455)
+ var authId: String? = null,
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = true)
+ var authProvider: AuthenticationProvider? = null,
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = true)
+ 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
+ }
+
+}
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 00000000..b9a8191d
--- /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 00000000..9ac730b3
--- /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 00000000..d39be4cd
--- /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/exceptions/ApiExceptionHandler.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/exceptions/ApiExceptionHandler.kt
new file mode 100644
index 00000000..309c1a22
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/exceptions/ApiExceptionHandler.kt
@@ -0,0 +1,56 @@
+package de.interaapps.pastefy.exceptions
+
+import de.interaapps.pastefy.dto.ExceptionResponse
+import de.interaapps.pastefy.service.FrontendIndexService
+import jakarta.servlet.http.HttpServletRequest
+import org.slf4j.LoggerFactory
+import org.springframework.http.HttpStatus
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.MethodArgumentNotValidException
+import org.springframework.web.bind.annotation.ExceptionHandler
+import org.springframework.web.bind.annotation.RestControllerAdvice
+import org.springframework.web.servlet.NoHandlerFoundException
+import org.springframework.web.servlet.resource.NoResourceFoundException
+
+@RestControllerAdvice
+class ApiExceptionHandler(
+ private val frontendIndex: FrontendIndexService,
+) {
+ private val logger = LoggerFactory.getLogger(ApiExceptionHandler::class.java)
+
+ @ExceptionHandler(HttpException::class)
+ fun handleHttpException(exception: HttpException): ResponseEntity =
+ ResponseEntity.status(exception.status).body(exception.toResponse())
+
+ @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))
+ }
+
+ @ExceptionHandler(NoResourceFoundException::class, NoHandlerFoundException::class)
+ fun handleMissingRoute(exception: Exception, request: HttpServletRequest): ResponseEntity<*> {
+ val path = request.requestURI.trimStart('/')
+ if (path.isBackendPath()) {
+ return ResponseEntity.status(HttpStatus.NOT_FOUND)
+ .body(ExceptionResponse(exception::class.simpleName ?: "NotFoundException", "Not found"))
+ }
+ return frontendIndex.frontend()
+ }
+
+ @ExceptionHandler(Exception::class)
+ fun handleUnexpectedException(exception: Exception): ResponseEntity {
+ logger.error("Unhandled API exception", exception)
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body(ExceptionResponse(exception::class.simpleName ?: "Exception", "Internal server error"))
+ }
+
+ private fun HttpException.toResponse() = ExceptionResponse(
+ exception = this::class.simpleName ?: "HttpException",
+ message = message,
+ )
+
+ private fun String.isBackendPath(): Boolean =
+ this == "api" || startsWith("api/")
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/exceptions/HttpException.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/exceptions/HttpException.kt
new file mode 100644
index 00000000..d6e59130
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/exceptions/HttpException.kt
@@ -0,0 +1,35 @@
+package de.interaapps.pastefy.exceptions
+
+import org.springframework.http.HttpStatus
+
+open class HttpException(
+ val status: HttpStatus,
+ message: String,
+) : RuntimeException(message)
+
+class AuthenticationException(message: String = "Not authenticated") :
+ HttpException(HttpStatus.UNAUTHORIZED, message)
+
+class AwaitingAccessException(message: String = "Awaiting access") :
+ HttpException(HttpStatus.FORBIDDEN, message)
+
+class BlockedException(message: String = "Blocked") :
+ HttpException(HttpStatus.UNAUTHORIZED, message)
+
+class FeatureDisabledException(message: String = "Feature disabled") :
+ HttpException(HttpStatus.FORBIDDEN, message)
+
+class NotFoundException(message: String = "Resource not found") :
+ HttpException(HttpStatus.NOT_FOUND, message)
+
+class PastePrivateException(message: String = "Paste is private") :
+ HttpException(HttpStatus.FORBIDDEN, message)
+
+class PermissionsDeniedException(message: String = "Permission denied") :
+ HttpException(HttpStatus.FORBIDDEN, message)
+
+class TooManyRequestsException(message: String = "Too many requests") :
+ HttpException(HttpStatus.TOO_MANY_REQUESTS, message)
+
+class OAuth2Exception(message: String = "OAuth2 authentication failed") :
+ HttpException(HttpStatus.UNAUTHORIZED, message)
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 00000000..f0050c21
--- /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
new file mode 100644
index 00000000..fbeafda7
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/PasteAI.kt
@@ -0,0 +1,72 @@
+package de.interaapps.pastefy.infrastructure.ai
+
+import com.fasterxml.jackson.databind.ObjectMapper
+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.context.annotation.Conditional
+import org.springframework.stereotype.Service
+
+@Service
+@Conditional(AiEnabledCondition::class)
+class PasteAI(
+ chatModel: ChatModel,
+ private val objectMapper: ObjectMapper,
+ properties: PastefyProperties,
+) {
+ private val client = ChatClient.create(chatModel)
+ val provider: String = properties.ai.provider.trim().ifBlank { "spring-ai" }
+ val model: String = properties.ai.model.trim().ifBlank { "configured-default" }
+
+ fun generateInfo(paste: Paste, content: String): GeneratedPasteInfo {
+ val response = generate(
+ """
+ You are a metadata extraction service for Pastefy, a public paste and code sharing platform.
+ Analyze the paste title and content preview. Return useful metadata for search, discovery, moderation and SEO.
+ Return valid JSON only. Do not include markdown or explanations. Do not invent facts.
+ Do not include secrets, tokens, passwords, API keys or private data in the description.
+ Description must be neutral and at most 500 characters. Tags must be lowercase slugs using only a-z, 0-9 and hyphen.
+ Return at most 8 tags. Include exactly one lang-{language} tag if a language is identifiable.
+ dangerous is true only for obviously harmful code, malware, credential theft, destructive commands, phishing, token stealing or exploit logic.
+ Severity ranges from 1 for harmless to 10 for severe malware, credential theft or destructive code.
+ JSON schema:
+ {"tags":["string"],"description":"string","system_warnings":[{"description":"string","severity":1}],"suggested_filename":"string","dangerous":false}
+ Omit optional fields when they are not needed.
+ """.trimIndent(),
+ "Title: ${paste.title.orEmpty()}\n\nContent preview:\n${content.take(1_000)}",
+ )
+ return objectMapper.readValue(jsonObject(response), GeneratedPasteInfo::class.java)
+ }
+
+ private fun generate(systemPrompt: String, userPrompt: String): String =
+ requireNotNull(
+ client.prompt()
+ .system(systemPrompt)
+ .user(userPrompt)
+ .call()
+ .content(),
+ ) { "Spring AI returned an empty response" }
+
+ private fun jsonObject(value: String): String {
+ val start = value.indexOf('{')
+ val end = value.lastIndexOf('}')
+ require(start >= 0 && end > start) { "AI response does not contain a JSON object" }
+ val json = value.substring(start, end + 1)
+ require(objectMapper.readTree(json).isObject) { "AI response is not a JSON object" }
+ return json
+ }
+}
+
+data class GeneratedPasteInfo(
+ val tags: List = emptyList(),
+ val description: String = "",
+ val systemWarnings: List = emptyList(),
+ val suggestedFilename: String? = null,
+ val dangerous: Boolean = false,
+)
+
+data class GeneratedWarning(
+ val description: String = "",
+ val severity: Int = 1,
+)
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
new file mode 100644
index 00000000..e7719f45
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/ai/PasteAIInfoService.kt
@@ -0,0 +1,107 @@
+package de.interaapps.pastefy.infrastructure.ai
+
+import de.interaapps.pastefy.config.PastefyProperties
+import de.interaapps.pastefy.entities.AIWarning
+import de.interaapps.pastefy.entities.BackgroundJob
+import de.interaapps.pastefy.entities.Paste
+import de.interaapps.pastefy.entities.PasteAIInfo
+import de.interaapps.pastefy.infrastructure.jobs.BackgroundJobHandler
+import de.interaapps.pastefy.infrastructure.jobs.BackgroundJobService
+import de.interaapps.pastefy.repositories.PasteAIInfoRepository
+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.context.annotation.Conditional
+import org.springframework.scheduling.annotation.Scheduled
+import org.springframework.stereotype.Service
+
+@Service
+@Conditional(AiEnabledCondition::class)
+class PasteAIInfoService(
+ private val pasteAI: PasteAI,
+ private val pasteService: PasteService,
+ private val pasteRepository: PasteRepository,
+ private val infoRepository: PasteAIInfoRepository,
+ private val engagementRepository: PublicPasteEngagementRepository,
+ private val jobs: BackgroundJobService,
+ private val properties: PastefyProperties,
+) : BackgroundJobHandler {
+ override val type = BackgroundJob.Type.PASTE_AI_INFO
+
+ 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 ?: 1)) return
+ jobs.enqueue(type, pasteId, paste.version, PROMPT_VERSION)
+ }
+
+ @Scheduled(fixedDelayString = "\${pastefy.ai.jobs.sweep-interval-millis:300000}")
+ fun sweep() {
+ if (!properties.ai.jobs.sweeperEnabled) return
+ try {
+ engagementRepository.findAllByScoreGreaterThanEqual(threshold())
+ .mapNotNull { pasteRepository.findById(it.pasteId).orElse(null) }
+ .forEach(::enqueueIfEligible)
+ } catch (exception: RuntimeException) {
+ LOGGER.warn("Unable to enqueue eligible pastes for AI metadata generation", exception)
+ }
+ }
+
+ override fun handle(job: BackgroundJob) {
+ val paste = pasteRepository.findById(job.entityId).orElse(null) ?: return
+ if (paste.version != job.sourceVersion) {
+ enqueueIfEligible(paste)
+ 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
+ if (freshPaste.version != job.sourceVersion) {
+ enqueueIfEligible(freshPaste)
+ return
+ }
+
+ val warnings = generated.systemWarnings.asSequence()
+ .map { AIWarning(it.description.trim().take(300), it.severity.coerceIn(1, 10)) }
+ .filter { it.description.isNotBlank() }
+ .take(10)
+ .toMutableList()
+ val info = infoRepository.findById(job.entityId).orElse(PasteAIInfo(pasteId = job.entityId))
+ info.sourcePasteVersion = freshPaste.version ?: 1
+ info.promptVersion = PROMPT_VERSION
+ info.provider = pasteAI.provider.take(30)
+ info.model = pasteAI.model.take(100)
+ info.description = generated.description.trim().take(500)
+ info.tagsJson = generated.tags.asSequence()
+ .map { it.trim().lowercase() }
+ .filter(VALID_TAG::matches)
+ .distinct()
+ .take(8)
+ .toMutableList()
+ info.warningsJson = warnings
+ info.dangerous = generated.dangerous
+ info.maxSeverity = warnings.maxOfOrNull(AIWarning::severity) ?: 0
+ info.suggestedFilename = generated.suggestedFilename?.trim()?.takeIf(String::isNotEmpty)?.take(255)
+ info.generatedAt = java.time.Instant.now()
+ infoRepository.save(info)
+ }
+
+ private fun isEligible(paste: Paste): Boolean =
+ paste.isPublic && !paste.encrypted &&
+ engagementRepository.findFirstByPasteIdOrderByIdAsc(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)
+
+ private fun threshold() = properties.ai.engagementThreshold.coerceAtLeast(1)
+ private fun Int?.orZero() = this ?: 0
+
+ companion object {
+ private const val PROMPT_VERSION = 1
+ private val VALID_TAG = Regex("^[a-z0-9-]{1,30}$")
+ private val LOGGER = LoggerFactory.getLogger(PasteAIInfoService::class.java)
+ }
+}
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
new file mode 100644
index 00000000..095df068
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsConfiguration.kt
@@ -0,0 +1,49 @@
+package de.interaapps.pastefy.infrastructure.analytics
+
+import com.zaxxer.hikari.HikariConfig
+import com.zaxxer.hikari.HikariDataSource
+import de.interaapps.pastefy.config.PastefyProperties
+import jakarta.annotation.PreDestroy
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.jdbc.core.JdbcTemplate
+
+@Configuration
+@ConditionalOnProperty(prefix = "pastefy.analytics", name = ["enabled"], havingValue = "true")
+class AnalyticsConfiguration {
+ private var analyticsDataSource: HikariDataSource? = null
+
+ @Bean
+ fun analyticsJdbcTemplate(properties: PastefyProperties): JdbcTemplate {
+ analyticsDataSource = analyticsDataSource(properties)
+ return JdbcTemplate(requireNotNull(analyticsDataSource))
+ }
+
+ private fun analyticsDataSource(properties: PastefyProperties): HikariDataSource {
+ 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" }
+
+ val pool = properties.analytics.pool
+ val config = HikariConfig().apply {
+ poolName = "pastefy-clickhouse-analytics"
+ driverClassName = "com.clickhouse.jdbc.ClickHouseDriver"
+ jdbcUrl = clickHouseJdbcUrl(properties.analytics.jdbcUrl, properties.analytics.database)
+ username = properties.analytics.user
+ password = properties.analytics.password
+ maximumPoolSize = pool.maximumPoolSize.coerceAtLeast(1)
+ minimumIdle = pool.minimumIdle.coerceIn(0, maximumPoolSize)
+ connectionTimeout = pool.connectionTimeoutMillis.coerceAtLeast(250)
+ idleTimeout = pool.idleTimeoutMillis.coerceAtLeast(0)
+ maxLifetime = pool.maxLifetimeMillis.coerceAtLeast(30_000)
+ validationTimeout = pool.validationTimeoutMillis.coerceAtLeast(250)
+ connectionTestQuery = "SELECT 1"
+ }
+ return HikariDataSource(config)
+ }
+
+ @PreDestroy
+ fun closeAnalyticsDataSource() {
+ analyticsDataSource?.close()
+ }
+}
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
new file mode 100644
index 00000000..e7fd46a7
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/AnalyticsService.kt
@@ -0,0 +1,349 @@
+package de.interaapps.pastefy.infrastructure.analytics
+
+import com.maxmind.db.CHMCache
+import com.maxmind.geoip2.DatabaseReader
+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
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+import org.springframework.jdbc.core.JdbcTemplate
+import org.springframework.scheduling.annotation.Scheduled
+import org.springframework.stereotype.Service
+import java.io.File
+import java.math.BigInteger
+import java.net.InetAddress
+import java.net.URI
+import java.nio.charset.StandardCharsets
+import java.security.MessageDigest
+import java.sql.Timestamp
+import java.time.Instant
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
+import java.util.concurrent.ArrayBlockingQueue
+import java.util.concurrent.atomic.AtomicLong
+
+@Service
+@ConditionalOnProperty(prefix = "pastefy.analytics", name = ["enabled"], havingValue = "true")
+class AnalyticsService(
+ private val jdbc: JdbcTemplate,
+ private val properties: PastefyProperties,
+) {
+ 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 droppedEvents = AtomicLong()
+ private val writerFailures = AtomicLong()
+ private val geoIpReader = createGeoIpReader(config.geoIpMmdbPath)
+
+ enum class VisitType { PAGE, RAW }
+
+ fun track(request: HttpServletRequest, paste: Paste, visitor: User?, visitType: VisitType) {
+ val userAgent = UserAgentInfo.parse(request.getHeader("User-Agent"))
+ if (!config.trackBots && userAgent.bot) return
+ val ip = resolveIp(request)
+ val geo = lookupGeo(ip)
+ val refererHost = refererHost(request.getHeader("Referer"))
+ val event = VisitEvent(
+ pasteKey = paste.key,
+ pasteVisibility = paste.visibility?.name ?: PasteVisibility.UNLISTED.name,
+ pasteUserId = paste.userId,
+ visitType = visitType.name,
+ visitedAt = Instant.now(),
+ country = geo.country,
+ region = geo.region,
+ city = geo.city,
+ visitorUserId = visitor?.id,
+ browser = userAgent.browser,
+ deviceType = userAgent.deviceType,
+ os = userAgent.os,
+ ipHash = hashIp(ip),
+ refererHost = refererHost,
+ acquisition = acquisition(refererHost, request.getHeader("Host")),
+ isBot = userAgent.bot,
+ )
+ if (!queue.offer(event)) logDroppedEvent()
+ }
+
+ fun query(query: AnalyticsQuery): AnalyticsResponse {
+ if (!config.trackBots) {
+ query.filters.remove("is_bot")
+ if (query.groupBy == "is_bot") query.groupBy = "country"
+ }
+ require(query.groupBy in AnalyticsQuery.FILTERS) { "Unsupported analytics group field" }
+ val where = buildWhere(query)
+ val response = AnalyticsResponse(botTrackingEnabled = config.trackBots)
+ if (query.includeSummary) {
+ val botVisits = if (config.trackBots) ", countIf(is_bot = 1) AS bot_visits" else ""
+ jdbc.queryForList("SELECT count() AS total_visits, uniqExact(ip_hash) AS unique_visitors$botVisits FROM $table$where")
+ .firstOrNull()?.let {
+ response.totalVisits = (it["total_visits"] as Number).toLong()
+ response.uniqueVisitors = (it["unique_visitors"] as Number).toLong()
+ if (config.trackBots) response.botVisits = (it["bot_visits"] as Number).toLong()
+ }
+ val bucket = when (query.interval) {
+ "hour" -> "toStartOfHour(visited_at)"
+ "week" -> "toStartOfWeek(visited_at)"
+ "month" -> "toStartOfMonth(visited_at)"
+ else -> "toStartOfDay(visited_at)"
+ }
+ 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",
+ ).forEach {
+ 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",
+ ).forEach {
+ response.breakdown += BreakdownPoint(
+ it["value"].toString(),
+ (it["visits"] as Number).toLong(),
+ (it["unique_visitors"] as Number).toLong()
+ )
+ }
+ }
+ 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 {
+ val batch = mutableListOf()
+ queue.drainTo(batch, config.batchSize.coerceAtLeast(1))
+ if (batch.isEmpty()) return
+ 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ batch,
+ batch.size,
+ ) { statement, event ->
+ statement.setString(1, event.pasteKey)
+ statement.setString(2, event.pasteVisibility)
+ statement.setString(3, event.pasteUserId)
+ statement.setString(4, event.visitType)
+ statement.setTimestamp(5, Timestamp.from(event.visitedAt))
+ statement.setString(6, event.country)
+ statement.setString(7, event.region)
+ statement.setString(8, event.city)
+ statement.setString(9, event.visitorUserId)
+ statement.setString(10, event.browser)
+ statement.setString(11, event.deviceType)
+ statement.setString(12, event.os)
+ statement.setObject(13, event.ipHash)
+ statement.setString(14, event.refererHost)
+ statement.setString(15, event.acquisition)
+ statement.setInt(16, if (event.isBot) 1 else 0)
+ }
+ 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
+ )
+ batch.forEach { if (!queue.offer(it)) logDroppedEvent() }
+ return
+ }
+ } while (queue.size >= config.batchSize)
+ }
+
+ @PreDestroy
+ fun close() {
+ flush()
+ geoIpReader?.close()
+ }
+
+ private fun buildWhere(query: AnalyticsQuery): String {
+ val clauses = mutableListOf(
+ "visited_at >= parseDateTime64BestEffort('${DATE_TIME.format(query.from)}')",
+ "visited_at <= parseDateTime64BestEffort('${DATE_TIME.format(query.to)}')",
+ )
+ 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
+ )
+ }'"
+ }
+ return " WHERE ${clauses.joinToString(" AND ")}"
+ }
+
+ private fun resolveIp(request: HttpServletRequest): String {
+ 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")
+
+ else -> request.remoteAddr
+ }?.substringBefore(',')?.trim().orEmpty()
+ if (value.startsWith("[") && value.contains(']')) return value.substring(1, value.indexOf(']'))
+ return if (value.count { it == ':' } == 1 && value.contains('.')) value.substringBefore(':') else value
+ }
+
+ 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)
+ )
+ }
+
+ private fun lookupGeo(ip: String): GeoLocation {
+ val reader = geoIpReader ?: return GeoLocation()
+ 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()
+ )
+ }.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
+ )
+ }
+ .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
+ )
+ }
+
+ private fun refererHost(value: String?): String = runCatching {
+ URI.create(value.orEmpty()).host?.lowercase()?.takeIf { it.length <= 253 }.orEmpty()
+ }.getOrDefault("")
+
+ 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("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"
+ 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(
+ val pasteKey: String, val pasteVisibility: String, val pasteUserId: String?, val visitType: String,
+ val visitedAt: Instant, val country: String, val region: String, val city: String, val visitorUserId: String?,
+ 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 device = when {
+ ua.containsAny("ipad", "tablet") -> "TABLET"
+ ua.containsAny("mobile", "iphone", "android") -> "MOBILE"
+ bot -> "BOT"
+ else -> "DESKTOP"
+ }
+ val browser = when {
+ "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"
+ ua.containsAny("mac os", "macintosh") -> "MACOS"; "linux" in ua -> "LINUX"; else -> "UNKNOWN"
+ }
+ return UserAgentInfo(browser, device, os, bot)
+ }
+ }
+ }
+
+ companion object {
+ private val LOGGER = LoggerFactory.getLogger(AnalyticsService::class.java)
+ private val DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS").withZone(ZoneOffset.UTC)
+ }
+}
+
+private fun String.containsAny(vararg needles: String) = needles.any(::contains)
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
new file mode 100644
index 00000000..5df42341
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/analytics/ClickHouseFlywayConfiguration.kt
@@ -0,0 +1,46 @@
+package de.interaapps.pastefy.infrastructure.analytics
+
+import de.interaapps.pastefy.config.PastefyProperties
+import org.flywaydb.core.Flyway
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.jdbc.datasource.DriverManagerDataSource
+
+@Configuration
+@ConditionalOnProperty(prefix = "pastefy.analytics.migrations", name = ["enabled"], havingValue = "true")
+class ClickHouseFlywayConfiguration {
+ @Bean
+ fun clickHouseFlyway(properties: PastefyProperties): Flyway {
+ val flyway = Flyway.configure()
+ .dataSource(
+ DriverManagerDataSource().apply {
+ require(properties.analytics.jdbcUrl.isNotBlank()) {
+ "pastefy.analytics.jdbc-url is required when ClickHouse migrations are enabled"
+ }
+ setDriverClassName("com.clickhouse.jdbc.ClickHouseDriver")
+ url = clickHouseJdbcUrl(properties.analytics.jdbcUrl, properties.analytics.database)
+ username = properties.analytics.user
+ password = properties.analytics.password
+ },
+ )
+ .locations("classpath:db/migration/clickhouse")
+ .placeholders(
+ mapOf(
+ "analyticsDatabase" to identifier(properties.analytics.database),
+ "analyticsTable" to identifier(properties.analytics.table),
+ "retentionDays" to properties.analytics.retentionDays.coerceAtLeast(1).toString(),
+ ),
+ )
+ .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_]*")) }
+ ?: throw IllegalArgumentException("Invalid ClickHouse identifier: $value")
+}
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 00000000..86841de0
--- /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/ElasticPasteDocument.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteDocument.kt
new file mode 100644
index 00000000..cf84b7cb
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteDocument.kt
@@ -0,0 +1,65 @@
+package de.interaapps.pastefy.infrastructure.elastic
+
+import de.interaapps.pastefy.enums.PasteType
+import de.interaapps.pastefy.enums.PasteVisibility
+import de.interaapps.pastefy.enums.StorageType
+import de.interaapps.pastefy.entities.User
+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
+
+@Document(indexName = "pastefy_pastes", createIndex = false, writeTypeHint = WriteTypeHint.FALSE)
+data class ElasticPasteDocument(
+ @Id
+ val documentId: String = "",
+ @Field(name = "id", type = FieldType.Integer)
+ val pasteId: Int = 0,
+ val key: String = "",
+ val title: String? = null,
+ val content: String? = null,
+ val userId: String? = null,
+ val forkedFrom: String? = null,
+ val encrypted: Boolean = false,
+ 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,
+ val tags: List = emptyList(),
+ val starCount: Int = 0,
+ val starredBy: List = emptyList(),
+ val user: ElasticUserDocument? = null,
+)
+
+data class ElasticUserDocument(
+ val id: String,
+ val name: String? = null,
+ val uniqueName: String? = null,
+ @Field(name = "eMail")
+ val eMail: String? = null,
+ val avatar: String? = null,
+ val authId: String? = null,
+ val authProvider: User.AuthenticationProvider? = null,
+ val type: User.Type = User.Type.USER,
+
+ @Field(type = FieldType.Date, format = [DateFormat.epoch_millis])
+ val createdAt: Instant? = null,
+
+ @Field(type = FieldType.Date, format = [DateFormat.epoch_millis])
+ val updatedAt: Instant? = null,
+)
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 00000000..1707bf04
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteQueryAdapter.kt
@@ -0,0 +1,202 @@
+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.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
+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
+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 pasteMetricsService: PasteMetricsService,
+) {
+ 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()
+
+ val documents = operations.search(nativeQuery, ElasticPasteDocument::class.java, indexCoordinates)
+ .searchHits
+ .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 ->
+ 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, metrics: PasteMetrics?): 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,
+ tags = document.tags,
+ type = document.type,
+ 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
+
+ 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",
+ "user.email" to "user.eMail",
+ "user_email" to "user.eMail",
+ "user.unique_name" to "user.uniqueName",
+ "user.auth_id" to "user.authId",
+ "user.auth_provider" to "user.authProvider",
+ )
+ }
+}
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
new file mode 100644
index 00000000..139f1dea
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticPasteService.kt
@@ -0,0 +1,126 @@
+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
+import de.interaapps.pastefy.repositories.UserRepository
+import org.slf4j.LoggerFactory
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+import org.springframework.context.annotation.DependsOn
+import org.springframework.data.elasticsearch.core.ElasticsearchOperations
+import org.springframework.data.elasticsearch.core.mapping.IndexCoordinates
+import org.springframework.data.elasticsearch.core.query.Query
+import org.springframework.stereotype.Service
+
+@Service
+@DependsOn("elasticMigrationRunner")
+@ConditionalOnProperty(prefix = "pastefy.elasticsearch", name = ["enabled"], havingValue = "true")
+class ElasticPasteService(
+ private val operations: ElasticsearchOperations,
+ private val properties: PastefyProperties,
+ private val pasteTagRepository: PasteTagRepository,
+ private val pasteStarRepository: PasteStarRepository,
+ private val engagementRepository: PublicPasteEngagementRepository,
+ private val userRepository: UserRepository,
+) {
+ private val logger = LoggerFactory.getLogger(ElasticPasteService::class.java)
+ private val indexCoordinates: IndexCoordinates
+ get() = IndexCoordinates.of(properties.elasticsearch.indexName)
+
+ fun store(paste: Paste, content: String?): Boolean = runCatching {
+ operations.save(toDocument(paste, content), indexCoordinates)
+ true
+ }.onFailure {
+ logger.error("Unable to index paste {}", paste.id, it)
+ }.getOrDefault(false)
+
+ fun delete(paste: Paste) {
+ runCatching {
+ operations.delete(requireNotNull(paste.id).toString(), indexCoordinates)
+ }.onFailure {
+ logger.error("Unable to delete paste {} from Elasticsearch", paste.id, it)
+ }
+ }
+
+ fun count(): Long = operations.count(Query.findAll(), indexCoordinates)
+
+ fun updateTags(paste: Paste) = updateDocument(paste) { document ->
+ document.copy(tags = tags(paste))
+ }
+
+ fun updateEngagement(paste: Paste) = updateDocument(paste) { document ->
+ document.copy(engagementScore = engagement(paste))
+ }
+
+ fun updateStars(paste: Paste) = updateDocument(paste) { document ->
+ val starredBy = starredBy(paste)
+ document.copy(starCount = starredBy.size, starredBy = starredBy)
+ }
+
+ fun updateUser(paste: Paste) = updateDocument(paste) { document ->
+ document.copy(user = user(paste))
+ }
+
+ private fun updateDocument(paste: Paste, update: (ElasticPasteDocument) -> ElasticPasteDocument) {
+ runCatching {
+ val id = requireNotNull(paste.id).toString()
+ val document = operations.get(id, ElasticPasteDocument::class.java, indexCoordinates) ?: return
+ operations.save(update(document), indexCoordinates)
+ }.onFailure {
+ logger.error("Unable to update Elasticsearch document for paste {}", paste.id, it)
+ }
+ }
+
+ private fun toDocument(paste: Paste, content: String?) = ElasticPasteDocument(
+ documentId = requireNotNull(paste.id).toString(),
+ pasteId = requireNotNull(paste.id),
+ key = paste.key,
+ title = paste.title,
+ content = content,
+ userId = paste.userId,
+ forkedFrom = paste.forkedFrom,
+ encrypted = paste.encrypted,
+ folder = paste.folder,
+ type = paste.type ?: PasteType.PASTE,
+ visibility = paste.visibility ?: PasteVisibility.UNLISTED,
+ expireAt = paste.expireAt,
+ createdAt = paste.createdAt,
+ updatedAt = paste.updatedAt,
+ storageType = paste.storageType ?: StorageType.DATABASE,
+ version = paste.version ?: 1,
+ engagementScore = engagement(paste),
+ tags = tags(paste),
+ starCount = starredBy(paste).size,
+ starredBy = starredBy(paste),
+ user = user(paste),
+ )
+
+ private fun tags(paste: Paste): List = pasteTagRepository.findAllByPaste(paste.key).map { it.tag }
+
+ private fun starredBy(paste: Paste): List = pasteStarRepository.findAllByPaste(paste.key).map { it.userId }
+
+ private fun engagement(paste: Paste): Int =
+ paste.id?.let(engagementRepository::findFirstByPasteIdOrderByIdAsc)?.score ?: 0
+
+ private fun user(paste: Paste): ElasticUserDocument? =
+ paste.userId?.let(userRepository::findById)?.orElse(null)?.toDocument()
+
+ private fun User.toDocument() = ElasticUserDocument(
+ id = id,
+ name = name,
+ uniqueName = uniqueName,
+ eMail = email,
+ avatar = avatar,
+ authId = authId,
+ authProvider = authProvider,
+ type = type,
+ createdAt = createdAt,
+ updatedAt = updatedAt,
+ )
+}
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 00000000..072dc2f3
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/ElasticsearchConfiguration.kt
@@ -0,0 +1,42 @@
+package de.interaapps.pastefy.infrastructure.elastic
+
+import de.interaapps.pastefy.config.PastefyProperties
+import org.apache.http.auth.AuthScope
+import org.apache.http.auth.UsernamePasswordCredentials
+import org.apache.http.impl.client.BasicCredentialsProvider
+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
+import org.springframework.core.env.Environment
+
+@Configuration
+@ConditionalOnProperty(prefix = "pastefy.elasticsearch", name = ["enabled"], havingValue = "true")
+class ElasticsearchConfiguration {
+ @Bean
+ fun elasticsearchApiKeyCustomizer(
+ properties: PastefyProperties,
+ environment: Environment,
+ ): RestClientBuilderCustomizer =
+ RestClientBuilderCustomizer { builder ->
+ val elastic = properties.elasticsearch
+ val pool = elastic.pool
+ if (elastic.apiKey.isNotBlank()) {
+ builder.setDefaultHeaders(arrayOf(BasicHeader("Authorization", "ApiKey ${elastic.apiKey}")))
+ }
+ val username = environment.getProperty("spring.elasticsearch.username", "")
+ val password = environment.getProperty("spring.elasticsearch.password", "")
+ builder.setHttpClientConfigCallback { httpClientBuilder ->
+ if (elastic.apiKey.isBlank() && username.isNotBlank()) {
+ val credentialsProvider = BasicCredentialsProvider().apply {
+ setCredentials(AuthScope.ANY, UsernamePasswordCredentials(username, password))
+ }
+ httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider)
+ }
+ httpClientBuilder
+ .setMaxConnTotal(pool.maxConnections.coerceAtLeast(1))
+ .setMaxConnPerRoute(pool.maxConnectionsPerRoute.coerceAtLeast(1))
+ }
+ }
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/migrations/ElasticMigration.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/migrations/ElasticMigration.kt
new file mode 100644
index 00000000..4801d5f6
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/migrations/ElasticMigration.kt
@@ -0,0 +1,24 @@
+package de.interaapps.pastefy.infrastructure.elastic.migrations
+
+import co.elastic.clients.elasticsearch.ElasticsearchClient
+import de.interaapps.pastefy.config.PastefyProperties
+
+interface ElasticMigration {
+ val version: Int
+ fun migrate(context: ElasticMigrationContext)
+}
+
+data class ElasticMigrationContext(
+ val client: ElasticsearchClient,
+ val properties: PastefyProperties.Elasticsearch,
+) {
+ val alias: String = properties.indexName.requireElasticName("index alias")
+ val indexPrefix: String = properties.indexPrefix.requireElasticName("index prefix")
+ val legacyIndex: String = properties.legacyIndexName.requireElasticName("legacy index")
+
+ fun versionedIndex(version: Int): String = "$indexPrefix-v$version"
+}
+
+internal fun String.requireElasticName(description: String): String =
+ takeIf { matches(Regex("[a-z0-9][a-z0-9._-]*")) }
+ ?: throw IllegalArgumentException("Invalid Elasticsearch $description: $this")
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/migrations/ElasticMigrationRunner.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/migrations/ElasticMigrationRunner.kt
new file mode 100644
index 00000000..eabb3183
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/migrations/ElasticMigrationRunner.kt
@@ -0,0 +1,35 @@
+package de.interaapps.pastefy.infrastructure.elastic.migrations
+
+import co.elastic.clients.elasticsearch.ElasticsearchClient
+import de.interaapps.pastefy.config.PastefyProperties
+import jakarta.annotation.PostConstruct
+import org.slf4j.LoggerFactory
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+import org.springframework.stereotype.Component
+
+@Component("elasticMigrationRunner")
+@ConditionalOnProperty(prefix = "pastefy.elasticsearch", name = ["enabled"], havingValue = "true")
+class ElasticMigrationRunner(
+ private val client: ElasticsearchClient,
+ private val properties: PastefyProperties,
+ private val migrations: List,
+) {
+ @PostConstruct
+ fun migrate() {
+ if (!properties.elasticsearch.migrations.enabled) {
+ LOGGER.info("Elasticsearch migrations are disabled")
+ return
+ }
+
+ val context = ElasticMigrationContext(client, properties.elasticsearch)
+ migrations.sortedBy(ElasticMigration::version).forEach { migration ->
+ LOGGER.info("Applying Elasticsearch migration V{}", migration.version)
+ migration.migrate(context)
+ }
+ LOGGER.info("Elasticsearch migrations are up to date")
+ }
+
+ companion object {
+ private val LOGGER = LoggerFactory.getLogger(ElasticMigrationRunner::class.java)
+ }
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/migrations/ElasticV1PasteIndexMigration.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/migrations/ElasticV1PasteIndexMigration.kt
new file mode 100644
index 00000000..31e71915
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/elastic/migrations/ElasticV1PasteIndexMigration.kt
@@ -0,0 +1,94 @@
+package de.interaapps.pastefy.infrastructure.elastic.migrations
+
+import co.elastic.clients.elasticsearch._types.mapping.TypeMapping
+import org.slf4j.LoggerFactory
+import org.springframework.stereotype.Component
+import java.io.StringReader
+
+@Component
+class ElasticV1PasteIndexMigration : ElasticMigration {
+ override val version = 1
+
+ override fun migrate(context: ElasticMigrationContext) {
+ val index = context.properties.indexName.requireElasticName("index")
+ if (indexExists(context, index)) {
+ context.client.indices().putMapping { request ->
+ request.index(index).withJson(StringReader(LEGACY_MAPPING_JSON))
+ }
+ LOGGER.info("Updated Elasticsearch legacy mapping for index {}", index)
+ return
+ }
+
+ createIndex(context, index)
+ }
+
+ private fun createIndex(context: ElasticMigrationContext, index: String) {
+ try {
+ context.client.indices().create { request ->
+ request.index(index)
+ .settings { settings ->
+ settings
+ .numberOfShards(context.properties.numberOfShards.coerceAtLeast(1).toString())
+ .numberOfReplicas(context.properties.numberOfReplicas.coerceAtLeast(0).toString())
+ }
+ .mappings(MAPPING)
+ }
+ LOGGER.info("Created Elasticsearch index {}", index)
+ } catch (exception: RuntimeException) {
+ if (!indexExists(context, index)) throw exception
+ LOGGER.info("Elasticsearch index {} was created concurrently", index)
+ }
+ }
+
+ private fun indexExists(context: ElasticMigrationContext, index: String): Boolean =
+ context.client.indices().exists { it.index(index) }.value()
+
+ companion object {
+ private val LOGGER = LoggerFactory.getLogger(ElasticV1PasteIndexMigration::class.java)
+ private val LEGACY_MAPPING_JSON =
+ """
+ {
+ "properties": {
+ "key": { "type": "keyword" },
+ "title": { "type": "text", "fields": { "keyword": { "type": "keyword" } } },
+ "content": { "type": "text" },
+ "version": { "type": "integer" },
+ "starCount": { "type": "integer" },
+ "engagementScore": { "type": "integer" },
+ "userId": { "type": "keyword" },
+ "forkedFrom": { "type": "keyword" },
+ "visibility": { "type": "keyword" },
+ "folder": { "type": "keyword" },
+ "type": { "type": "keyword" },
+ "storageType": { "type": "keyword" },
+ "tags": { "type": "keyword" },
+ "starredBy": { "type": "keyword" },
+ "encrypted": { "type": "boolean" },
+ "expireAt": { "type": "date" },
+ "createdAt": { "type": "date" },
+ "updatedAt": { "type": "date" },
+ "user": {
+ "properties": {
+ "id": { "type": "keyword" },
+ "type": { "type": "keyword" },
+ "authId": { "type": "keyword" },
+ "authProvider": { "type": "keyword" },
+ "avatar": { "type": "text" },
+ "name": { "type": "text", "fields": { "keyword": { "type": "keyword" } } },
+ "uniqueName": { "type": "text", "fields": { "keyword": { "type": "keyword" } } },
+ "eMail": { "type": "text", "fields": { "keyword": { "type": "keyword" } } },
+ "createdAt": { "type": "date" },
+ "updatedAt": { "type": "date" }
+ }
+ }
+ }
+ }
+ """.trimIndent()
+
+ private val MAPPING = TypeMapping.of { builder ->
+ builder.withJson(
+ StringReader(LEGACY_MAPPING_JSON),
+ )
+ }
+ }
+}
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
new file mode 100644
index 00000000..4318fd81
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/jobs/BackgroundJobConfiguration.kt
@@ -0,0 +1,23 @@
+package de.interaapps.pastefy.infrastructure.jobs
+
+import de.interaapps.pastefy.config.PastefyProperties
+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
+@Conditional(AiEnabledCondition::class)
+class BackgroundJobConfiguration {
+ @Bean
+ fun backgroundJobExecutor(properties: PastefyProperties): ThreadPoolTaskExecutor =
+ ThreadPoolTaskExecutor().apply {
+ corePoolSize = properties.ai.jobs.workers.coerceAtLeast(1)
+ maxPoolSize = corePoolSize
+ queueCapacity = 0
+ setThreadNamePrefix("pastefy-job-")
+ setWaitForTasksToCompleteOnShutdown(true)
+ initialize()
+ }
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/jobs/BackgroundJobHandler.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/jobs/BackgroundJobHandler.kt
new file mode 100644
index 00000000..92fb8f04
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/jobs/BackgroundJobHandler.kt
@@ -0,0 +1,8 @@
+package de.interaapps.pastefy.infrastructure.jobs
+
+import de.interaapps.pastefy.entities.BackgroundJob
+
+interface BackgroundJobHandler {
+ val type: BackgroundJob.Type
+ fun handle(job: BackgroundJob)
+}
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
new file mode 100644
index 00000000..4ffbe542
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/jobs/BackgroundJobService.kt
@@ -0,0 +1,132 @@
+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.context.annotation.Conditional
+import org.springframework.dao.DataIntegrityViolationException
+import org.springframework.scheduling.annotation.Scheduled
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
+import org.springframework.stereotype.Service
+import org.springframework.transaction.PlatformTransactionManager
+import org.springframework.transaction.support.TransactionTemplate
+import java.time.Instant
+import java.util.UUID
+import java.util.concurrent.atomic.AtomicInteger
+
+@Service
+@Conditional(AiEnabledCondition::class)
+class BackgroundJobService(
+ private val repository: BackgroundJobRepository,
+ private val handlers: ObjectProvider,
+ private val executor: ThreadPoolTaskExecutor,
+ transactionManager: PlatformTransactionManager,
+ private val properties: PastefyProperties,
+) {
+ private val transaction = TransactionTemplate(transactionManager)
+ private val activeWorkers = AtomicInteger()
+
+ 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)
+ if (existing != null) {
+ if (existing.status == BackgroundJob.Status.DONE || existing.status == BackgroundJob.Status.FAILED) {
+ existing.status = BackgroundJob.Status.PENDING
+ existing.attempts = 0
+ existing.availableAt = Instant.now()
+ existing.leaseUntil = null
+ existing.leaseToken = null
+ existing.lastError = null
+ }
+ return@executeWithoutResult
+ }
+ try {
+ repository.saveAndFlush(
+ BackgroundJob(
+ key = key,
+ type = type,
+ entityId = entityId,
+ sourceVersion = sourceVersion ?: 1,
+ promptVersion = promptVersion,
+ availableAt = Instant.now(),
+ ),
+ )
+ } catch (_: DataIntegrityViolationException) {
+ // A second instance may have inserted the deterministic key concurrently.
+ }
+ }
+ }
+
+ @Scheduled(fixedDelayString = "\${pastefy.ai.jobs.poll-interval-millis:5000}")
+ fun poll() {
+ repeat((properties.ai.jobs.workers.coerceAtLeast(1) - activeWorkers.get()).coerceAtLeast(0)) {
+ val job = claimNext() ?: return
+ activeWorkers.incrementAndGet()
+ executor.execute {
+ try {
+ execute(job)
+ } finally {
+ activeWorkers.decrementAndGet()
+ }
+ }
+ }
+ }
+
+ private fun claimNext(): BackgroundJob? = transaction.execute {
+ repository.findNextForUpdate(Instant.now())?.apply {
+ status = BackgroundJob.Status.RUNNING
+ leaseToken = UUID.randomUUID().toString()
+ leaseUntil = Instant.now().plusSeconds(properties.ai.jobs.leaseSeconds.coerceAtLeast(30))
+ }
+ }
+
+ private fun execute(job: BackgroundJob) {
+ try {
+ val handler = handlers.orderedStream().filter { it.type == job.type }.findFirst()
+ .orElseThrow { IllegalStateException("No handler registered for job type ${job.type}") }
+ handler.handle(job)
+ updateLeased(job) {
+ status = BackgroundJob.Status.DONE
+ leaseUntil = null
+ leaseToken = null
+ lastError = null
+ }
+ } catch (exception: Exception) {
+ fail(job, exception)
+ }
+ }
+
+ private fun fail(job: BackgroundJob, exception: Exception) {
+ updateLeased(job) {
+ attempts += 1
+ leaseUntil = null
+ leaseToken = null
+ lastError = (exception.message ?: exception.javaClass.simpleName).take(2_000)
+ if (attempts >= properties.ai.jobs.maxAttempts.coerceAtLeast(1)) {
+ status = BackgroundJob.Status.FAILED
+ } 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))
+ }
+ }
+ LOGGER.warn("Background job failed: {}", job.key, exception)
+ }
+
+ private fun updateLeased(job: BackgroundJob, update: BackgroundJob.() -> Unit) {
+ transaction.executeWithoutResult {
+ repository.findById(job.key).orElse(null)
+ ?.takeIf { it.leaseToken == job.leaseToken }
+ ?.apply(update)
+ }
+ }
+
+ companion object {
+ private val LOGGER = LoggerFactory.getLogger(BackgroundJobService::class.java)
+ }
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/redis/PasteRedisCacheService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/redis/PasteRedisCacheService.kt
new file mode 100644
index 00000000..3b7b81fb
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/redis/PasteRedisCacheService.kt
@@ -0,0 +1,61 @@
+package de.interaapps.pastefy.infrastructure.redis
+
+import de.interaapps.pastefy.config.PastefyProperties
+import de.interaapps.pastefy.entities.Paste
+import org.slf4j.LoggerFactory
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+import org.springframework.data.redis.core.StringRedisTemplate
+import org.springframework.stereotype.Service
+import java.time.Duration
+
+@Service
+@ConditionalOnProperty(prefix = "pastefy.redis", name = ["enabled"], havingValue = "true")
+class PasteRedisCacheService(
+ private val redis: StringRedisTemplate,
+ private val properties: PastefyProperties,
+) {
+ private val logger = LoggerFactory.getLogger(PasteRedisCacheService::class.java)
+
+ fun getContent(paste: Paste): String? = runCatching {
+ redis.opsForValue().get(contentKey(paste))
+ }.onFailure {
+ logger.warn("Unable to read Redis content cache for paste {}", paste.id, it)
+ }.getOrNull()
+
+ fun putContent(paste: Paste, content: String) {
+ runCatching {
+ redis.opsForValue().set(
+ contentKey(paste),
+ content,
+ Duration.ofSeconds(properties.redis.contentTtlSeconds),
+ )
+ }.onFailure {
+ logger.warn("Unable to populate Redis content cache for paste {}", paste.id, it)
+ }
+ }
+
+ fun evictContent(paste: Paste) {
+ runCatching {
+ redis.delete(contentKey(paste))
+ }.onFailure {
+ logger.warn("Unable to evict Redis content cache for paste {}", paste.id, it)
+ }
+ }
+
+ fun incrementAccessCount(paste: Paste): Long = runCatching {
+ val key = accessCountKey(paste)
+ val count = redis.opsForValue().increment(key) ?: 0
+ if (count == 1L) {
+ redis.expire(key, Duration.ofSeconds(properties.redis.accessCountTtlSeconds))
+ }
+ count
+ }.onFailure {
+ logger.warn("Unable to update Redis access count for paste {}", paste.id, it)
+ }.getOrDefault(0)
+
+ fun shouldCache(accessCount: Long): Boolean = accessCount > properties.redis.cacheAfterAccesses
+
+ private fun contentKey(paste: Paste) = "paste:${requireNotNull(paste.id)}"
+
+ private fun accessCountKey(paste: Paste) = "${contentKey(paste)}:accessCount"
+}
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
new file mode 100644
index 00000000..996f8405
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/s3/S3Configuration.kt
@@ -0,0 +1,67 @@
+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 okhttp3.ConnectionPool
+import okhttp3.OkHttpClient
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import java.util.concurrent.TimeUnit
+
+@Configuration
+@ConditionalOnProperty(prefix = "pastefy.s3", name = ["enabled"], havingValue = "true")
+class S3Configuration {
+ @Bean
+ fun minioClient(properties: PastefyProperties): MinioClient {
+ val s3 = properties.s3
+ require(s3.endpoint.isNotBlank()) { "pastefy.s3.endpoint must be configured when S3 is enabled" }
+ require(s3.accessKey.isNotBlank()) { "pastefy.s3.access-key must be configured when S3 is enabled" }
+ require(s3.secretKey.isNotBlank()) { "pastefy.s3.secret-key must be configured when S3 is enabled" }
+ require(s3.bucket.isNotBlank()) { "pastefy.s3.bucket must be configured when S3 is enabled" }
+ val pool = s3.pool
+ val httpClient = OkHttpClient.Builder()
+ .connectionPool(
+ ConnectionPool(
+ pool.maxIdleConnections.coerceAtLeast(1),
+ pool.keepAliveMinutes.coerceAtLeast(1),
+ TimeUnit.MINUTES,
+ ),
+ )
+ .connectTimeout(pool.connectTimeoutSeconds.coerceAtLeast(1), TimeUnit.SECONDS)
+ .readTimeout(pool.readTimeoutSeconds.coerceAtLeast(1), TimeUnit.SECONDS)
+ .writeTimeout(pool.writeTimeoutSeconds.coerceAtLeast(1), TimeUnit.SECONDS)
+ .retryOnConnectionFailure(pool.retryOnConnectionFailure)
+ .build()
+ return MinioClient.builder()
+ .endpoint(s3.endpoint)
+ .credentials(s3.accessKey, s3.secretKey)
+ .httpClient(httpClient)
+ .apply {
+ 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
new file mode 100644
index 00000000..6ba99e94
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/s3/S3PasteService.kt
@@ -0,0 +1,101 @@
+package de.interaapps.pastefy.infrastructure.s3
+
+import com.fasterxml.jackson.annotation.JsonAlias
+import com.fasterxml.jackson.databind.ObjectMapper
+import de.interaapps.pastefy.config.PastefyProperties
+import de.interaapps.pastefy.entities.Paste
+import de.interaapps.pastefy.enums.StorageType
+import io.minio.GetObjectArgs
+import io.minio.MinioClient
+import io.minio.PutObjectArgs
+import io.minio.RemoveObjectArgs
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+import org.springframework.stereotype.Service
+import java.io.ByteArrayInputStream
+import java.nio.charset.StandardCharsets
+
+@Service
+@ConditionalOnProperty(prefix = "pastefy.s3", name = ["enabled"], havingValue = "true")
+class S3PasteService(
+ private val client: MinioClient,
+ private val objectMapper: ObjectMapper,
+ private val properties: PastefyProperties,
+) {
+ fun shouldStore(paste: Paste): Boolean {
+ val threshold = properties.s3.pasteSizeThreshold
+ return paste.storageType == StorageType.S3 || threshold >= 0 && (paste.content?.length ?: 0) > threshold
+ }
+
+ fun store(paste: Paste, content: String): S3PasteReference {
+ val bytes = content.toByteArray(StandardCharsets.UTF_8)
+ val response = client.putObject(
+ PutObjectArgs.builder()
+ .bucket(properties.s3.bucket)
+ .`object`(objectName(paste))
+ .contentType("text/plain; charset=utf-8")
+ .tags(mapOf("userId" to (paste.userId ?: "anonymous"), "paste" to paste.key))
+ .stream(ByteArrayInputStream(bytes), bytes.size.toLong(), -1)
+ .build()
+ )
+ return S3PasteReference(
+ server = properties.s3.endpoint,
+ bucket = response.bucket(),
+ objectName = response.`object`(),
+ region = response.region() ?: properties.s3.region,
+ etag = response.etag(),
+ )
+ }
+
+ fun getContent(paste: Paste): String {
+ val reference = reference(paste)
+ return client.getObject(
+ GetObjectArgs.builder()
+ .bucket(reference.bucket)
+ .region(reference.region)
+ .`object`(reference.objectName)
+ .build()
+ ).use { it.readBytes().toString(StandardCharsets.UTF_8) }
+ }
+
+ fun delete(paste: Paste) {
+ val reference = reference(paste)
+
+ client.removeObject(
+ RemoveObjectArgs.builder()
+ .bucket(reference.bucket)
+ .region(reference.region)
+ .`object`(reference.objectName)
+ .build()
+ )
+ }
+
+ 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"
+ }
+
+ 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()
+
+ return "pastes/${paste.userId ?: "anonymous"}/$prefix${paste.key}/contents.txt"
+ }
+}
+
+data class S3PasteReference(
+ @JsonAlias("objectName")
+ val objectName: String,
+ val bucket: String,
+ val server: String,
+ val region: String? = null,
+ val etag: String? = null,
+)
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 00000000..f8d1233f
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/infrastructure/seeding/LocalTestingSeeder.kt
@@ -0,0 +1,570 @@
+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 de.interaapps.pastefy.service.PasteService
+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 pastesService: PasteService,
+ 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://accounts.interaapps.de/avatars/A.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://accounts.interaapps.de/avatars/U.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",
+ avatar = "https://accounts.interaapps.de/avatars/B.png",
+ )
+ 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",
+ avatar = "https://accounts.interaapps.de/avatars/W.png",
+ )
+ }
+
+ 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://accounts.interaapps.de/avatars/${number.toString().take(1).uppercase()}.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.kt",
+ 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.json",
+ 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 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) {
+ 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.findFirstByPasteIdOrderByIdAsc(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/repositories/AuthKeyRepository.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/AuthKeyRepository.kt
new file mode 100644
index 00000000..96802f69
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/AuthKeyRepository.kt
@@ -0,0 +1,11 @@
+package de.interaapps.pastefy.repositories
+
+import de.interaapps.pastefy.entities.AuthKey
+import org.springframework.data.jpa.repository.JpaRepository
+
+interface AuthKeyRepository : JpaRepository {
+ fun findByKey(key: String): AuthKey?
+ fun findAllByUserIdAndType(userId: String, type: AuthKey.Type): List
+ fun deleteByKeyAndUserId(key: String, userId: String)
+ fun deleteByUserId(userId: String)
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/BackgroundJobRepository.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/BackgroundJobRepository.kt
new file mode 100644
index 00000000..f6d5dedf
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/BackgroundJobRepository.kt
@@ -0,0 +1,24 @@
+package de.interaapps.pastefy.repositories
+
+import de.interaapps.pastefy.entities.BackgroundJob
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.data.jpa.repository.Query
+import org.springframework.data.repository.query.Param
+import java.time.Instant
+
+interface BackgroundJobRepository : JpaRepository {
+ fun deleteByTypeAndEntityId(type: BackgroundJob.Type, entityId: Int)
+
+ @Query(
+ value = """
+ select * from pastefy_background_jobs
+ where (status = 'PENDING' and available_at <= :now)
+ or (status = 'RUNNING' and lease_until is not null and lease_until <= :now)
+ order by available_at asc
+ limit 1
+ for update skip locked
+ """,
+ nativeQuery = true,
+ )
+ fun findNextForUpdate(@Param("now") now: Instant): BackgroundJob?
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/FolderRepository.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/FolderRepository.kt
new file mode 100644
index 00000000..870baa04
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/FolderRepository.kt
@@ -0,0 +1,14 @@
+package de.interaapps.pastefy.repositories
+
+import de.interaapps.pastefy.entities.Folder
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor
+
+interface FolderRepository : JpaRepository, JpaSpecificationExecutor {
+ fun findByKey(key: String): Folder?
+ fun findAllByUserId(userId: String): List
+ fun findAllByUserIdAndParentIsNull(userId: String): List
+ fun findAllByParent(parent: String): List
+ fun findAllByParentOrderByUpdatedAtDesc(parent: String): List
+ fun deleteByUserId(userId: String)
+}
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
new file mode 100644
index 00000000..b8693cda
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/NotificationRepository.kt
@@ -0,0 +1,17 @@
+package de.interaapps.pastefy.repositories
+
+import de.interaapps.pastefy.entities.Notification
+import org.springframework.data.jpa.repository.JpaRepository
+
+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 deleteByUserId(userId: String)
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteAIInfoRepository.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteAIInfoRepository.kt
new file mode 100644
index 00000000..e9dff322
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteAIInfoRepository.kt
@@ -0,0 +1,6 @@
+package de.interaapps.pastefy.repositories
+
+import de.interaapps.pastefy.entities.PasteAIInfo
+import org.springframework.data.jpa.repository.JpaRepository
+
+interface PasteAIInfoRepository : JpaRepository
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
new file mode 100644
index 00000000..5e361a68
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteCommentRepository.kt
@@ -0,0 +1,27 @@
+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
+ fun findAllByPasteAndParentIdIsNullOrderByCreatedAtDesc(paste: String, pageable: Pageable): List
+ fun findAllByPasteAndParentIdIsNullAndLineFromOrderByCreatedAtDesc(
+ paste: String,
+ lineFrom: Int,
+ pageable: Pageable
+ ): List
+
+ 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/PasteRepository.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteRepository.kt
new file mode 100644
index 00000000..bb5f4427
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteRepository.kt
@@ -0,0 +1,59 @@
+package de.interaapps.pastefy.repositories
+
+import de.interaapps.pastefy.entities.Paste
+import de.interaapps.pastefy.enums.StorageType
+import de.interaapps.pastefy.enums.PasteVisibility
+import org.springframework.data.domain.Pageable
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor
+import org.springframework.data.jpa.repository.Modifying
+import org.springframework.data.jpa.repository.Query
+import org.springframework.data.repository.query.Param
+
+interface PasteRepository : JpaRepository, JpaSpecificationExecutor {
+ fun findByKey(key: String): Paste?
+
+ fun existsByKey(key: String): Boolean
+
+ fun findAllByUserId(userId: String): List
+ fun findAllByFolderOrderByUpdatedAtDesc(folder: String): List
+ fun findAllByUserIdAndFolderIsNullOrderByUpdatedAtDesc(userId: String, pageable: Pageable): List
+ fun findAllByVisibilityAndEncryptedFalseOrderByCreatedAtDesc(
+ visibility: PasteVisibility,
+ pageable: Pageable,
+ ): List
+
+ fun findAllByUserIdAndVisibilityAndEncryptedFalseOrderByCreatedAtDesc(
+ userId: String,
+ visibility: PasteVisibility,
+ pageable: Pageable,
+ ): List
+
+ fun findAllByKeyIn(keys: Collection): List
+ fun findAllByExpireAtBeforeAndExpireAtIsNotNull(expireAt: java.time.Instant): List
+ fun countByUserIdIsNotNull(): Long
+ fun countByStorageType(storageType: StorageType): Long
+
+ fun deleteByUserId(userId: String)
+
+ @Query(
+ """
+ select p from Paste p
+ left join PublicPasteEngagement e on e.pasteId = p.id
+ and e.id = (
+ select min(e2.id)
+ from PublicPasteEngagement e2
+ where e2.pasteId = p.id
+ )
+ where p.visibility = de.interaapps.pastefy.enums.PasteVisibility.PUBLIC
+ and p.encrypted = false
+ and (:createdAfter is null or p.createdAt > :createdAfter)
+ order by coalesce(e.score, 0) desc, p.createdAt desc
+ """,
+ )
+ fun findTrending(@Param("createdAfter") createdAfter: java.time.Instant?, pageable: Pageable): List
+
+ @Modifying(clearAutomatically = true, flushAutomatically = true)
+ @Query("update Paste p set p.indexedInElastic = :indexed where p.id = :id")
+ fun updateIndexedInElastic(@Param("id") id: Int, @Param("indexed") indexed: Boolean)
+}
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 00000000..9d94e99b
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteStarRepository.kt
@@ -0,0 +1,28 @@
+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 {
+
+ fun existsByPasteAndUserId(paste: String, userId: String): Boolean
+
+ fun deleteByPasteAndUserId(paste: String, userId: String)
+
+ 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/repositories/PasteTagRepository.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteTagRepository.kt
new file mode 100644
index 00000000..8e9d00a5
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PasteTagRepository.kt
@@ -0,0 +1,11 @@
+package de.interaapps.pastefy.repositories
+
+import de.interaapps.pastefy.entities.PasteTag
+import org.springframework.data.jpa.repository.JpaRepository
+
+interface PasteTagRepository : JpaRepository {
+ fun findAllByPaste(paste: String): List
+ fun countByTag(tag: String): Int
+ fun deleteByPasteAndTag(paste: String, tag: String)
+ fun deleteByPaste(paste: String)
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PublicPasteEngagementRepository.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PublicPasteEngagementRepository.kt
new file mode 100644
index 00000000..d2b44da4
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/PublicPasteEngagementRepository.kt
@@ -0,0 +1,24 @@
+package de.interaapps.pastefy.repositories
+
+import de.interaapps.pastefy.entities.PublicPasteEngagement
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.data.jpa.repository.Modifying
+import org.springframework.data.jpa.repository.Query
+import org.springframework.data.repository.query.Param
+
+interface PublicPasteEngagementRepository : JpaRepository {
+ fun findFirstByPasteIdOrderByIdAsc(pasteId: Int): PublicPasteEngagement?
+ fun deleteByPasteId(pasteId: Int)
+ fun findAllByScoreGreaterThanEqual(score: Int): List
+
+ @Modifying
+ @Query(
+ value = """
+ update pastefy_public_paste_engagements
+ set score = score + :score, updated_at = current_timestamp()
+ where paste_id = :pasteId
+ """,
+ nativeQuery = true,
+ )
+ fun incrementScore(@Param("pasteId") pasteId: Int, @Param("score") score: Int): Int
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/SharedPasteRepository.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/SharedPasteRepository.kt
new file mode 100644
index 00000000..e96f663d
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/SharedPasteRepository.kt
@@ -0,0 +1,11 @@
+package de.interaapps.pastefy.repositories
+
+import de.interaapps.pastefy.entities.SharedPaste
+import org.springframework.data.domain.Pageable
+import org.springframework.data.jpa.repository.JpaRepository
+
+interface SharedPasteRepository : JpaRepository {
+ fun findAllByTargetId(targetId: String): List
+ fun findAllByTargetIdOrderByUpdatedAtDesc(targetId: String, pageable: Pageable): List
+ fun deleteByTargetIdOrUserId(targetId: String, userId: String)
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/TagListingRepository.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/TagListingRepository.kt
new file mode 100644
index 00000000..e01602db
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/TagListingRepository.kt
@@ -0,0 +1,15 @@
+package de.interaapps.pastefy.repositories
+
+import de.interaapps.pastefy.entities.TagListing
+import org.springframework.data.domain.Pageable
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor
+
+interface TagListingRepository : JpaRepository, JpaSpecificationExecutor {
+ fun findAllByOrderByPasteCountDesc(pageable: Pageable): List
+ fun findAllByTagContainingIgnoreCaseOrDisplayNameContainingIgnoreCaseOrderByPasteCountDesc(
+ tag: String,
+ displayName: String,
+ pageable: Pageable,
+ ): List
+}
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 00000000..76078a34
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/repositories/UserRepository.kt
@@ -0,0 +1,18 @@
+package de.interaapps.pastefy.repositories
+
+import de.interaapps.pastefy.entities.User
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor
+
+interface UserRepository : JpaRepository, JpaSpecificationExecutor {
+
+ fun findByUniqueName(uniqueName: String): User?
+
+ fun existsByUniqueName(uniqueName: String): Boolean
+
+ fun findByAuthIdAndAuthProvider(
+ authId: String,
+ authProvider: User.AuthenticationProvider
+ ): User?
+
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/ExpiredPasteCleanupService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/ExpiredPasteCleanupService.kt
new file mode 100644
index 00000000..4f5e915f
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/ExpiredPasteCleanupService.kt
@@ -0,0 +1,30 @@
+package de.interaapps.pastefy.service
+
+import de.interaapps.pastefy.repositories.PasteRepository
+import org.slf4j.LoggerFactory
+import org.springframework.scheduling.annotation.Scheduled
+import org.springframework.stereotype.Service
+import org.springframework.transaction.annotation.Transactional
+import java.time.Instant
+
+@Service
+class ExpiredPasteCleanupService(
+ private val pasteRepository: PasteRepository,
+ private val pasteService: PasteService,
+) {
+ @Scheduled(fixedDelay = 60_000)
+ @Transactional
+ fun deleteExpiredPastes() {
+ pasteRepository.findAllByExpireAtBeforeAndExpireAtIsNotNull(Instant.now()).forEach { paste ->
+ runCatching {
+ pasteService.delete(paste)
+ }.onFailure {
+ LOGGER.warn("Unable to delete expired paste {}", paste.key, it)
+ }
+ }
+ }
+
+ companion object {
+ private val LOGGER = LoggerFactory.getLogger(ExpiredPasteCleanupService::class.java)
+ }
+}
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
new file mode 100644
index 00000000..a86d52dd
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/FolderService.kt
@@ -0,0 +1,114 @@
+package de.interaapps.pastefy.service
+
+import de.interaapps.pastefy.config.PastefyProperties
+import de.interaapps.pastefy.dto.folder.CreateFolderRequest
+import de.interaapps.pastefy.dto.folder.FolderResponse
+import de.interaapps.pastefy.entities.Folder
+import de.interaapps.pastefy.entities.User
+import de.interaapps.pastefy.exceptions.NotFoundException
+import de.interaapps.pastefy.exceptions.PermissionsDeniedException
+import de.interaapps.pastefy.repositories.FolderRepository
+import de.interaapps.pastefy.repositories.PasteRepository
+import de.interaapps.pastefy.service.query.LegacyFilterSpecificationBuilder
+import de.interaapps.pastefy.service.query.LegacyFilterSpecificationBuilder.Companion.FOLDER_FIELDS
+import jakarta.persistence.criteria.Predicate
+import jakarta.servlet.http.HttpServletRequest
+import org.springframework.data.domain.PageRequest
+import org.springframework.data.domain.Sort
+import org.springframework.data.jpa.domain.Specification
+import org.springframework.stereotype.Service
+import org.springframework.transaction.annotation.Transactional
+
+@Service
+class FolderService(
+ private val folderRepository: FolderRepository,
+ private val pasteRepository: PasteRepository,
+ private val pasteService: PasteService,
+ private val pasteResponseMapper: PasteResponseMapper,
+ private val pasteMetricsService: PasteMetricsService,
+ private val legacyFilters: LegacyFilterSpecificationBuilder,
+ private val properties: PastefyProperties,
+) {
+ @Transactional
+ fun create(request: CreateFolderRequest, user: User): Folder {
+ val parent = request.parent?.let(folderRepository::findByKey)?.takeIf { it.userId == user.id }
+ return folderRepository.save(
+ Folder(name = request.name, userId = user.id, parent = parent?.key),
+ )
+ }
+
+ fun list(request: HttpServletRequest, user: User?): List {
+ val page = request.positiveInt("page", 1)
+ val pageLimit = request.positiveInt("page_limit", 10).coerceAtMost(100)
+ val search = request.getParameter("search")?.trim()?.takeIf(String::isNotEmpty)
+ val requestedUserId = request.getParameter("user_id")?.trim()?.takeIf(String::isNotEmpty)
+ if (user == null && !properties.listPastes) throw PermissionsDeniedException()
+ val filteredUserId = when {
+ user?.isAdmin == true -> requestedUserId
+ user != null -> user.id
+ else -> null
+ }
+ val baseSpecification = Specification { root, _, builder ->
+ val predicates = mutableListOf()
+ filteredUserId?.let { predicates += builder.equal(root.get("userId"), it) }
+ search?.let { predicates += builder.like(builder.lower(root.get("name")), "%${it.lowercase()}%") }
+ request.getParameter("parent")?.let { predicates += builder.equal(root.get("parent"), it) }
+ builder.and(*predicates.toTypedArray())
+ }
+ val specification = baseSpecification.and(legacyFilters.fromRequest(request, FOLDER_FIELDS))
+ 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
+ )
+ }
+ }
+
+ fun get(id: String): Folder = folderRepository.findByKey(id) ?: throw NotFoundException()
+
+ fun map(
+ folder: Folder,
+ fetchChildren: Boolean = true,
+ fetchSubChildren: Boolean = true,
+ fetchPastes: Boolean = true,
+ showPrivate: Boolean = false,
+ ): FolderResponse {
+ val children = if (fetchChildren) {
+ folderRepository.findAllByParentOrderByUpdatedAtDesc(folder.key)
+ .map { map(it, fetchSubChildren, fetchSubChildren, fetchPastes, showPrivate) }
+ } else null
+ val pastes = if (fetchChildren && fetchPastes) {
+ pasteRepository.findAllByFolderOrderByUpdatedAtDesc(folder.key)
+ .filter { showPrivate || !it.isPrivate }
+ .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,
+ id = folder.key,
+ name = folder.name,
+ userId = folder.userId,
+ children = children,
+ pastes = pastes,
+ created = folder.createdAt?.toString() ?: "0000-00-00 00:00:00",
+ )
+ }
+
+ @Transactional
+ fun delete(folder: Folder) {
+ folderRepository.findAllByParent(folder.key).forEach(::delete)
+ pasteRepository.findAllByFolderOrderByUpdatedAtDesc(folder.key).forEach(pasteService::delete)
+ folderRepository.delete(folder)
+ }
+}
+
+private fun HttpServletRequest.positiveInt(name: String, default: Int): Int =
+ getParameter(name)?.toIntOrNull()?.coerceAtLeast(1) ?: default
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 00000000..262f4f27
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/FrontendIndexService.kt
@@ -0,0 +1,34 @@
+package de.interaapps.pastefy.service
+
+import de.interaapps.pastefy.config.PastefyProperties
+import org.slf4j.LoggerFactory
+import org.springframework.core.io.ClassPathResource
+import org.springframework.http.MediaType
+import org.springframework.http.ResponseEntity
+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()
+
+ 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/InteraAppsExternalAccessService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/InteraAppsExternalAccessService.kt
new file mode 100644
index 00000000..21704d0e
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/InteraAppsExternalAccessService.kt
@@ -0,0 +1,33 @@
+package de.interaapps.pastefy.service
+
+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.exceptions.AuthenticationException
+import de.interaapps.pastefy.exceptions.NotFoundException
+import de.interaapps.pastefy.repositories.AuthKeyRepository
+import de.interaapps.pastefy.repositories.UserRepository
+import org.springframework.stereotype.Service
+import org.springframework.transaction.annotation.Transactional
+
+@Service
+class InteraAppsExternalAccessService(
+ private val properties: PastefyProperties,
+ private val providers: OAuth2ProviderRegistry,
+ private val userRepository: UserRepository,
+ private val authKeyRepository: AuthKeyRepository,
+) {
+ @Transactional
+ fun issue(request: InteraAppsExternalAccessRequest): String {
+ if (providers.get("interaapps") == null) throw NotFoundException()
+ val config = properties.oauth.interaapps
+ if (config.clientId != request.appId || config.clientSecret != request.appSecret) throw AuthenticationException()
+ val user = userRepository.findByAuthIdAndAuthProvider(request.userId, User.AuthenticationProvider.INTERAAPPS)
+ ?: throw NotFoundException()
+ return authKeyRepository.save(
+ AuthKey(userId = user.id, type = AuthKey.Type.ACCESS_TOKEN, scopes = request.appScopeList.toMutableList()),
+ ).key
+ }
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/NotificationService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/NotificationService.kt
new file mode 100644
index 00000000..89df0789
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/NotificationService.kt
@@ -0,0 +1,34 @@
+package de.interaapps.pastefy.service
+
+import de.interaapps.pastefy.entities.Notification
+import de.interaapps.pastefy.entities.User
+import de.interaapps.pastefy.repositories.NotificationRepository
+import org.springframework.stereotype.Service
+import org.springframework.transaction.annotation.Transactional
+
+@Service
+class NotificationService(
+ private val repository: NotificationRepository,
+) {
+ @Transactional
+ fun list(user: User, onlyNotReceived: Boolean, onlyNotRead: Boolean): List {
+ val notifications = when {
+ onlyNotReceived && onlyNotRead -> repository.findAllByUserIdAndReceivedAndAlreadyRead(user.id, false, false)
+ onlyNotReceived -> repository.findAllByUserIdAndReceived(user.id, false)
+ onlyNotRead -> repository.findAllByUserIdAndAlreadyRead(user.id, false)
+ else -> repository.findAllByUserId(user.id)
+ }
+ notifications.forEach { it.received = true }
+ return repository.saveAll(notifications)
+ }
+
+ @Transactional
+ fun readAll(user: User) {
+ val unread = repository.findAllByUserIdAndAlreadyRead(user.id, false)
+ unread.forEach {
+ it.received = true
+ it.alreadyRead = true
+ }
+ repository.saveAll(unread)
+ }
+}
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
new file mode 100644
index 00000000..8b5af985
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteCommandService.kt
@@ -0,0 +1,114 @@
+package de.interaapps.pastefy.service
+
+import de.interaapps.pastefy.dto.pastes.CreatePasteRequest
+import de.interaapps.pastefy.dto.pastes.EditPasteRequest
+import de.interaapps.pastefy.entities.Paste
+import de.interaapps.pastefy.entities.PasteTag
+import de.interaapps.pastefy.entities.User
+import de.interaapps.pastefy.enums.PasteType
+import de.interaapps.pastefy.exceptions.NotFoundException
+import de.interaapps.pastefy.exceptions.PermissionsDeniedException
+import de.interaapps.pastefy.infrastructure.ai.PasteAIInfoService
+import de.interaapps.pastefy.infrastructure.elastic.ElasticPasteService
+import de.interaapps.pastefy.repositories.FolderRepository
+import de.interaapps.pastefy.repositories.PasteRepository
+import de.interaapps.pastefy.repositories.PasteTagRepository
+import org.springframework.beans.factory.ObjectProvider
+import org.springframework.stereotype.Service
+import org.springframework.transaction.annotation.Transactional
+import java.sql.Timestamp
+import java.time.Instant
+
+@Service
+class PasteCommandService(
+ private val pasteRepository: PasteRepository,
+ private val folderRepository: FolderRepository,
+ private val pasteTagRepository: PasteTagRepository,
+ private val pasteService: PasteService,
+ private val tagListings: TagListingService,
+ private val engagement: PublicPasteEngagementService,
+ private val elasticProvider: ObjectProvider,
+ private val aiProvider: ObjectProvider,
+) {
+ @Transactional
+ fun create(request: CreatePasteRequest, user: User?): Paste {
+ val paste = Paste(
+ title = request.title,
+ userId = user?.id,
+ encrypted = request.encrypted,
+ type = request.type,
+ visibility = request.visibility,
+ expireAt = parseExpireAt(request.expireAt),
+ forkedFrom = request.forkedFrom?.takeIf { pasteRepository.existsByKey(it) },
+ folder = request.folder?.let(folderRepository::findByKey)?.takeIf { it.userId == user?.id }?.key,
+ ).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) }
+ 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 {
+ val paste = Paste(title = title, userId = user?.id, type = type).apply { setDatabaseContent(content) }
+ val saved = pasteService.save(paste)
+ syncTags(saved, tags)
+ return saved
+ }
+
+ @Transactional
+ fun update(key: String, request: EditPasteRequest, user: User): Paste {
+ 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.type?.let { paste.type = it }
+ request.encrypted?.let { paste.encrypted = it }
+ request.visibility?.let { paste.visibility = it }
+ request.expireAt?.let { paste.expireAt = parseExpireAt(it) }
+ val saved = pasteService.save(paste)
+ request.tags?.let { syncTags(saved, it) }
+ return saved
+ }
+
+ @Transactional
+ fun delete(key: String, user: User) = pasteService.delete(requireOwned(key, user))
+
+ fun requireOwned(key: String, user: User): Paste {
+ val paste = pasteRepository.findByKey(key) ?: throw NotFoundException()
+ if (paste.userId != user.id && !user.isAdmin) throw PermissionsDeniedException()
+ return paste
+ }
+
+ @Transactional
+ fun syncTags(paste: Paste, requestedTags: List) {
+ val tags = requestedTags.asSequence().map { it.trim().take(30) }.filter(String::isNotEmpty).distinct().toSet()
+ val existing = pasteTagRepository.findAllByPaste(paste.key).map(PasteTag::tag).toSet()
+ (tags - existing).forEach { tag ->
+ pasteTagRepository.save(PasteTag(paste = paste.key, tag = tag))
+ tagListings.updateCount(tag)
+ }
+ (existing - tags).forEach { tag ->
+ pasteTagRepository.deleteByPasteAndTag(paste.key, tag)
+ tagListings.updateCount(tag)
+ }
+ elasticProvider.ifAvailable?.updateTags(paste)
+ }
+
+ private fun parseExpireAt(value: String?): Instant? {
+ if (value == null || value.length < 16) return null
+ return runCatching { Instant.parse(value) }
+ .recoverCatching { Timestamp.valueOf(value).toInstant() }
+ .getOrNull()
+ }
+}
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
new file mode 100644
index 00000000..cc08291b
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteCommentService.kt
@@ -0,0 +1,123 @@
+package de.interaapps.pastefy.service
+
+import de.interaapps.pastefy.dto.pastes.CreatePasteCommentRequest
+import de.interaapps.pastefy.dto.pastes.PasteCommentMarkerResponse
+import de.interaapps.pastefy.dto.pastes.PasteCommentResponse
+import de.interaapps.pastefy.entities.PasteComment
+import de.interaapps.pastefy.entities.User
+import de.interaapps.pastefy.exceptions.HttpException
+import de.interaapps.pastefy.exceptions.NotFoundException
+import de.interaapps.pastefy.exceptions.PermissionsDeniedException
+import de.interaapps.pastefy.repositories.PasteCommentRepository
+import de.interaapps.pastefy.repositories.UserRepository
+import org.springframework.data.domain.PageRequest
+import org.springframework.http.HttpStatus
+import org.springframework.stereotype.Service
+import org.springframework.transaction.annotation.Transactional
+
+@Service
+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)
+ val pageable = PageRequest.of(page.coerceAtLeast(1) - 1, pageLimit.coerceIn(1, MAX_PAGE_LIMIT))
+ val comments = if (line == null) {
+ commentRepository.findAllByPasteAndParentIdIsNullOrderByCreatedAtDesc(pasteId, pageable)
+ } else {
+ commentRepository.findAllByPasteAndParentIdIsNullAndLineFromOrderByCreatedAtDesc(
+ pasteId,
+ line.coerceAtLeast(1),
+ pageable
+ )
+ }
+ return comments.map { map(it, fetchReplies = true) }
+ }
+
+ fun markers(pasteId: String, user: User?): List {
+ pasteService.getAccessiblePasteOrFail(pasteId, user)
+ return commentRepository.findAllByPasteAndLineFromIsNotNullOrderByCreatedAtAsc(pasteId)
+ .groupBy(PasteComment::lineFrom)
+ .mapNotNull { (line, comments) ->
+ line?.let {
+ val profiles = comments.mapNotNull { comment ->
+ userRepository.findById(comment.userId).orElse(null)?.toPublicDto()
+ }.distinctBy { profile -> profile.id }
+ PasteCommentMarkerResponse(it, profiles.take(2), (profiles.size - 2).coerceAtLeast(0))
+ }
+ }
+ }
+
+ @Transactional
+ fun create(pasteId: String, request: CreatePasteCommentRequest, user: User): PasteCommentResponse {
+ pasteService.getAccessiblePasteOrFail(pasteId, user)
+ validate(request)
+ val parentId = request.parentId?.let { id ->
+ val parent = commentRepository.findById(id).orElseThrow(::NotFoundException)
+ if (parent.paste != pasteId) throw NotFoundException()
+ parent.parentId ?: parent.id
+ }
+ val saved = commentRepository.save(
+ PasteComment(
+ paste = pasteId,
+ userId = user.id,
+ content = requireNotNull(request.content).trim(),
+ parentId = parentId,
+ lineFrom = request.lineFrom,
+ lineTo = request.lineTo,
+ ),
+ )
+ pasteMetricsService.invalidate(pasteId)
+ return map(saved, fetchReplies = false)
+ }
+
+ @Transactional
+ fun delete(pasteId: String, commentId: Int, user: User) {
+ val paste = pasteService.get(pasteId) ?: throw NotFoundException()
+ val comment = commentRepository.findById(commentId).orElseThrow(::NotFoundException)
+ if (comment.paste != pasteId) throw NotFoundException()
+ 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) {
+ val content = request.content?.trim().orEmpty()
+ if (content.isEmpty()) badRequest("Comment content is required")
+ if (content.length > MAX_CONTENT_LENGTH) badRequest("Comment content must not exceed 2000 characters")
+ if (request.lineFrom == null && request.lineTo != null) badRequest("line_to requires line_from")
+ if (request.lineFrom != null && request.lineFrom < 1) badRequest("line_from must be positive")
+ if (request.lineTo != null && request.lineTo < requireNotNull(request.lineFrom)) badRequest("line_to must not be smaller than line_from")
+ if (request.lineTo != null && request.lineTo - requireNotNull(request.lineFrom) >= MAX_LINE_RANGE) {
+ badRequest("Comment line ranges must not exceed 1000 lines")
+ }
+ }
+
+ private fun map(comment: PasteComment, fetchReplies: Boolean): PasteCommentResponse =
+ PasteCommentResponse(
+ id = requireNotNull(comment.id),
+ content = comment.content,
+ parentId = comment.parentId,
+ lineFrom = comment.lineFrom,
+ 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(),
+ )
+
+ private fun badRequest(message: String): Nothing = throw HttpException(HttpStatus.BAD_REQUEST, message)
+
+ companion object {
+ const val DEFAULT_PAGE_LIMIT = 10
+ const val MAX_PAGE_LIMIT = 30
+ private const val MAX_CONTENT_LENGTH = 2_000
+ private const val MAX_LINE_RANGE = 1_000
+ }
+}
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 00000000..1609188b
--- /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
new file mode 100644
index 00000000..51c87213
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteQueryService.kt
@@ -0,0 +1,113 @@
+package de.interaapps.pastefy.service
+
+import de.interaapps.pastefy.dto.pastes.PasteResponse
+import de.interaapps.pastefy.entities.Paste
+import de.interaapps.pastefy.entities.User
+import de.interaapps.pastefy.enums.PasteVisibility
+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.LegacyPasteQuery
+import de.interaapps.pastefy.service.query.LegacyPasteQueryParser
+import jakarta.servlet.http.HttpServletRequest
+import jakarta.servlet.http.HttpServletResponse
+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 parser: LegacyPasteQueryParser,
+ private val jpa: JpaPasteQueryAdapter,
+ private val mapper: PasteResponseMapper,
+ private val pasteMetricsService: PasteMetricsService,
+ private val elasticProvider: ObjectProvider,
+) {
+ fun list(
+ request: HttpServletRequest,
+ response: HttpServletResponse,
+ user: User?,
+ guarded: Boolean = true,
+ visibility: PasteVisibility? = null,
+ encrypted: Boolean? = null,
+ userId: String? = null,
+ starredBy: String? = null,
+ ): List {
+ val query = parser.parse(
+ request = request,
+ response = response,
+ user = user,
+ guarded = guarded,
+ visibility = visibility,
+ encrypted = encrypted,
+ userId = userId,
+ starredBy = starredBy,
+ )
+ elasticProvider.ifAvailable {
+ response.addHeader("using-elastic", "true")
+ }
+ return search(query, currentUser = user, fetchStar = user != null, fetchUser = true)
+ }
+
+ fun trending(request: HttpServletRequest, response: HttpServletResponse): List {
+ val additionalFilters = buildList {
+ if (request.parameterMap.containsKey("trending")) {
+ add(
+ LegacyFieldFilter(
+ field = "createdAt",
+ operator = LegacyFilterOperator.GT,
+ value = Instant.now().minus(4, ChronoUnit.DAYS).toString(),
+ ),
+ )
+ }
+ }
+ val query = parser.parse(
+ request = request,
+ response = response,
+ user = null,
+ guarded = false,
+ visibility = PasteVisibility.PUBLIC,
+ encrypted = false,
+ defaultSort = "engagementScore",
+ additionalFilters = additionalFilters,
+ )
+ elasticProvider.ifAvailable {
+ response.addHeader("using-elastic", "true")
+ }
+ return search(query, fetchUser = true)
+ }
+
+ fun search(
+ query: LegacyPasteQuery,
+ currentUser: User? = query.currentUser,
+ fetchStar: Boolean = currentUser != null,
+ fetchUser: Boolean = true,
+ ): List =
+ elasticProvider.ifAvailable?.find(query)
+ ?: jpa.find(query).let { pastes ->
+ val metrics = pasteMetricsService.getMetrics(pastes.map { it.key })
+ pastes.map {
+ mapper.map(
+ it,
+ currentUser,
+ fetchStar = fetchStar,
+ fetchUser = fetchUser,
+ shortenContent = query.shortenContent,
+ withAiInfo = query.withAiInfo,
+ metrics = metrics[it.key],
+ )
+ }
+ }
+
+ 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
new file mode 100644
index 00000000..f81e9f27
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteResponseMapper.kt
@@ -0,0 +1,86 @@
+package de.interaapps.pastefy.service
+
+import de.interaapps.pastefy.config.PastefyProperties
+import de.interaapps.pastefy.dto.pastes.PasteAiInfoResponse
+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
+import de.interaapps.pastefy.util.shorten
+import org.springframework.stereotype.Service
+
+@Service
+class PasteResponseMapper(
+ private val pasteService: PasteService,
+ private val pasteTagRepository: PasteTagRepository,
+ private val userRepository: UserRepository,
+ private val userService: UserService,
+ private val aiInfoRepository: PasteAIInfoRepository,
+ private val pasteMetricsService: PasteMetricsService,
+ properties: PastefyProperties,
+) {
+ private val serverName = properties.serverName.trimEnd('/')
+
+ fun map(
+ paste: Paste,
+ currentUser: User? = null,
+ fetchStar: Boolean = false,
+ 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 ->
+ if (shortenContent) {
+ raw.shorten()
+ } else {
+ raw
+ }
+ }
+ return PasteResponse(
+ exists = true,
+ id = paste.key,
+ content = content,
+ title = paste.title,
+ encrypted = paste.encrypted,
+ folder = paste.folder,
+ userId = paste.userId,
+ visibility = paste.visibility ?: PasteVisibility.UNLISTED,
+ forkedFrom = paste.forkedFrom,
+ rawUrl = "$serverName/${paste.key}/raw",
+ 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 },
+ 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,
+ suggestedFilename = it.suggestedFilename,
+ warnings = it.warningsJson,
+ tags = it.tagsJson,
+ description = it.description,
+ )
+ } else null,
+ )
+ }
+}
+
+fun User.toPublicDto() = PublicUserDto(
+ id = id,
+ name = uniqueName,
+ displayName = name,
+ avatar = avatar,
+)
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 00000000..305e0d6e
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteService.kt
@@ -0,0 +1,108 @@
+package de.interaapps.pastefy.service
+
+import de.interaapps.pastefy.entities.BackgroundJob
+import de.interaapps.pastefy.entities.Paste
+import de.interaapps.pastefy.entities.User
+import de.interaapps.pastefy.enums.StorageType
+import de.interaapps.pastefy.exceptions.NotFoundException
+import de.interaapps.pastefy.exceptions.PastePrivateException
+import de.interaapps.pastefy.infrastructure.elastic.ElasticPasteService
+import de.interaapps.pastefy.infrastructure.redis.PasteRedisCacheService
+import de.interaapps.pastefy.infrastructure.s3.S3PasteService
+import de.interaapps.pastefy.repositories.BackgroundJobRepository
+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 org.springframework.beans.factory.ObjectProvider
+import org.springframework.stereotype.Service
+import org.springframework.transaction.annotation.Transactional
+
+@Service
+class PasteService(
+ private val pasteRepository: PasteRepository,
+ private val pasteTagRepository: PasteTagRepository,
+ private val pasteStarRepository: PasteStarRepository,
+ private val pasteCommentRepository: PasteCommentRepository,
+ private val pasteAIInfoRepository: PasteAIInfoRepository,
+ private val backgroundJobRepository: BackgroundJobRepository,
+ private val engagementRepository: PublicPasteEngagementRepository,
+ private val redisCacheProvider: ObjectProvider,
+ private val s3Provider: ObjectProvider,
+ private val elasticProvider: ObjectProvider,
+) {
+ fun get(pasteKey: String): Paste? = 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? {
+ val redisCache = redisCacheProvider.ifAvailable
+ var accessCount = 0L
+ if (withCache) {
+ accessCount = redisCache?.incrementAccessCount(paste) ?: 0
+ redisCache?.getContent(paste)?.let { return it }
+ }
+
+ val content = if (paste.storageType == StorageType.S3) {
+ if (paste.cachedContents == null) paste.cachedContents = requireS3().getContent(paste)
+ paste.cachedContents
+ } else {
+ paste.content
+ }
+
+ if (withCache && content != null && redisCache?.shouldCache(accessCount) == true) {
+ redisCache.putContent(paste, content)
+ }
+ return content
+ }
+
+ @Transactional
+ fun save(paste: Paste): Paste {
+ var saved = pasteRepository.saveAndFlush(paste)
+ 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)
+ }
+
+ if (elasticProvider.ifAvailable?.store(saved, getContent(saved, withCache = false)) == true) {
+ pasteRepository.updateIndexedInElastic(requireNotNull(saved.id), true)
+ saved.indexedInElastic = true
+ }
+ return saved
+ }
+
+ @Transactional
+ fun delete(paste: Paste) {
+ if (paste.storageType == StorageType.S3) requireS3().delete(paste)
+ if (paste.indexedInElastic) elasticProvider.ifAvailable?.delete(paste)
+
+ val pasteId = requireNotNull(paste.id)
+ pasteCommentRepository.deleteByPaste(paste.key)
+ pasteTagRepository.deleteByPaste(paste.key)
+ pasteStarRepository.deleteByPaste(paste.key)
+ engagementRepository.deleteByPasteId(pasteId)
+ pasteAIInfoRepository.deleteById(pasteId)
+ backgroundJobRepository.deleteByTypeAndEntityId(BackgroundJob.Type.PASTE_AI_INFO, pasteId)
+ pasteRepository.delete(paste)
+ redisCacheProvider.ifAvailable?.evictContent(paste)
+ }
+
+ private fun requireS3(): S3PasteService =
+ 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
new file mode 100644
index 00000000..59cb2a50
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PasteThumbnailService.kt
@@ -0,0 +1,47 @@
+package de.interaapps.pastefy.service
+
+import org.apache.batik.transcoder.TranscoderInput
+import org.apache.batik.transcoder.TranscoderOutput
+import org.apache.batik.transcoder.image.PNGTranscoder
+import org.springframework.core.io.ClassPathResource
+import org.springframework.stereotype.Service
+import org.springframework.web.util.HtmlUtils
+import java.awt.Font
+import java.awt.GraphicsEnvironment
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.nio.charset.StandardCharsets
+
+@Service
+class PasteThumbnailService {
+ private val template =
+ ClassPathResource("templates/thumbnail-svg.svg").inputStream.bufferedReader().use { it.readText() }
+
+ init {
+ registerFont("fonts/PlusJakartaSans.ttf")
+ registerFont("fonts/JetBrainsMono.ttf")
+ }
+
+ fun render(title: String?, content: String): ByteArray {
+ var svg = template.replace("%title%", escape(title.orEmpty()))
+ val lines = content.lines()
+ repeat(5) { index ->
+ svg = svg.replace("%content_${index + 1}%", escape(lines.getOrElse(index) { "" }))
+ }
+ return ByteArrayOutputStream().use { output ->
+ PNGTranscoder().transcode(
+ TranscoderInput(ByteArrayInputStream(svg.toByteArray(StandardCharsets.UTF_8))),
+ TranscoderOutput(output),
+ )
+ output.toByteArray()
+ }
+ }
+
+ private fun escape(value: String) = HtmlUtils.htmlEscape(value)
+
+ private fun registerFont(path: String) {
+ ClassPathResource(path).inputStream.use {
+ GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(Font.createFont(Font.TRUETYPE_FONT, it))
+ }
+ }
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PublicPasteEngagementService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PublicPasteEngagementService.kt
new file mode 100644
index 00000000..39d55ddb
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/PublicPasteEngagementService.kt
@@ -0,0 +1,35 @@
+package de.interaapps.pastefy.service
+
+import de.interaapps.pastefy.entities.Paste
+import de.interaapps.pastefy.entities.PublicPasteEngagement
+import de.interaapps.pastefy.infrastructure.elastic.ElasticPasteService
+import de.interaapps.pastefy.repositories.PublicPasteEngagementRepository
+import org.slf4j.LoggerFactory
+import org.springframework.beans.factory.ObjectProvider
+import org.springframework.scheduling.annotation.Async
+import org.springframework.stereotype.Service
+import org.springframework.transaction.annotation.Transactional
+
+@Service
+class PublicPasteEngagementService(
+ private val repository: PublicPasteEngagementRepository,
+ private val elasticProvider: ObjectProvider,
+) {
+ @Async
+ @Transactional
+ fun addInterest(paste: Paste, score: Int) {
+ val pasteId = requireNotNull(paste.id)
+ runCatching {
+ if (repository.incrementScore(pasteId, score) == 0) {
+ repository.save(PublicPasteEngagement(pasteId = pasteId, score = score))
+ }
+ elasticProvider.ifAvailable?.updateEngagement(paste)
+ }.onFailure {
+ LOGGER.warn("Unable to update engagement for paste {}", pasteId, it)
+ }
+ }
+
+ companion object {
+ private val LOGGER = LoggerFactory.getLogger(PublicPasteEngagementService::class.java)
+ }
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/SeoHtmlCacheService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/SeoHtmlCacheService.kt
new file mode 100644
index 00000000..d9829a30
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/SeoHtmlCacheService.kt
@@ -0,0 +1,16 @@
+package de.interaapps.pastefy.service
+
+import org.springframework.cache.annotation.Cacheable
+import org.springframework.stereotype.Service
+
+@Service
+class SeoHtmlCacheService(
+ private val seo: SeoRenderer,
+) {
+ @Cacheable(cacheNames = ["seo-pages"], key = "#p0", unless = "#result == null")
+ fun render(cacheKey: String, pageFactory: () -> SeoRenderer.SeoPage?): String? {
+ val page = pageFactory() ?: return null
+ val html = seo.render(page) ?: return null
+ return html.takeIf { it.contains("""id="seo-content"""") }
+ }
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/SeoPageCacheService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/SeoPageCacheService.kt
new file mode 100644
index 00000000..d1a095b7
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/SeoPageCacheService.kt
@@ -0,0 +1,105 @@
+package de.interaapps.pastefy.service
+
+import de.interaapps.pastefy.config.PastefyProperties
+import jakarta.annotation.PreDestroy
+import org.slf4j.LoggerFactory
+import org.springframework.cache.CacheManager
+import org.springframework.http.MediaType
+import org.springframework.http.ResponseEntity
+import org.springframework.stereotype.Service
+import java.util.concurrent.ArrayBlockingQueue
+import java.util.concurrent.ExecutionException
+import java.util.concurrent.RejectedExecutionException
+import java.util.concurrent.ThreadPoolExecutor
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+import java.util.concurrent.atomic.AtomicInteger
+
+@Service
+class SeoPageCacheService(
+ private val htmlCache: SeoHtmlCacheService,
+ private val cacheManager: CacheManager,
+ private val properties: PastefyProperties,
+) {
+ private val threadCounter = AtomicInteger()
+ private val executor = ThreadPoolExecutor(
+ properties.seo.executorThreads.coerceAtLeast(1),
+ properties.seo.executorThreads.coerceAtLeast(1),
+ 0L,
+ TimeUnit.MILLISECONDS,
+ ArrayBlockingQueue(properties.seo.executorQueueCapacity.coerceAtLeast(1)),
+ { runnable ->
+ Thread(runnable, "pastefy-seo-render-${threadCounter.incrementAndGet()}").apply {
+ isDaemon = true
+ }
+ },
+ ThreadPoolExecutor.AbortPolicy(),
+ )
+
+ fun renderResponse(
+ cacheKey: String,
+ fallback: () -> ResponseEntity,
+ pageFactory: () -> SeoRenderer.SeoPage?,
+ ): ResponseEntity =
+ renderWithBudget(cacheKey, pageFactory)?.let {
+ ResponseEntity.ok()
+ .contentType(MediaType("text", "html", Charsets.UTF_8))
+ .body(it)
+ } ?: fallback()
+
+ private fun renderWithBudget(cacheKey: String, pageFactory: () -> SeoRenderer.SeoPage?): String? {
+ cachedHtml(cacheKey)?.let { return it }
+
+ if (!properties.seo.renderTimeoutEnabled || properties.seo.renderTimeoutMillis <= 0) {
+ return renderSafely(cacheKey, pageFactory)
+ }
+
+ val future = try {
+ executor.submit { renderSafely(cacheKey, pageFactory) }
+ } catch (exception: RejectedExecutionException) {
+ LOGGER.debug("SEO render queue is full for {}; falling back to frontend", cacheKey, exception)
+ return null
+ }
+
+ return try {
+ future.get(properties.seo.renderTimeoutMillis.coerceAtLeast(1), TimeUnit.MILLISECONDS)
+ } catch (exception: TimeoutException) {
+ future.cancel(true)
+ LOGGER.debug(
+ "SEO render timed out after {}ms for {}; falling back to frontend",
+ properties.seo.renderTimeoutMillis,
+ cacheKey,
+ )
+ null
+ } catch (exception: InterruptedException) {
+ Thread.currentThread().interrupt()
+ future.cancel(true)
+ null
+ } catch (exception: ExecutionException) {
+ LOGGER.warn("SEO render failed for {}; falling back to frontend", cacheKey, exception.cause ?: exception)
+ null
+ }
+ }
+
+ private fun cachedHtml(cacheKey: String): String? =
+ runCatching {
+ cacheManager.getCache(SEO_PAGES_CACHE)?.get(cacheKey, String::class.java)
+ }.getOrNull()
+
+ private fun renderSafely(cacheKey: String, pageFactory: () -> SeoRenderer.SeoPage?): String? =
+ runCatching {
+ htmlCache.render(cacheKey, pageFactory)
+ }.onFailure {
+ LOGGER.warn("SEO render failed for {}; falling back to frontend", cacheKey, it)
+ }.getOrNull()
+
+ @PreDestroy
+ fun shutdown() {
+ executor.shutdownNow()
+ }
+
+ companion object {
+ private const val SEO_PAGES_CACHE = "seo-pages"
+ private val LOGGER = LoggerFactory.getLogger(SeoPageCacheService::class.java)
+ }
+}
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/SeoPageContentService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/SeoPageContentService.kt
new file mode 100644
index 00000000..40a8480c
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/SeoPageContentService.kt
@@ -0,0 +1,163 @@
+package de.interaapps.pastefy.service
+
+import de.interaapps.pastefy.dto.pastes.PasteResponse
+import de.interaapps.pastefy.entities.Paste
+import de.interaapps.pastefy.repositories.PasteAIInfoRepository
+import de.interaapps.pastefy.repositories.PasteTagRepository
+import de.interaapps.pastefy.repositories.UserRepository
+import org.springframework.stereotype.Service
+
+@Service
+class SeoPageContentService(
+ private val seo: SeoRenderer,
+ private val users: UserRepository,
+ private val tags: PasteTagRepository,
+ private val aiInfo: PasteAIInfoRepository,
+ private val metrics: PasteMetricsService,
+) {
+ fun title(paste: Paste): String =
+ seo.truncate(seo.normalizeText(paste.title, "Paste"), 120)
+
+ fun author(paste: Paste): SeoAuthor? =
+ paste.userId
+ ?.let(users::findById)
+ ?.orElse(null)
+ ?.let { user ->
+ val username = seo.normalizeText(user.uniqueName).takeIf(String::isNotBlank) ?: return@let null
+ SeoAuthor(
+ displayName = seo.normalizeText(user.name, username),
+ username = username,
+ profileUrl = seo.absoluteUrl("/@${seo.pathSegment(username)}"),
+ avatar = user.avatar,
+ )
+ }
+
+ fun pasteListSection(title: String, pastes: List, emptyText: String): String {
+ if (pastes.isEmpty()) return seo.section(title, seo.paragraph(emptyText))
+
+ val metricMap = metrics.getMetrics(pastes.map { it.key })
+ return seo.section(
+ title,
+ seo.unorderedList(
+ pastes.map { paste ->
+ pasteListItem(paste, metricMap[paste.key] ?: PasteMetrics())
+ },
+ cssClass = "seo-paste-list",
+ ),
+ )
+ }
+
+ fun pasteResponseListSection(title: String, pastes: List, emptyText: String): String {
+ if (pastes.isEmpty()) return seo.section(title, seo.paragraph(emptyText))
+
+ return seo.section(
+ title,
+ seo.unorderedList(
+ pastes.map(::pasteResponseListItem),
+ cssClass = "seo-paste-list",
+ ),
+ )
+ }
+
+ fun pasteListItem(paste: Paste, metrics: PasteMetrics): String {
+ val pasteTitle = title(paste)
+ val pasteUrl = seo.absoluteUrl("/${seo.pathSegment(paste.key)}")
+ val author = author(paste)
+ val tagLinks = tagLinks(paste, limit = 8)
+ val aiDescription = paste.id
+ ?.let(aiInfo::findById)
+ ?.orElse(null)
+ ?.description
+ ?.let { seo.normalizeText(it) }
+ ?.takeIf(String::isNotBlank)
+ ?.let { seo.paragraph(seo.truncate(it, 180)) }
+ .orEmpty()
+
+ val metadata = buildMap {
+ paste.createdAt?.let { put("Created", it.toString()) }
+ author?.let { put("Author", "${it.displayName} (@${it.username})") }
+ put("Views", metrics.viewCount.toString())
+ put("Comments", metrics.commentCount.toString())
+ put("Stars", metrics.starCount.toString())
+ }
+
+ return buildString {
+ append("")
+ append(seo.heading(3, pasteTitle))
+ append(seo.paragraph("Public paste on Pastefy."))
+ append(seo.link(pasteUrl, "Open paste"))
+ append(seo.definitionList(metadata))
+ if (tagLinks.isNotBlank()) append("$tagLinks
")
+ append(aiDescription)
+ append("")
+ }
+ }
+
+ fun pasteResponseListItem(paste: PasteResponse): String {
+ val pasteKey = paste.id?.takeIf(String::isNotBlank) ?: return ""
+ val pasteTitle = seo.truncate(seo.normalizeText(paste.title, "Paste"), 120)
+ val pasteUrl = seo.absoluteUrl("/${seo.pathSegment(pasteKey)}")
+ val username = seo.normalizeText(paste.user?.name)
+ val displayName = seo.normalizeText(paste.user?.displayName, username)
+ val tagLinks = paste.tags.orEmpty()
+ .mapNotNull { it.trim().takeIf(String::isNotBlank) }
+ .take(8)
+ .joinToString(" ") { tag ->
+ seo.link(seo.absoluteUrl("/tags/${seo.pathSegment(tag)}"), tag, "seo-tag")
+ }
+ val aiDescription = paste.aiInfo?.description
+ ?.let { seo.normalizeText(it) }
+ ?.takeIf(String::isNotBlank)
+ ?.let { seo.paragraph(seo.truncate(it, 180)) }
+ .orEmpty()
+
+ val metadata = buildMap {
+ paste.createdAt.takeIf { it != "0000-00-00 00:00:00" }?.let { put("Created", it) }
+ if (username.isNotBlank()) put("Author", "$displayName (@$username)")
+ put("Views", paste.viewCount.toString())
+ put("Comments", paste.commentCount.toString())
+ put("Stars", paste.starCount.toString())
+ }
+
+ return buildString {
+ append("")
+ append(seo.heading(3, pasteTitle))
+ append(seo.paragraph("Public paste on Pastefy."))
+ append(seo.link(pasteUrl, "Open paste"))
+ append(seo.definitionList(metadata))
+ if (tagLinks.isNotBlank()) append("$tagLinks
")
+ append(aiDescription)
+ append("")
+ }
+ }
+
+ fun pasteMetadata(paste: Paste, author: SeoAuthor?): String {
+ val pasteMetrics = metrics.getMetrics(paste.key)
+ return seo.definitionList(
+ buildMap {
+ paste.createdAt?.let { put("Created", it.toString()) }
+ author?.let { put("Author", "${it.displayName} (@${it.username})") }
+ paste.type?.let { put("Type", it.name) }
+ paste.visibility?.let { put("Visibility", it.name) }
+ put("Views", pasteMetrics.viewCount.toString())
+ put("Comments", pasteMetrics.commentCount.toString())
+ put("Stars", pasteMetrics.starCount.toString())
+ },
+ )
+ }
+
+ fun tagLinks(paste: Paste, limit: Int = Int.MAX_VALUE): String =
+ tags.findAllByPaste(paste.key)
+ .mapNotNull { it.tag.trim().takeIf(String::isNotBlank) }
+ .take(limit)
+ .joinToString(" ") { tag ->
+ seo.link(seo.absoluteUrl("/tags/${seo.pathSegment(tag)}"), tag, "seo-tag")
+ }
+}
+
+data class SeoAuthor(
+ val displayName: String,
+ val username: String,
+ val profileUrl: String,
+ val avatar: String? = null,
+)
diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/SeoPasteListService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/SeoPasteListService.kt
new file mode 100644
index 00000000..851a83d3
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/SeoPasteListService.kt
@@ -0,0 +1,72 @@
+package de.interaapps.pastefy.service
+
+import de.interaapps.pastefy.dto.pastes.PasteResponse
+import de.interaapps.pastefy.enums.PasteVisibility
+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 de.interaapps.pastefy.service.query.LegacySort
+import org.springframework.stereotype.Service
+import java.time.Instant
+import java.time.temporal.ChronoUnit
+
+@Service
+class SeoPasteListService(
+ private val queries: PasteQueryService,
+) {
+ fun latestPublic(limit: Int): List =
+ queries.search(publicQuery(limit = limit, sort = "createdAt"))
+
+ fun recentTrending(limit: Int): List =
+ queries.search(
+ publicQuery(
+ limit = limit,
+ sort = "engagementScore",
+ additionalFilters = listOf(
+ LegacyFieldFilter(
+ field = "createdAt",
+ operator = LegacyFilterOperator.GT,
+ value = Instant.now().minus(4, ChronoUnit.DAYS).toString(),
+ ),
+ ),
+ ),
+ )
+
+ fun allTimeTrending(limit: Int): List =
+ queries.search(publicQuery(limit = limit, sort = "engagementScore"))
+
+ fun publicByUser(userId: String, limit: Int): List =
+ queries.search(
+ publicQuery(
+ limit = limit,
+ sort = "createdAt",
+ additionalFilters = listOf(LegacyFieldFilter("userId", LegacyFilterOperator.EQ, userId)),
+ ),
+ )
+
+ private fun publicQuery(
+ limit: Int,
+ sort: String,
+ additionalFilters: List = emptyList(),
+ ): LegacyPasteQuery =
+ LegacyPasteQuery(
+ page = 1,
+ pageLimit = limit.coerceAtLeast(1),
+ search = null,
+ sorts = listOf(LegacySort(field = sort, ascending = false)),
+ filter = LegacyFilterGroup(
+ LegacyGroupOperator.AND,
+ listOf(
+ LegacyFieldFilter("visibility", LegacyFilterOperator.EQ, PasteVisibility.PUBLIC.name),
+ LegacyFieldFilter("encrypted", LegacyFilterOperator.EQ, false.toString()),
+ ) + additionalFilters,
+ ),
+ filterTags = emptyList(),
+ shortenContent = true,
+ withAiInfo = true,
+ currentUser = null,
+ )
+}
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
new file mode 100644
index 00000000..e140e8ee
--- /dev/null
+++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/SeoRenderer.kt
@@ -0,0 +1,191 @@
+package de.interaapps.pastefy.service
+
+import de.interaapps.pastefy.config.PastefyProperties
+import org.slf4j.LoggerFactory
+import org.springframework.stereotype.Service
+import org.springframework.web.util.HtmlUtils
+import java.net.URLEncoder
+import java.nio.charset.StandardCharsets
+
+@Service
+class SeoRenderer(
+ private val properties: PastefyProperties,
+ frontendIndex: FrontendIndexService,
+) {
+ 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
+ val template = html ?: return null
+ return template
+ .replace(TITLE_REPLACEMENT, escapeHtml(page.title))
+ .replace(META_REPLACEMENT, tagsToHtml(page))
+ .replace(SEO_CONTENT_REPLACEMENT, page.content)
+ }
+
+ fun page(canonicalPath: String, title: String, description: String) =
+ SeoPage(absoluteUrl(canonicalPath), title, description)
+
+ fun pathSegment(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20")
+
+ fun normalizeText(value: String?, fallback: String = ""): String =
+ value?.trim()?.replace(Regex("\\s+"), " ")?.takeIf(String::isNotEmpty) ?: fallback
+
+ 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
+ else value.substring(0, value.offsetByCodePoints(0, maxLength))
+
+ fun escapeHtml(value: String?) = HtmlUtils.htmlEscape(value.orEmpty())
+
+ fun mainContent(vararg parts: String): String =
+ "${parts.joinToString("")}"
+
+ fun section(title: String, vararg parts: String): String =
+ "${escapeHtml(title)}
${parts.joinToString("")}"
+
+ fun heading(level: Int, text: String, attributes: String = ""): String {
+ val safeLevel = level.coerceIn(1, 6)
+ val attrs = attributes.takeIf(String::isNotBlank)?.let { " $it" }.orEmpty()
+ return "${escapeHtml(text)}"
+ }
+
+ fun paragraph(text: String): String = "${escapeHtml(text)}
"
+
+ fun link(url: String, label: String, cssClass: String? = null): String {
+ val classAttribute = cssClass?.takeIf(String::isNotBlank)?.let { " class=\"${escapeHtml(it)}\"" }.orEmpty()
+ return "${escapeHtml(label)}"
+ }
+
+ fun unorderedList(items: Collection, cssClass: String? = null): String {
+ if (items.isEmpty()) return ""
+ val classAttribute = cssClass?.takeIf(String::isNotBlank)?.let { " class=\"${escapeHtml(it)}\"" }.orEmpty()
+ return items.joinToString(prefix = "") { "$it" }
+ }
+
+ fun definitionList(items: Map): String {
+ if (items.isEmpty()) return ""
+ return items.entries.joinToString(prefix = "", postfix = "
") {
+ "${escapeHtml(it.key)}${escapeHtml(it.value)}"
+ }
+ }
+
+ 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) {
+ "Frontend mount is missing in static/index.html"
+ }
+
+ return if (TITLE.containsMatchIn(prepared)) {
+ TITLE.replaceFirst(prepared, "