diff --git a/README.md b/README.md index 9ee41ad2..c0cee2a3 100755 --- a/README.md +++ b/README.md @@ -65,21 +65,6 @@ Visit the docs for everything around Pastefy here: https://docs.pastefy.app See [Self-Hosting](https://docs.pastefy.app/self-hosting/index.html) for more options. -### Container-Less -```bash -git clone https://github.com/interaapps/pastefy.git -cd pastefy/frontend -npm run install -npm run build -cd ../backend -mvn clean package -cd .. -cp .env.example .env -nano .env -java -jar backend/target/backend.jar -``` -Using intelliJ? Look at [Develop](#Develop) - ## Configuration See [Configuration](https://docs.pastefy.app/self-hosting/configuration.html) for all options. ### Adding login @@ -106,21 +91,28 @@ OAUTH2_CUSTOM_USERINFO_ENDPOINT=https://accounts.interaapps.de/api/v2/oidc/useri ## Develop -#### Build frontend into the backend +#### Backend Development +Open IntelliJ, go into Main.kt and click on the green play button. + +Having docker installed, all dependencies will be automatically started. (MySQL, Redis, MinIO, Elastic, Clickhouse) + +The backend will start on port 8080. + +#### Bundle frontend into the backend ```bash # You might want to build the frontend cd frontend -npm build prod +npm build ``` #### Frontend -Run the backend (On port 1337) and then go to the frontend and run +Run the backend (On port 8080) and then go to the frontend and run ```bash cd frontend -npm run serve +VITE_APP_BASE_URL=http://localhost:8080 npm run dev ``` -We are using IntelliJ Idea and Visual Studio code. +We are using IntelliJ Idea and WebStorm. ### API You can find the docs of the Pastefy-Rest-APi here: [Docs](https://docs.pastefy.app/api/) diff --git a/backend-kt/.gitignore b/backend-kt/.gitignore new file mode 100644 index 00000000..4a646c92 --- /dev/null +++ b/backend-kt/.gitignore @@ -0,0 +1,48 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +src/main/resources/static +src/main/resources/application-prod.properties + +### Kotlin ### +.kotlin + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store diff --git a/backend-kt/.idea/.gitignore b/backend-kt/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/backend-kt/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/backend-kt/.idea/dataSources.local.xml b/backend-kt/.idea/dataSources.local.xml new file mode 100644 index 00000000..316e072c --- /dev/null +++ b/backend-kt/.idea/dataSources.local.xml @@ -0,0 +1,42 @@ + + + + + + $ + ` + + + master_key + myuser + + + + + + + + + + master_key + + + + + + + + + ` + + + master_key + pastefy + + + + + + + + \ No newline at end of file diff --git a/backend-kt/.idea/dataSources.xml b/backend-kt/.idea/dataSources.xml new file mode 100644 index 00000000..00fcccc8 --- /dev/null +++ b/backend-kt/.idea/dataSources.xml @@ -0,0 +1,26 @@ + + + + + mysql.8 + true + com.mysql.cj.jdbc.Driver + jdbc:mysql://localhost:3306/mydatabase + $ProjectFileDir$ + + + redis + true + jdbc.RedisDriver + jdbc:redis://localhost:6379/0 + $ProjectFileDir$ + + + clickhouse + true + com.clickhouse.jdbc.ClickHouseDriver + jdbc:clickhouse://localhost:8123/pastefy + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/backend-kt/.idea/dataSources/b37783b9-20c2-452a-8fe1-13f1a147f7ca.xml b/backend-kt/.idea/dataSources/b37783b9-20c2-452a-8fe1-13f1a147f7ca.xml new file mode 100644 index 00000000..bfcdd6aa --- /dev/null +++ b/backend-kt/.idea/dataSources/b37783b9-20c2-452a-8fe1-13f1a147f7ca.xml @@ -0,0 +1,26 @@ + + + + + 7.4.9 + + + 1 + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend-kt/.idea/dataSources/c563d18b-01ac-45cd-aec1-2e7fe7a80fb9.xml b/backend-kt/.idea/dataSources/c563d18b-01ac-45cd-aec1-2e7fe7a80fb9.xml new file mode 100644 index 00000000..0cc0e7a4 --- /dev/null +++ b/backend-kt/.idea/dataSources/c563d18b-01ac-45cd-aec1-2e7fe7a80fb9.xml @@ -0,0 +1,2443 @@ + + + + + 26.5.1.882 + + + + + + + 1 + + + 1 + + + 1 + + + + + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + 1 + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + + + + + + + + + + + + 1 + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + 1 + + + 1 + + + 1 + + + + + + + 1 + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + 1 + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + 1 + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + 1 + + + + 1 + + + + + + + + + + 1 + + + 1 + + + + + + + + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + + 1 + + + 1 + + + + + + + + + + 1 + + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + + + 1 + + + + + + 1 + + + + + 1 + + + 1 + + + + + 1 + + + + + + + + 1 + + + 1 + + + + 1 + + + 1 + + + 1 + + + + 1 + + + + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + 1 + + + + + + + + + 1 + + + 1 + + + 1 + + + 1 + + + + + + + + + + + + + + + + + 1 + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + [plaintext_password] + 1 + ::/0 + + + MergeTree + PRIMARY KEY (script) +ORDER BY (script) +SETTINGS index_granularity = 8192 + 062a09d7-b86d-4079-b8f7-b53fb112e79d +
+ + MergeTree + PARTITION BY toYYYYMM(visited_at) +ORDER BY (paste_key, visited_at) +TTL visited_at + toIntervalDay(90) +SETTINGS index_granularity = 8192 + 9058fccf-1cfe-428b-84ea-6ac0367f1fed +
+ + 1 + 1 + Int32|0s + + + 2 + String|0s + + + 1 + 3 + String|0s + + + 1 + 4 + String|0s + + + 1 + 5 + String|0s + + + 6 + Int32|0s + + + 1 + 7 + String|0s + + + now() + 1 + 8 + DateTime|0s + + + 1 + 9 + Int32|0s + + + 1 + 10 + Bool|0s + + + 1 + 1 + FixedString(8)|0s + + + 1 + 2 + LowCardinality(String)|0v + + + 3 + FixedString(8)|0s + + + 1 + 4 + LowCardinality(String)|0v + + + 1 + 5 + DateTime64(3, 'UTC')|0v + + + 1 + 6 + LowCardinality(String)|0v + + + 1 + 7 + LowCardinality(String)|0v + + + 1 + 8 + LowCardinality(String)|0v + + + 9 + FixedString(8)|0s + + + 1 + 10 + LowCardinality(String)|0v + + + 1 + 11 + LowCardinality(String)|0v + + + 1 + 12 + LowCardinality(String)|0v + + + 13 + UInt64|0s + + + 1 + 14 + String|0s + + + 1 + 15 + LowCardinality(String)|0v + + + 1 + 16 + UInt8|0s + +
+
\ No newline at end of file diff --git a/backend-kt/.idea/dataSources/c563d18b-01ac-45cd-aec1-2e7fe7a80fb9/storage_v2/_src_/schema/system.L3Icyw.meta b/backend-kt/.idea/dataSources/c563d18b-01ac-45cd-aec1-2e7fe7a80fb9/storage_v2/_src_/schema/system.L3Icyw.meta new file mode 100644 index 00000000..dd07835b --- /dev/null +++ b/backend-kt/.idea/dataSources/c563d18b-01ac-45cd-aec1-2e7fe7a80fb9/storage_v2/_src_/schema/system.L3Icyw.meta @@ -0,0 +1,2 @@ +#n:system +! [null, 0, null, null, -2147483648, -2147483648] diff --git a/backend-kt/.idea/dataSources/data_sources_history.xml b/backend-kt/.idea/dataSources/data_sources_history.xml new file mode 100644 index 00000000..18d0ceb8 --- /dev/null +++ b/backend-kt/.idea/dataSources/data_sources_history.xml @@ -0,0 +1,60 @@ + + + + + $ + ` + + + mysql.8 + true + com.mysql.cj.jdbc.Driver + jdbc:mysql://localhost:3306/mydatabase + master_key + myuser + + + + + + $ProjectFileDir$ + + + + + + + redis + true + jdbc.RedisDriver + jdbc:redis://localhost:6379/0 + master_key + + + + + + $ProjectFileDir$ + + + + + + ` + + + clickhouse + true + com.clickhouse.jdbc.ClickHouseDriver + jdbc:clickhouse://localhost:8123/pastefy + master_key + pastefy + + + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/backend-kt/.idea/dataSources/f90989bf-a7eb-4fb1-90d3-4e8a669d378d.xml b/backend-kt/.idea/dataSources/f90989bf-a7eb-4fb1-90d3-4e8a669d378d.xml new file mode 100644 index 00000000..96c0a559 --- /dev/null +++ b/backend-kt/.idea/dataSources/f90989bf-a7eb-4fb1-90d3-4e8a669d378d.xml @@ -0,0 +1,1899 @@ + + + + + caching_sha2_password + exact + InnoDB + InnoDB + mydatabase|schema||myuser||ALTER|G +mydatabase|schema||myuser||ALTER ROUTINE|G +mydatabase|schema||myuser||CREATE|G +mydatabase|schema||myuser||CREATE ROUTINE|G +mydatabase|schema||myuser||CREATE TEMPORARY TABLES|G +mydatabase|schema||myuser||CREATE VIEW|G +mydatabase|schema||myuser||DELETE|G +mydatabase|schema||myuser||DROP|G +mydatabase|schema||myuser||EVENT|G +mydatabase|schema||myuser||EXECUTE|G +mydatabase|schema||myuser||INDEX|G +mydatabase|schema||myuser||INSERT|G +mydatabase|schema||myuser||LOCK TABLES|G +mydatabase|schema||myuser||REFERENCES|G +mydatabase|schema||myuser||SELECT|G +mydatabase|schema||myuser||SHOW VIEW|G +mydatabase|schema||myuser||TRIGGER|G +mydatabase|schema||myuser||UPDATE|G + 8.4.8 + + + armscii8 + + + armscii8 + 1 + + + ascii + + + ascii + 1 + + + big5 + + + big5 + 1 + + + binary + 1 + + + cp1250 + + + cp1250 + + + cp1250 + + + cp1250 + 1 + + + cp1250 + + + cp1251 + + + cp1251 + + + cp1251 + 1 + + + cp1251 + + + cp1251 + + + cp1256 + + + cp1256 + 1 + + + cp1257 + + + cp1257 + 1 + + + cp1257 + + + cp850 + + + cp850 + 1 + + + cp852 + + + cp852 + 1 + + + cp866 + + + cp866 + 1 + + + cp932 + + + cp932 + 1 + + + dec8 + + + dec8 + 1 + + + eucjpms + + + eucjpms + 1 + + + euckr + + + euckr + 1 + + + gb18030 + + + gb18030 + 1 + + + gb18030 + + + gb2312 + + + gb2312 + 1 + + + gbk + + + gbk + 1 + + + geostd8 + + + geostd8 + 1 + + + greek + + + greek + 1 + + + hebrew + + + hebrew + 1 + + + hp8 + + + hp8 + 1 + + + keybcs2 + + + keybcs2 + 1 + + + koi8r + + + koi8r + 1 + + + koi8u + + + koi8u + 1 + + + latin1 + + + latin1 + + + latin1 + + + latin1 + + + latin1 + + + latin1 + + + latin1 + + + latin1 + 1 + + + latin2 + + + latin2 + + + latin2 + + + latin2 + 1 + + + latin2 + + + latin5 + + + latin5 + 1 + + + latin7 + + + latin7 + + + latin7 + 1 + + + latin7 + + + macce + + + macce + 1 + + + macroman + + + macroman + 1 + + + sjis + + + sjis + 1 + + + swe7 + + + swe7 + 1 + + + tis620 + + + tis620 + 1 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + 1 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ucs2 + + + ujis + + + ujis + 1 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + 1 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16 + + + utf16le + + + utf16le + 1 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + 1 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf32 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + 1 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb3 + + + utf8mb4 + 1 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb4 + + + utf8mb3_general_ci + + + 3 + 1 + 3 + 2026-06-04.21:03:37 + utf8mb4_0900_ai_ci + + + utf8mb4_0900_ai_ci + + + + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 3 + InnoDB + utf8mb4_0900_ai_ci +
+ + 1 + 1 + varchar(255)|0s + + + 1 + 2 + varchar(255)|0s + + + 1 + 3 + varchar(255)|0s + + + 1 + 4 + datetime|0s + + + 1 + 5 + int|0s + + + 1 + 6 + varchar(10)|0s + + + 7 + varchar(35)|0s + + + 8 + varchar(255)|0s + + + 9 + varchar(255)|0s + + + 10 + varchar(255)|0s + + + 11 + varchar(20)|0s + + + 12 + varchar(255)|0s + + + 13 + varchar(255)|0s + + + 14 + varchar(10)|0s + + + 1 + 1 + int|0s + + + 1 + 2 + tinyint|0s + + + 3 + datetime|0s + + + 4 + varchar(255)|0s + + + ID + btree + 1 + + + 1 + 1 + PRIMARY + + + 5 + 1 + 1 + int|0s + + + 2 + varchar(60)|0s + + + 3 + varchar(160)|0s + + + 4 + varchar(8)|0s + + + 'API' + 5 + enum('API', 'USER', 'ACCESS_TOKEN')|0e + + + CURRENT_TIMESTAMP + 1 + CURRENT_TIMESTAMP + 6 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + 7 + timestamp|0s + + + 8 + json|0s + + + 9 + varchar(255)|0s + + + 10 + varchar(255)|0s + + + id + btree + 1 + + + key + btree + 1 + + + user_id + btree + + + 1 + 1 + PRIMARY + + + key + + + 1 + 1 + varchar(255)|0s + + + 2 + enum('PASTE_AI_INFO')|0e + + + 3 + int|0s + + + 4 + int|0s + + + 5 + int|0s + + + 'PENDING' + 6 + enum('PENDING', 'RUNNING', 'DONE', 'FAILED')|0e + + + 7 + int|0s + + + 8 + timestamp|0s + + + 9 + timestamp|0s + + + 10 + varchar(64)|0s + + + 11 + varchar(2048)|0s + + + CURRENT_TIMESTAMP + 1 + 12 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + CURRENT_TIMESTAMP + 13 + timestamp|0s + + + key + btree + 1 + + + type +entity_id + btree + + + status +available_at + btree + + + status +lease_until + btree + + + 1 + 1 + PRIMARY + + + 11 + 1 + 1 + int|0s + + + 2 + varchar(8)|0s + + + 3 + text|0s + + + 4 + varchar(8)|0s + + + 5 + text|0s + + + CURRENT_TIMESTAMP + 1 + CURRENT_TIMESTAMP + 6 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + 7 + timestamp|0s + + + id + btree + 1 + + + key + btree + + + user_id + btree + + + 1 + 1 + PRIMARY + + + 4 + 1 + 1 + int|0s + + + 2 + text|0s + + + 3 + varchar(8)|0s + + + 4 + text|0s + + + 5 + bit(1)|0s + + + 6 + bit(1)|0s + + + CURRENT_TIMESTAMP + 1 + CURRENT_TIMESTAMP + 7 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + 8 + timestamp|0s + + + id + btree + 1 + + + user_id +received + btree + + + 1 + 1 + PRIMARY + + + 1 + 1 + int|0s + + + 2 + int|0s + + + 3 + int|0s + + + 4 + varchar(30)|0s + + + 5 + varchar(100)|0s + + + 6 + varchar(500)|0s + + + 7 + varchar(2048)|0s + + + 8 + varchar(4096)|0s + + + 9 + bit(1)|0s + + + 10 + int|0s + + + 11 + varchar(255)|0s + + + 12 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + 13 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + CURRENT_TIMESTAMP + 14 + timestamp|0s + + + paste_id + btree + 1 + + + 1 + 1 + PRIMARY + + + 7 + 1 + 1 + int|0s + + + 1 + 2 + varchar(8)|0s + + + 1 + 3 + varchar(8)|0s + + + 4 + int|0s + + + 1 + 5 + varchar(2000)|0s + + + 6 + int|0s + + + 7 + int|0s + + + CURRENT_TIMESTAMP + 1 + 8 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + CURRENT_TIMESTAMP + 9 + timestamp|0s + + + id + btree + 1 + + + paste +line_from + btree + + + paste + btree + + + user_id + btree + + + parent_id + btree + + + 1 + 1 + PRIMARY + + + 6 + 1 + 1 + int|0s + + + 2 + varchar(8)|0s + + + 3 + varchar(8)|0s + + + id + btree + 1 + + + paste +user_id + btree + + + paste + btree + + + 1 + 1 + PRIMARY + + + 590 + 1 + 1 + int|0s + + + 2 + varchar(8)|0s + + + 3 + varchar(30)|0s + + + id + btree + 1 + + + paste +tag + btree + + + 1 + 1 + PRIMARY + + + 205 + 1 + 1 + int|0s + + + 2 + varchar(8)|0s + + + 3 + text|0s + + + 4 + longtext|0s + + + 5 + varchar(8)|0s + + + 6 + bit(1)|0s + + + 7 + varchar(8)|0s + + + CURRENT_TIMESTAMP + 1 + 8 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + 9 + timestamp|0s + + + 'PASTE' + 10 + enum('PASTE', 'MULTI_PASTE')|0e + + + 11 + varchar(8)|0s + + + 'UNLISTED' + 12 + enum('UNLISTED', 'PUBLIC', 'PRIVATE')|0e + + + 13 + timestamp|0s + + + 'DATABASE' + 14 + enum('DATABASE', 'S3', 'HTTP')|0e + + + 15 + int|0s + + + 16 + bit(1)|0s + + + 17 + int|0s + + + 18 + varchar(64)|0s + + + id + btree + 1 + + + key + btree + + + user_id +folder + btree + + + user_id + btree + + + folder + btree + + + visibility + btree + + + expire_at + btree + + + storage_type + btree + + + indexed_in_elastic + btree + + + length + btree + + + hash + btree + + + 1 + 1 + PRIMARY + + + 2 + 1 + 1 + int|0s + + + 2 + int|0s + + + 3 + int|0s + + + CURRENT_TIMESTAMP + 1 + CURRENT_TIMESTAMP + 4 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + 5 + timestamp|0s + + + id + btree + 1 + + + paste_id +score + btree + + + paste_id + btree + + + 1 + 1 + PRIMARY + + + 2 + 1 + 1 + int|0s + + + 2 + varchar(8)|0s + + + 3 + varchar(8)|0s + + + 4 + text|0s + + + CURRENT_TIMESTAMP + 1 + CURRENT_TIMESTAMP + 5 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + 6 + timestamp|0s + + + id + btree + 1 + + + user_id + btree + + + target_id + btree + + + 1 + 1 + PRIMARY + + + 1 + 1 + varchar(30)|0s + + + 2 + text|0s + + + 3 + text|0s + + + 4 + text|0s + + + 5 + text|0s + + + 6 + text|0s + + + 7 + int|0s + + + tag + btree + 1 + + + 1 + 1 + PRIMARY + + + 1 + 1 + varchar(8)|0s + + + 2 + text|0s + + + 3 + varchar(33)|0s + + + 4 + text|0s + + + 5 + text|0s + + + 6 + varchar(455)|0s + + + 7 + enum('INTERAAPPS', 'GOOGLE', 'GITHUB', 'TWITCH', 'DISCORD')|0e + + + CURRENT_TIMESTAMP + 1 + CURRENT_TIMESTAMP + 8 + timestamp|0s + + + CURRENT_TIMESTAMP + 1 + 9 + timestamp|0s + + + 'USER' + 10 + enum('USER', 'ADMIN', 'BLOCKED', 'AWAITING_ACCESS')|0e + + + id + btree + 1 + + + 1 + 1 + PRIMARY + +
+
\ No newline at end of file diff --git a/backend-kt/.idea/dataSources/f90989bf-a7eb-4fb1-90d3-4e8a669d378d/storage_v2/_src_/schema/information_schema.FNRwLQ.meta b/backend-kt/.idea/dataSources/f90989bf-a7eb-4fb1-90d3-4e8a669d378d/storage_v2/_src_/schema/information_schema.FNRwLQ.meta new file mode 100644 index 00000000..1ff3db2e --- /dev/null +++ b/backend-kt/.idea/dataSources/f90989bf-a7eb-4fb1-90d3-4e8a669d378d/storage_v2/_src_/schema/information_schema.FNRwLQ.meta @@ -0,0 +1,2 @@ +#n:information_schema +! [null, 0, null, null, -2147483648, -2147483648] diff --git a/backend-kt/.idea/dataSources/f90989bf-a7eb-4fb1-90d3-4e8a669d378d/storage_v2/_src_/schema/performance_schema.kIw0nw.meta b/backend-kt/.idea/dataSources/f90989bf-a7eb-4fb1-90d3-4e8a669d378d/storage_v2/_src_/schema/performance_schema.kIw0nw.meta new file mode 100644 index 00000000..9394db14 --- /dev/null +++ b/backend-kt/.idea/dataSources/f90989bf-a7eb-4fb1-90d3-4e8a669d378d/storage_v2/_src_/schema/performance_schema.kIw0nw.meta @@ -0,0 +1,2 @@ +#n:performance_schema +! [null, 0, null, null, -2147483648, -2147483648] diff --git a/backend-kt/.idea/db-forest-config.xml b/backend-kt/.idea/db-forest-config.xml new file mode 100644 index 00000000..c5fda93b --- /dev/null +++ b/backend-kt/.idea/db-forest-config.xml @@ -0,0 +1,11 @@ + + + + . + ---------------------------------------- + 1:0:f90989bf-a7eb-4fb1-90d3-4e8a669d378d + 2:0:b37783b9-20c2-452a-8fe1-13f1a147f7ca + 3:0:c563d18b-01ac-45cd-aec1-2e7fe7a80fb9 + . + + \ No newline at end of file diff --git a/backend-kt/.idea/gradle.xml b/backend-kt/.idea/gradle.xml new file mode 100644 index 00000000..2a65317e --- /dev/null +++ b/backend-kt/.idea/gradle.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/backend-kt/.idea/kotlinc.xml b/backend-kt/.idea/kotlinc.xml new file mode 100644 index 00000000..4cb74572 --- /dev/null +++ b/backend-kt/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/backend-kt/.idea/misc.xml b/backend-kt/.idea/misc.xml new file mode 100644 index 00000000..fd385d4a --- /dev/null +++ b/backend-kt/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/backend-kt/.idea/vcs.xml b/backend-kt/.idea/vcs.xml new file mode 100644 index 00000000..6c0b8635 --- /dev/null +++ b/backend-kt/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/backend-kt/README.md b/backend-kt/README.md new file mode 100644 index 00000000..02248788 --- /dev/null +++ b/backend-kt/README.md @@ -0,0 +1,10 @@ +# Backend + + +## Local Starquery +Your can either visit http://localhost:8081/ once the docker-compose is running or add the url as a server in the [StarQuery client](https://starquery.app/). + +Field|Value +---|--- +username|admin +password|ADMIN_pastefy \ No newline at end of file diff --git a/backend-kt/TODOS.md b/backend-kt/TODOS.md new file mode 100644 index 00000000..12e310f7 --- /dev/null +++ b/backend-kt/TODOS.md @@ -0,0 +1,339 @@ +# Backend Rewrite Roadmap + +Ziel: `backend-kt` ersetzt `backend` ohne Verhaltensänderungen für bestehende Clients. Neben Feature-Parity ist 1:1 API-Parity erforderlich: HTTP-Methode, Pfad, Query-Parameter, Header, Statuscode, Content-Type, JSON-Feldname, Null-Verhalten, Defaultwert und Response-Struktur müssen kompatibel bleiben. Request- und Response-JSON verwendet verbindlich `snake_case`; `/v3/api-docs` muss denselben Vertrag dokumentieren. + +## P0: API-Vertrag stabilisieren + +- [ ] Für alle API-Responses dedizierte DTOs verwenden. Keine JPA-Entities direkt serialisieren, insbesondere nicht bei Admin-Usern, Notifications und Tag-Listings. +- [ ] `ExceptionResponse` exakt kompatibel machen: Das alte Backend liefert `success`, `exception`, `error` und `exists`, aber kein zusätzliches `message`-Feld. +- [ ] Validierungsfehler auf das Legacy-Fehlerformat abbilden. Prüfen, welcher Exception-Name und welcher HTTP-Status bei ungültigen Requests bisher zurückgegeben wurde. +- [ ] `CreatePasteResponse.success` standardmäßig wieder `false` setzen und nur nach erfolgreichem Speichern auf `true`. +- [ ] `PasteResponse.exists` standardmäßig wieder `false` setzen und nur für vorhandene Pastes auf `true`. +- [ ] Zeitwerte exakt kompatibel serialisieren. Das alte Backend verwendet bei vielen DTOs `Timestamp.toString()` statt ISO-8601. +- [ ] Nullable-Felder und ausgelassene Felder gegen das alte Gson-Verhalten prüfen. Jackson darf keine zusätzlichen `null`-Felder oder leeren Collections ausliefern, wenn sie vorher fehlten. +- [x] OAuth-Routen auf den Legacy-Pfad zurückführen: `/api/v2/auth/oauth2/{provider}` und `/api/v2/auth/oauth2/{provider}/callback`. Der aktuelle Kotlin-Pfad `/auth/oauth2/...` ist nicht API-kompatibel. +- [ ] `Server: InteraApps-Pastefy` wieder für Responses setzen. +- [x] Pagination-Response-Header `PAGINATION_LIMIT` und `PAGINATION_PAGE` wieder ausliefern, wo das alte Backend sie gesetzt hat. +- [ ] Rate-Limiter-Defaults korrigieren: Der allgemeine Legacy-Rate-Limiter war nur aktiv, wenn `ratelimiter.millis` konfiguriert war. Der Thumbnail-Limiter war dagegen immer aktiv. + +## P0: Listenabfragen und Response-Mapping + +- [x] Einen gemeinsamen Mapper `Paste -> PasteResponse` implementieren, inklusive `raw_url`, Tags, User, Starred-Status, optionaler AI-Analyse und Inhaltskürzung. +- [x] `shorten_content=true` exakt nachbauen: Inhalte über 303 Zeichen auf 300 Zeichen plus `...` kürzen. +- [x] `with_ai_analysis=true` exakt nachbauen und `PasteAiInfoResponse` nur dann einbetten. +- [x] Einen gemeinsamen Listen-Query-Service für SQL und Elasticsearch implementieren. +- [x] Legacy-Query-Parameter unterstützen: `page`, `page_limit`, `search`, `sort`, `filter`, `filters`, `filter_tags`, `shorten_content`. +- [x] Das konfigurierte Maximum `PASTEFY_PAGINATION_PAGE_LIMIT` nachbauen. +- [x] Legacy-Sichtbarkeitsregeln nachbauen: Nicht-Admins sehen öffentliche Pastes sowie eigene und selbst markierte Pastes; Admins dürfen ungefiltert lesen. +- [ ] SQL- und Elasticsearch-Ergebnisse auf identische Reihenfolge, Filterung und DTOs bringen. +- [x] Elasticsearch-Volltextsuche mit Legacy-Gewichtung nachbauen: `title^3`, `content`, `user.name`, `user.uniqueName`. +- [x] Elasticsearch-Filter für Tags, Sichtbarkeit, Verschlüsselung, User, Stars, Folder und Zeitwerte nachbauen. +- [x] SQL-Fallback für `starredBy`, Tagfilter und Sortierung nach `engagementScore` nachbauen. +- [ ] Prüfen, ob `PASTEFY_LIST_PASTES` weiterhin benötigt wird, und das Legacy-Verhalten für anonyme Listenabfragen übernehmen. + +## Paste-API + +### `POST /api/v2/paste` + +- [x] Handler implementieren. +- [x] Folder nur übernehmen, wenn er dem eingeloggten User gehört. +- [x] `title`, `content`, `encrypted`, `type`, `visibility`, `folder`, `expireAt`, `forkedFrom` und `tags` Legacy-kompatibel speichern. +- [x] `expireAt` wie bisher nur ab mindestens 16 Zeichen parsen. +- [x] Bei Forks eines öffentlichen Pastes asynchron `+10` Engagement auf dem Ursprungspaste buchen. +- [x] Tags speichern und Tag-Listing-Zähler aktualisieren. +- [ ] `ai=true` nachbauen: Für nicht verschlüsselte Pastes asynchron AI-Tags, Dateiname und Dateiendung ergänzen. +- [x] Response exakt als `CreatePasteResponse` mit eingebettetem `PasteResponse` ausliefern. + +### `GET /api/v2/paste` + +- [x] Handler über den gemeinsamen Listen-Query-Service implementieren. +- [ ] Permission `pastes:read`, Sichtbarkeitsregeln, Pagination, Filter, Suche und Elasticsearch-Fallback übernehmen. + +### `GET /api/v2/paste/{id}` + +- [x] Handler implementieren. +- [x] Private Pastes nur dem Owner zugänglich machen. +- [x] Öffentliche Fremdaufrufe asynchron bewerten: `+5` für anonyme Frontend-Aufrufe, `+4` für eingeloggte Frontend-Aufrufe, sonst `+2`. +- [x] Bei `from_frontend=true` PAGE-Analytics tracken. +- [x] Query-Parameter `shorten_content` und `with_ai_analysis` berücksichtigen. +- [x] Starred-Status und Public-User wie im alten DTO einbetten. + +### `PUT /api/v2/paste/{id}` + +- [x] Handler implementieren. +- [x] Nur Owner oder Admin zulassen. +- [x] Teilupdates für Titel, Inhalt, Folder, Typ, Verschlüsselung, Sichtbarkeit und Ablaufzeit übernehmen. +- [x] Tag-Diff speichern, entfernte Tags löschen und Tag-Listing-Zähler aktualisieren. +- [x] Cache invalidieren, S3-Speicherung und Elasticsearch-Reindexing auslösen. + +### `DELETE /api/v2/paste/{id}` + +- [x] Handler implementieren. +- [x] Nur Owner oder Admin zulassen. +- [x] Kommentare, Tags, Stars, Engagement, AI-Info und Hintergrundjobs löschen. +- [x] S3-Objekt, Redis-Cache und Elasticsearch-Dokument entfernen. + +### Stars, Freunde und AI + +- [x] `POST /api/v2/paste/{id}/star` implementieren. +- [x] `DELETE /api/v2/paste/{id}/star` implementieren. +- [x] Elasticsearch-Star-Felder nach Änderungen aktualisieren. +- [x] `POST /api/v2/paste/{id}/friend` bewusst Legacy-kompatibel behandeln. Das alte Backend wirft hier `RuntimeException("NOT IMPLEMENTED")`; entscheiden, ob exakt beibehalten oder versioniert entfernt wird. +- [x] `POST /api/v2/paste/{id}/ai-analysis` implementieren: Admin, Permission, Feature-Flag, Accessible-Paste-Prüfung und erzwungenes Enqueue. + +## Raw-, Multipart- und Thumbnail-Endpunkte + +### `GET /{id}/raw` + +- [x] Handler implementieren. +- [x] Content-Type exakt `text/plain; charset=utf-8` setzen. +- [x] Fehlende Pastes legacy-kompatibel als HTTP 404 mit Text `404 - Paste not found` beantworten. +- [x] Private-Paste-Prüfung übernehmen. +- [x] Öffentliche Raw-Aufrufe asynchron mit `+1` Engagement bewerten. +- [x] RAW-Analytics tracken. +- [x] `part=` für `MULTI_PASTE` unterstützen und den passenden Dateiinhalt zurückgeben. +- [x] DATABASE-, S3- und Redis-Inhalte korrekt ausgeben. + +### `POST /` und `POST /api/asciicasts` + +- [x] Multipart-Handler implementieren. +- [x] Alle Parts lesen; ein File wird `PASTE`, mehrere Files werden als JSON-Liste `MULTI_PASTE`. +- [x] Owner übernehmen, wenn eingeloggt. +- [x] Für `/api/asciicasts` Titel `.cast`, Tags `asciicast` und `asciinema` setzen. +- [x] Legacy-Textantworten exakt übernehmen, einschließlich abschließendem Newline. +- [x] Für `/api/asciicasts` die historische JSON-Struktur mit `url` und `message` sowie JSON-Content-Type liefern. +- [x] Fehlende Dateien mit `No file given. curl -F 'f=@filename' pastefy.app\n` beantworten. + +### `GET /{id}/thumbnail.png` + +- [x] Thumbnail-Handler implementieren. +- [x] SVG-Template und Fonts in `backend-kt/src/main/resources` übernehmen. +- [x] Nur nicht-private, nicht-verschlüsselte Pastes rendern. +- [x] Titel und maximal fünf Inhaltszeilen XML-sicher einsetzen. +- [x] PNG mit Batik erzeugen und streamen. +- [x] Image-Rate-Limit exakt `3` Requests pro `5000 ms` beibehalten. + +## Kommentare + +- [x] `GET /api/v2/paste/{pasteId}/comments` implementieren. +- [x] `page`, `page_limit` und optional `line` übernehmen; Defaults `1`, `10`, Maximum `30`. +- [x] Nur Top-Level-Kommentare laden und direkte Replies einbetten. +- [x] `GET /api/v2/paste/{pasteId}/comments/markers` implementieren. +- [x] Marker nach Zeile gruppieren, maximal zwei eindeutige Profile einbetten und `additionalProfiles` korrekt berechnen. +- [x] `POST /api/v2/paste/{pasteId}/comments` implementieren. +- [x] Content trimmen und auf maximal `2000` Zeichen begrenzen. +- [x] Parent nur akzeptieren, wenn er zum selben Paste gehört. +- [x] `lineFrom` und `lineTo` validieren; keine negativen Werte, kein inverser Bereich, maximale Spannweite `1000`. +- [x] `DELETE /api/v2/paste/{pasteId}/comments/{commentId}` implementieren. +- [x] Autor, Paste-Owner oder Admin löschen lassen und Replies wie bisher behandeln. + +## Folder-API + +- [x] `POST /api/v2/folder` implementieren. +- [x] Parent nur setzen, wenn er existiert und demselben User gehört. +- [x] `GET /api/v2/folder` implementieren: User-Sichtbarkeit, `page`, `page_limit` und `search`. +- [ ] Legacy-`filter` für `GET /api/v2/folder` übernehmen. +- [x] `GET /api/v2/folder/{id}` implementieren. +- [x] `hide_children` exakt übernehmen und rekursive Folder-/Paste-Strukturen kompatibel serialisieren. +- [x] Private Pastes im Folder nur für den Owner einbetten. +- [x] `DELETE /api/v2/folder/{id}` implementieren. +- [x] Nur Owner oder Admin löschen lassen. +- [x] Rekursives Löschen von Unterordnern und enthaltenen Pastes nachbauen. + +## User-, Admin- und Notification-API + +### User + +- [x] `GET /api/v2/user` implementieren, inklusive anonymem `loggedIn=false`-Response. +- [x] `UserResponse` exakt mappen: `id`, `name`, `displayName`, feste Legacy-Farbe `#f52966`, `profilePicture`, `authType`, `authTypes`, `type`. +- [x] `GET /api/v2/user/overview` implementieren: Root-Pastes seitenweise, Root-Folder mit optionalen Kindern. +- [x] `GET /api/v2/user/folders` implementieren und `hide_children`, `hide_sub_children`, `hide_pastes` übernehmen. +- [x] `GET /api/v2/user/pastes` über den gemeinsamen Listen-Query-Service implementieren. +- [x] `GET /api/v2/user/sharedpastes` implementieren. Der Endpunkt ist deprecated, gehört aber zur API-Parity. +- [x] Verwaiste Shared-Paste-Einträge beim Lesen wie bisher löschen. +- [x] `GET /api/v2/user/starred-pastes` implementieren. + +### API Keys + +- [x] `POST /api/v2/user/keys` implementieren und nur `API`-Keys erzeugen. +- [x] `GET /api/v2/user/keys` implementieren und nur Key-Strings für den aktuellen User liefern. +- [x] `DELETE /api/v2/user/keys/{key}` implementieren und nur eigene Keys entfernen. + +### Notifications + +- [x] `POST /api/v2/user/notification` Legacy-kompatibel implementieren. Das alte Backend liefert aktuell nur `success=false`; keine neue Semantik unbemerkt einführen. +- [x] `GET /api/v2/user/notification` implementieren. +- [x] Query-Flags `not_received` und `not_read` übernehmen. +- [x] Gelesene Notifications nach dem Abruf als `received=true` markieren. +- [x] `GET /api/v2/user/notification/readall` implementieren und `received=true`, `already_read=true` setzen. +- [ ] Notification-Response über DTO stabilisieren und Entity-Felder gegen den Legacy-Payload prüfen. + +### Admin + +- [x] `GET /api/v2/admin/users` implementieren: Pagination, Suche und Sortierung nach `created_at`. +- [ ] Legacy-`filter` für `GET /api/v2/admin/users` übernehmen. +- [x] `GET /api/v2/admin/users/{id}` implementieren. +- [ ] Legacy-Null-/Fehlerverhalten für `GET /api/v2/admin/users/{id}` prüfen. +- [x] `DELETE /api/v2/admin/users/{id}` implementieren, inklusive abhängiger Daten. +- [x] `PUT /api/v2/admin/users/{id}` implementieren: partielle Updates für `name`, `uniqueName`, `type`. +- [ ] Admin-User-Response über ein kompatibles DTO absichern, damit keine zusätzlichen Entity-Felder erscheinen. + +## App-, Stats-, Public- und SEO-Endpunkte + +### App und Stats + +- [x] `GET /api/v2/app/info` implementieren. +- [x] Konfiguration für `customLogo`, `customName`, `customFooter`, `encryptionIsDefault`, Login-Flags und Public-Pastes-Flag ergänzen. +- [x] `aiEnabled` und `analyticsEnabled` anhand tatsächlich verfügbarer Services liefern. +- [x] `GET /api/v2/app/stats` implementieren. +- [x] `PASTEFY_PUBLIC_STATS` nachbauen; andernfalls nur Admin oder Permission `stats:read`. +- [x] Stats zählen: Pastes, eingeloggte Pastes, User, Tags, Folder, S3-Pastes und Elasticsearch-Dokumente. +- [x] Snake-Case-Feldnamen ausliefern, insbesondere `s3_pastes`. + +### Public + +- [x] `GET /api/v2/public-pastes` implementieren: nur `PUBLIC`. +- [x] `GET /api/v2/public-pastes/trending` implementieren: nur öffentliche, unverschlüsselte Pastes, Sortierung nach Engagement. +- [x] Bei Query-Flag `trending` nur Pastes der letzten vier Tage berücksichtigen. +- [x] `GET /api/v2/public-pastes/latest` implementieren: nur öffentliche Pastes, Sortierung nach `createdAt`. +- [x] `GET /api/v2/public/user/{name}` implementieren und bei unbekanntem User 404 liefern. +- [x] `GET /api/v2/public/tags` implementieren: Sortierung nach `pasteCount`, Pagination und Suche. +- [ ] Legacy-`filter` für `GET /api/v2/public/tags` übernehmen. +- [x] `GET /api/v2/public/tags/{tag}` implementieren: Tag kürzen, bei Bedarf erstellen und zurückgeben. + +### SEO und statisches Frontend + +- [x] SSR-Renderer für `GET /{id}` portieren. +- [x] SSR-Renderer für `GET /tags/{tag}` portieren. +- [x] SPA-Assets, `static/index.html`, Thumbnail-Template und Fonts in den Spring-Boot-Resources bereitstellen. +- [x] SEO-Platzhalter ``, ``, `` und `<div id="app">` 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=<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/<user-or-anonymous>/<key[0..2]>/<key[2..4]>/<key>/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:<id>` und `paste:<id>: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/<name>` 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<Test> { + 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<String>) { + runApplication<ApiApplication>(*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<RateLimited>(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<Authenticated>(handler) && auth == null) throw AuthenticationException() + if (has<AdminRoute>(handler) && auth?.user?.type != User.Type.ADMIN) throw PermissionsDeniedException() + if (has<RejectBlocked>(handler) && auth?.user?.type == User.Type.BLOCKED) throw BlockedException() + if (has<RejectAwaitingAccess>(handler) && auth?.user?.type == User.Type.AWAITING_ACCESS) throw AwaitingAccessException() + if (has<LoginRequiredForRead>(handler) && properties.loginRequiredRead && auth?.user?.roleCheck() != true) { + throw AuthenticationException() + } + if (has<LoginRequiredForCreate>(handler) && properties.loginRequiredCreate && auth?.user?.roleCheck() != true) { + throw AuthenticationException() + } + if (has<PublicPastesEnabled>(handler) && !properties.publicPastesEnabled) throw FeatureDisabledException() + annotation<RequiresPermission>(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 <reified T : Annotation> has(handler: HandlerMethod): Boolean = annotation<T>(handler) != null + + private inline fun <reified T : Annotation> 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<String> = [], +) + +@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<String, OAuth2Provider> = buildList<OAuth2Provider> { + 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<String> = providers.keys + + private fun List<String>.orDefault(vararg defaults: String): List<String> = 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<String, String> = emptyMap()): JsonNode = + execute(Request.Builder().url(url).applyHeaders(headers).get().build()) + + fun postForm(url: String, fields: Map<String, String>, headers: Map<String, String> = 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<String, String>, headers: Map<String, String> = 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<String, String>) = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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, String>): 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<String> = 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<String, Window>() + + 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<Long>() + + 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<RedisConnectionFactory>, + ): 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<String> = emptyList(), + ) { + val enabled: Boolean get() = clientId.isNotBlank() && clientSecret.isNotBlank() + } + + class OidcClient( + clientId: String = "", + clientSecret: String = "", + scopes: List<String> = 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<HandlerMethodArgumentResolver>) { + 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<PasteAI>, + private val analyticsProvider: ObjectProvider<AnalyticsService>, +) { + @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<FolderResponse> = + 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<String> { + 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<String> { + 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<String> { + 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<User> { + 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<User> { 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<AnalyticsService>, + 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<Void> { + 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<Void> { + 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<PasteCommentResponse> = + 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<PasteCommentMarkerResponse> = + 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<AnalyticsService>, + private val aiProvider: ObjectProvider<PasteAIInfoService>, +) { + @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<PasteResponse> = 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<AnalyticsService>, + private val objectMapper: ObjectMapper, +) { + @GetMapping( + "/{id}/raw", + produces = [MediaType.TEXT_PLAIN_VALUE] + ) + @LoginRequiredForRead + fun getRaw( + @PathVariable id: String, + @CurrentUser user: User?, + request: HttpServletRequest, + ): ResponseEntity<String> { + 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<List<MultiPastesElement>>() {}) + .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<String> = + 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<PasteResponse> = + queries.list(request, response, null, guarded = false, visibility = PasteVisibility.PUBLIC) + + @GetMapping("/trending") + fun getTrendingPastes(request: HttpServletRequest, response: HttpServletResponse): List<PasteResponse> = + queries.trending(request, response) + + @GetMapping("/latest") + fun getLatestPastes(request: HttpServletRequest, response: HttpServletResponse): List<PasteResponse> = + 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<TagListing> { + val pageable = PageRequest.of( + page.coerceAtLeast(1) - 1, + pageLimit.coerceIn(1, 100), + Sort.by(Sort.Direction.DESC, "pasteCount"), + ) + val searchSpecification = Specification<TagListing> { 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<String> { + 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<String> { + 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<String> { + 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 { + "<p>By <a href=\"${seo.escapeHtml(it.profileUrl)}\">${seo.escapeHtml(it.displayName)} (@${seo.escapeHtml(it.username)})</a></p>" + }.orEmpty() + + val aiHtml = + aiDescription?.takeIf(String::isNotBlank)?.let { "<h2>Description</h2><p>${seo.escapeHtml(it)}</p>" } + .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."), + "<pre><code>${seo.escapeHtml(preview)}</code></pre>", + 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<String> { + 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<String> { + 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<String> = + 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<Notification> = + 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<FolderResponse> = + foldersForUser(request, user) + + @GetMapping("/pastes") + @Authenticated + @RejectAwaitingAccess + @RejectBlocked + @RequiresPermission("pastes:read") + fun getPastes( + request: HttpServletRequest, + response: HttpServletResponse, + @CurrentUser user: User, + @CurrentAuthKey authKey: AuthKey + ): List<PasteResponse> = + 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<PasteResponse> { + 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<PasteResponse> = + queries.list(request, response, user, guarded = false, starredBy = user.id) + + private fun foldersForUser(request: HttpServletRequest, user: User): List<FolderResponse> = + 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<SeriesPoint> = mutableListOf(), + val breakdown: MutableList<BreakdownPoint> = 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<String, String> = 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<String, String> = 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<String> = emptyList(), + val appScopeList: List<String> = 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<FolderResponse>? = null, + val pastes: List<PasteResponse>? = 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<String>? = 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<String>? = 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<PasteCommentResponse> = emptyList(), +) + +data class PasteCommentMarkerResponse( + val line: Int, + val profiles: List<PublicUserDto> = 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<AIWarning>? = null, + val tags: List<String>? = 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<String>? = 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<String> = emptyList(), + val type: User.Type? = null, +) + +data class UserPastesResponse( + val pastes: List<PasteResponse> = emptyList(), + val folder: List<FolderResponse> = 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<String>? = 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<String>().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<String>? = null, + @Convert(converter = AIWarningListJsonConverter::class) + @Column(length = 4096) var warningsJson: MutableList<AIWarning>? = 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<MutableList<String>?, String?> { + override fun convertToDatabaseColumn(attribute: MutableList<String>?): String? = + attribute?.let(MAPPER::writeValueAsString) + + override fun convertToEntityAttribute(dbData: String?): MutableList<String>? = + dbData?.takeIf(String::isNotBlank) + ?.let { MAPPER.readValue(it, object : TypeReference<MutableList<String>>() {}) } +} + +@Converter +class AIWarningListJsonConverter : AttributeConverter<MutableList<AIWarning>?, String?> { + override fun convertToDatabaseColumn(attribute: MutableList<AIWarning>?): String? = + attribute?.let(MAPPER::writeValueAsString) + + override fun convertToEntityAttribute(dbData: String?): MutableList<AIWarning>? = + dbData?.takeIf(String::isNotBlank) + ?.let { MAPPER.readValue(it, object : TypeReference<MutableList<AIWarning>>() {}) } +} + +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<out OAuth2Provider>, + 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<out OAuth2Provider> + ): 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<ExceptionResponse> = + ResponseEntity.status(exception.status).body(exception.toResponse()) + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidationException(exception: MethodArgumentNotValidException): ResponseEntity<ExceptionResponse> { + 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<ExceptionResponse> { + 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<String> = emptyList(), + val description: String = "", + val systemWarnings: List<GeneratedWarning> = 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<VisitEvent>(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<String>): Map<String, Long> { + 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<VisitEvent>() + 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<String> = emptyList(), + val starCount: Int = 0, + val starredBy: List<String> = 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<PasteResponse> { + 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<String>): 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<String> = pasteTagRepository.findAllByPaste(paste.key).map { it.tag } + + private fun starredBy(paste: Paste): List<String> = 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<ElasticMigration>, +) { + @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<BackgroundJobHandler>, + 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<String>? = 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<String>, + ) { + 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<AuthKey, Int> { + fun findByKey(key: String): AuthKey? + fun findAllByUserIdAndType(userId: String, type: AuthKey.Type): List<AuthKey> + 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<BackgroundJob, String> { + 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<Folder, Int>, JpaSpecificationExecutor<Folder> { + fun findByKey(key: String): Folder? + fun findAllByUserId(userId: String): List<Folder> + fun findAllByUserIdAndParentIsNull(userId: String): List<Folder> + fun findAllByParent(parent: String): List<Folder> + fun findAllByParentOrderByUpdatedAtDesc(parent: String): List<Folder> + 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<Notification, Int> { + fun findAllByUserId(userId: String): List<Notification> + fun findAllByUserIdAndReceived(userId: String, received: Boolean): List<Notification> + fun findAllByUserIdAndAlreadyRead(userId: String, alreadyRead: Boolean): List<Notification> + fun findAllByUserIdAndReceivedAndAlreadyRead( + userId: String, + received: Boolean, + alreadyRead: Boolean + ): List<Notification> + + 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<PasteAIInfo, Int> 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<PasteComment, Int> { + fun findAllByPaste(paste: String): List<PasteComment> + fun findAllByPasteAndParentIdIsNullOrderByCreatedAtDesc(paste: String, pageable: Pageable): List<PasteComment> + fun findAllByPasteAndParentIdIsNullAndLineFromOrderByCreatedAtDesc( + paste: String, + lineFrom: Int, + pageable: Pageable + ): List<PasteComment> + + fun findAllByPasteAndLineFromIsNotNullOrderByCreatedAtAsc(paste: String): List<PasteComment> + fun findAllByParentIdOrderByCreatedAtAsc(parentId: Int): List<PasteComment> + + @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<String>): List<PasteCountProjection> + + 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<Paste, Int>, JpaSpecificationExecutor<Paste> { + fun findByKey(key: String): Paste? + + fun existsByKey(key: String): Boolean + + fun findAllByUserId(userId: String): List<Paste> + fun findAllByFolderOrderByUpdatedAtDesc(folder: String): List<Paste> + fun findAllByUserIdAndFolderIsNullOrderByUpdatedAtDesc(userId: String, pageable: Pageable): List<Paste> + fun findAllByVisibilityAndEncryptedFalseOrderByCreatedAtDesc( + visibility: PasteVisibility, + pageable: Pageable, + ): List<Paste> + + fun findAllByUserIdAndVisibilityAndEncryptedFalseOrderByCreatedAtDesc( + userId: String, + visibility: PasteVisibility, + pageable: Pageable, + ): List<Paste> + + fun findAllByKeyIn(keys: Collection<String>): List<Paste> + fun findAllByExpireAtBeforeAndExpireAtIsNotNull(expireAt: java.time.Instant): List<Paste> + 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<Paste> + + @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<PasteStar, Int> { + + 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<String>): List<PasteCountProjection> + + fun findAllByPaste(paste: String): List<PasteStar> + fun findAllByUserId(userId: String): List<PasteStar> + + 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<PasteTag, Int> { + fun findAllByPaste(paste: String): List<PasteTag> + 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<PublicPasteEngagement, Int> { + fun findFirstByPasteIdOrderByIdAsc(pasteId: Int): PublicPasteEngagement? + fun deleteByPasteId(pasteId: Int) + fun findAllByScoreGreaterThanEqual(score: Int): List<PublicPasteEngagement> + + @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<SharedPaste, Int> { + fun findAllByTargetId(targetId: String): List<SharedPaste> + fun findAllByTargetIdOrderByUpdatedAtDesc(targetId: String, pageable: Pageable): List<SharedPaste> + 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<TagListing, String>, JpaSpecificationExecutor<TagListing> { + fun findAllByOrderByPasteCountDesc(pageable: Pageable): List<TagListing> + fun findAllByTagContainingIgnoreCaseOrDisplayNameContainingIgnoreCaseOrderByPasteCountDesc( + tag: String, + displayName: String, + pageable: Pageable, + ): List<TagListing> +} 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<User, String>, JpaSpecificationExecutor<User> { + + 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<FolderResponse> { + 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<Folder> { root, _, builder -> + val predicates = mutableListOf<Predicate>() + filteredUserId?.let { predicates += builder.equal(root.get<String>("userId"), it) } + search?.let { predicates += builder.like(builder.lower(root.get("name")), "%${it.lowercase()}%") } + request.getParameter("parent")?.let { predicates += builder.equal(root.get<String>("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("<!--CUSTOM_HEADERS-->", properties.customHeader) + .replace("<!--CUSTOM_BODY-->", properties.customBody) + }.onFailure { + LOGGER.warn("Unable to load static/index.html for frontend serving", it) + }.getOrNull() + + fun frontend(): ResponseEntity<String> { + 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<Notification> { + 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<ElasticPasteService>, + private val aiProvider: ObjectProvider<PasteAIInfoService>, +) { + @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<String> = 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<String>) { + 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<PasteCommentResponse> { + 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<PasteCommentMarkerResponse> { + 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<AnalyticsService>, + private val redisProvider: ObjectProvider<StringRedisTemplate>, + private val objectMapper: ObjectMapper, + private val properties: PastefyProperties, +) { + fun getMetrics(pasteKeys: Collection<String>): Map<String, PasteMetrics> { + 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<String>): Map<String, PasteMetrics> { + 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<String>): Map<String, PasteMetrics> { + 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<String, PasteMetrics>) { + 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<ElasticPasteQueryAdapter>, +) { + fun list( + request: HttpServletRequest, + response: HttpServletResponse, + user: User?, + guarded: Boolean = true, + visibility: PasteVisibility? = null, + encrypted: Boolean? = null, + userId: String? = null, + starredBy: String? = null, + ): List<PasteResponse> { + 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<PasteResponse> { + 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<PasteResponse> = + 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<PasteRedisCacheService>, + private val s3Provider: ObjectProvider<S3PasteService>, + private val elasticProvider: ObjectProvider<ElasticPasteService>, +) { + 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<ElasticPasteService>, +) { + @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<String>, + pageFactory: () -> SeoRenderer.SeoPage?, + ): ResponseEntity<String> = + 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<String?> { 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<Paste>, 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<PasteResponse>, 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("<article>") + 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("<p>$tagLinks</p>") + append(aiDescription) + append("</article>") + } + } + + 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("<article>") + 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("<p>$tagLinks</p>") + append(aiDescription) + append("</article>") + } + } + + 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<PasteResponse> = + queries.search(publicQuery(limit = limit, sort = "createdAt")) + + fun recentTrending(limit: Int): List<PasteResponse> = + 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<PasteResponse> = + queries.search(publicQuery(limit = limit, sort = "engagementScore")) + + fun publicByUser(userId: String, limit: Int): List<PasteResponse> = + queries.search( + publicQuery( + limit = limit, + sort = "createdAt", + additionalFilters = listOf(LegacyFieldFilter("userId", LegacyFilterOperator.EQ, userId)), + ), + ) + + private fun publicQuery( + limit: Int, + sort: String, + additionalFilters: List<LegacyFilter> = 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 = + "<main id=\"seo-content\">${parts.joinToString("")}</main>" + + fun section(title: String, vararg parts: String): String = + "<section><h2>${escapeHtml(title)}</h2>${parts.joinToString("")}</section>" + + 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 "<h$safeLevel$attrs>${escapeHtml(text)}</h$safeLevel>" + } + + fun paragraph(text: String): String = "<p>${escapeHtml(text)}</p>" + + fun link(url: String, label: String, cssClass: String? = null): String { + val classAttribute = cssClass?.takeIf(String::isNotBlank)?.let { " class=\"${escapeHtml(it)}\"" }.orEmpty() + return "<a href=\"${escapeHtml(url)}\"$classAttribute>${escapeHtml(label)}</a>" + } + + fun unorderedList(items: Collection<String>, cssClass: String? = null): String { + if (items.isEmpty()) return "" + val classAttribute = cssClass?.takeIf(String::isNotBlank)?.let { " class=\"${escapeHtml(it)}\"" }.orEmpty() + return items.joinToString(prefix = "<ul$classAttribute>", postfix = "</ul>") { "<li>$it</li>" } + } + + fun definitionList(items: Map<String, String>): String { + if (items.isEmpty()) return "" + return items.entries.joinToString(prefix = "<dl>", postfix = "</dl>") { + "<dt>${escapeHtml(it.key)}</dt><dd>${escapeHtml(it.value)}</dd>" + } + } + + 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 + "</div>" + } + + require(SEO_CONTENT_REPLACEMENT in prepared) { + "Frontend mount is missing in static/index.html" + } + + return if (TITLE.containsMatchIn(prepared)) { + TITLE.replaceFirst(prepared, "<title>$TITLE_REPLACEMENT") + } else { + prepared.replace("", "$TITLE_REPLACEMENT") + } + } + + private fun tagsToHtml(page: SeoPage): String = buildString { + page.seoTags.forEach { (name, value) -> appendMeta("name", name, value) } + append("") + page.openGraphTags.forEach { (name, value) -> appendMeta("property", name, value) } + page.twitterTags.forEach { (name, value) -> appendMeta("name", name, value) } + } + + private fun StringBuilder.appendMeta(attribute: String, name: String, value: String) { + append("") + } + + fun absoluteUrl(pathOrUrl: String): String { + if (pathOrUrl.startsWith("http://", true) || pathOrUrl.startsWith("https://", true)) return pathOrUrl + val configured = properties.serverName.trim().ifBlank { "http://localhost" } + + val base = if (configured.startsWith("http://", true) || configured.startsWith( + "https://", + true + ) + ) configured else "https://$configured" + + return base.trimEnd('/') + "/" + pathOrUrl.trimStart('/') + } + + inner class SeoPage( + val canonicalUrl: String, + val title: String, + description: String, + ) { + val seoTags = linkedMapOf("description" to description) + + val openGraphTags = linkedMapOf( + "og:site_name" to "Pastefy", + "og:type" to "website", + "og:title" to title, + "og:description" to description, + "og:url" to canonicalUrl, + ) + + val twitterTags = linkedMapOf( + "twitter:card" to "summary", + "twitter:title" to title, + "twitter:description" to description, + "twitter:url" to canonicalUrl, + ) + + var content: String = "" + private set + + fun content(value: String) = apply { content = value } + + fun meta(name: String, value: String?) = apply { + value?.let { seoTags[name] = it } + } + + fun openGraph(name: String, value: String?) = apply { + value?.let { openGraphTags[name] = it } + } + + fun twitter(name: String, value: String?) = apply { + value?.let { twitterTags[name] = it } + } + + fun type(value: String) = openGraph("og:type", value) + + fun image(value: String?) = apply { + value?.trim()?.takeIf(String::isNotEmpty)?.let { + val url = absoluteUrl(it) + openGraphTags["og:image"] = url + twitterTags["twitter:card"] = "summary_large_image" + twitterTags["twitter:image"] = url + } + } + } + + companion object { + private const val META_START_TAG = "" + private const val META_END_TAG = "" + private const val META_REPLACEMENT = "" + private const val TITLE_REPLACEMENT = "" + private const val SEO_CONTENT_REPLACEMENT = "" + private val TITLE = Regex("(?is)]*>.*?") + private val APP_MOUNT = Regex("(?is)(]*>)\\s*") + private val LOGGER = LoggerFactory.getLogger(SeoRenderer::class.java) + } +} diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/StatsService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/StatsService.kt new file mode 100644 index 00000000..531fb374 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/StatsService.kt @@ -0,0 +1,30 @@ +package de.interaapps.pastefy.service + +import de.interaapps.pastefy.dto.app.StatsResponse +import de.interaapps.pastefy.enums.StorageType +import de.interaapps.pastefy.infrastructure.elastic.ElasticPasteService +import de.interaapps.pastefy.repositories.FolderRepository +import de.interaapps.pastefy.repositories.PasteRepository +import de.interaapps.pastefy.repositories.TagListingRepository +import de.interaapps.pastefy.repositories.UserRepository +import org.springframework.beans.factory.ObjectProvider +import org.springframework.stereotype.Service + +@Service +class StatsService( + private val pasteRepository: PasteRepository, + private val userRepository: UserRepository, + private val tagListingRepository: TagListingRepository, + private val folderRepository: FolderRepository, + private val elasticProvider: ObjectProvider, +) { + fun get(): StatsResponse = StatsResponse( + createdPastes = pasteRepository.count().toInt(), + loggedInPastes = pasteRepository.countByUserIdIsNotNull().toInt(), + userCount = userRepository.count().toInt(), + tagCount = tagListingRepository.count().toInt(), + folderCount = folderRepository.count().toInt(), + indexedPastes = elasticProvider.ifAvailable?.count(), + s3Pastes = pasteRepository.countByStorageType(StorageType.S3).toInt(), + ) +} diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/TagListingService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/TagListingService.kt new file mode 100644 index 00000000..2745ef8f --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/TagListingService.kt @@ -0,0 +1,28 @@ +package de.interaapps.pastefy.service + +import de.interaapps.pastefy.entities.TagListing +import de.interaapps.pastefy.repositories.PasteTagRepository +import de.interaapps.pastefy.repositories.TagListingRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class TagListingService( + private val tagListingRepository: TagListingRepository, + private val pasteTagRepository: PasteTagRepository, +) { + @Transactional + fun getOrCreate(rawTag: String): TagListing { + val tag = rawTag.take(30) + return tagListingRepository.findById(tag).orElseGet { + tagListingRepository.save(TagListing(tag = tag)) + } + } + + @Transactional + fun updateCount(rawTag: String): TagListing { + val listing = getOrCreate(rawTag) + listing.pasteCount = pasteTagRepository.countByTag(listing.tag) + return tagListingRepository.save(listing) + } +} diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/UserService.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/UserService.kt new file mode 100644 index 00000000..b0823e93 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/UserService.kt @@ -0,0 +1,130 @@ +package de.interaapps.pastefy.service + +import de.interaapps.pastefy.entities.User +import de.interaapps.pastefy.dto.folder.FolderResponse +import de.interaapps.pastefy.entities.Folder +import de.interaapps.pastefy.entities.Notification +import de.interaapps.pastefy.entities.Paste +import de.interaapps.pastefy.entities.PasteStar +import de.interaapps.pastefy.infrastructure.elastic.ElasticPasteService +import de.interaapps.pastefy.repositories.PasteRepository +import de.interaapps.pastefy.repositories.PasteStarRepository +import de.interaapps.pastefy.repositories.UserRepository +import de.interaapps.pastefy.repositories.AuthKeyRepository +import de.interaapps.pastefy.repositories.FolderRepository +import de.interaapps.pastefy.repositories.NotificationRepository +import de.interaapps.pastefy.repositories.SharedPasteRepository +import org.springframework.beans.factory.ObjectProvider +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class UserService( + private val userRepository: UserRepository, + private val pasteRepository: PasteRepository, + private val folderRepository: FolderRepository, + private val pasteStarRepository: PasteStarRepository, + private val authKeyRepository: AuthKeyRepository, + private val notificationRepository: NotificationRepository, + private val sharedPasteRepository: SharedPasteRepository, + private val elasticProvider: ObjectProvider, + private val pasteService: PasteService, + private val pasteMetricsService: PasteMetricsService, + private val engagement: PublicPasteEngagementService, +) { + + fun get(id: String): User? { + return userRepository.findById(id).orElse(null) + } + + fun getByName(name: String): User? { + return userRepository.findByUniqueName(name) + } + + fun getPastes(user: User): List { + return pasteRepository.findAllByUserId(user.id) + } + + fun getFolders(user: User): List { + return folderRepository.findAllByUserId(user.id) + } + + fun getFolderTree( + user: User, + fetchChildren: Boolean, + fetchSubChildren: Boolean, + fetchPastes: Boolean + ): List { + return folderRepository.findAllByUserIdAndParentIsNull(user.id) + .map { folder -> + FolderResponse( + id = folder.key, + name = folder.name, + userId = folder.userId, + created = folder.createdAt?.toString() ?: "0000-00-00 00:00:00", + exists = true, + ) + } + } + + fun getFolderWithChildren(user: User): List { + return folderRepository.findAllByUserIdAndParentIsNull(user.id) + .map { folder -> + FolderResponse( + id = folder.key, + name = folder.name, + userId = folder.userId, + created = folder.createdAt?.toString() ?: "0000-00-00 00:00:00", + exists = true, + ) + } + } + + @Transactional + fun sendNotification(user: User, notification: Notification): Notification { + notification.userId = user.id + return notificationRepository.save(notification) + } + + fun hasStarred(user: User, paste: Paste): Boolean { + return pasteStarRepository.existsByPasteAndUserId(paste.key, user.id) + } + + @Transactional + fun star(user: User, paste: Paste) { + if (hasStarred(user, paste)) { + return + } + + val pasteStar = PasteStar( + paste = paste.key, + userId = user.id + ) + + pasteStarRepository.save(pasteStar) + pasteMetricsService.invalidate(paste.key) + elasticProvider.ifAvailable?.updateStars(paste) + engagement.addInterest(paste, 20) + } + + @Transactional + fun unstar(user: User, paste: Paste) { + pasteStarRepository.deleteByPasteAndUserId(paste.key, user.id) + pasteMetricsService.invalidate(paste.key) + elasticProvider.ifAvailable?.updateStars(paste) + engagement.addInterest(paste, -20) + } + + @Transactional + fun delete(user: User) { + pasteRepository.findAllByUserId(user.id) + .forEach(pasteService::delete) + + folderRepository.deleteByUserId(user.id) + authKeyRepository.deleteByUserId(user.id) + notificationRepository.deleteByUserId(user.id) + sharedPasteRepository.deleteByTargetIdOrUserId(user.id, user.id) + + userRepository.delete(user) + } +} diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/JpaPasteQueryAdapter.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/JpaPasteQueryAdapter.kt new file mode 100644 index 00000000..b2befd0f --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/JpaPasteQueryAdapter.kt @@ -0,0 +1,214 @@ +package de.interaapps.pastefy.service.query + +import de.interaapps.pastefy.entities.Paste +import de.interaapps.pastefy.entities.PasteStar +import de.interaapps.pastefy.entities.PasteTag +import de.interaapps.pastefy.entities.PublicPasteEngagement +import de.interaapps.pastefy.enums.PasteType +import de.interaapps.pastefy.enums.PasteVisibility +import de.interaapps.pastefy.enums.StorageType +import jakarta.persistence.EntityManager +import jakarta.persistence.criteria.CriteriaBuilder +import jakarta.persistence.criteria.CriteriaQuery +import jakarta.persistence.criteria.Expression +import jakarta.persistence.criteria.Predicate +import jakarta.persistence.criteria.Root +import org.springframework.stereotype.Component +import java.sql.Timestamp +import java.time.Instant + +@Component +class JpaPasteQueryAdapter( + private val entityManager: EntityManager, +) { + fun find(query: LegacyPasteQuery): List { + val builder = entityManager.criteriaBuilder + val criteria = builder.createQuery(Paste::class.java) + val root = criteria.from(Paste::class.java) + + val predicates = mutableListOf() + query.filter?.let { predicates += predicate(it, root, criteria, builder) } + query.search?.let { predicates += searchPredicate(it, root, builder) } + if (query.filterTags.isNotEmpty()) predicates += tagsPredicate(query.filterTags, root, criteria, builder) + + if (predicates.isNotEmpty()) criteria.where(builder.and(*predicates.toTypedArray())) + criteria.orderBy(query.sorts.map { order(it, root, criteria, builder) }) + + return entityManager.createQuery(criteria) + .setFirstResult(query.offset) + .setMaxResults(query.pageLimit) + .resultList + } + + private fun predicate( + filter: LegacyFilter, + root: Root, + query: CriteriaQuery, + builder: CriteriaBuilder, + ): Predicate = when (filter) { + is LegacyFilterGroup -> { + val children = filter.children.map { predicate(it, root, query, builder) }.toTypedArray() + when (filter.operator) { + LegacyGroupOperator.AND -> if (children.isEmpty()) builder.conjunction() else builder.and(*children) + LegacyGroupOperator.OR -> if (children.isEmpty()) builder.conjunction() else builder.or(*children) + } + } + is LegacyFieldFilter -> fieldPredicate(filter, root, query, builder) + } + + private fun fieldPredicate( + filter: LegacyFieldFilter, + root: Root, + query: CriteriaQuery, + builder: CriteriaBuilder, + ): Predicate { + val field = normalizeField(filter.field) + if (field == "starredBy") return starredPredicate(filter, root, query, builder) + if (field == "tags") return tagsPredicate(listOfNotNull(filter.value), root, query, builder, filter.operator) + if (field == "engagementScore") return engagementPredicate(filter, root, query, builder) + + val path = root.get(field) + return when (filter.operator) { + LegacyFilterOperator.EQ -> filter.value?.let { builder.equal(path, convert(field, it)) } ?: builder.isNull(path) + LegacyFilterOperator.NE -> filter.value?.let { builder.notEqual(path, convert(field, it)) } ?: builder.isNotNull(path) + LegacyFilterOperator.NULL -> builder.isNull(path) + LegacyFilterOperator.NOT_NULL -> builder.isNotNull(path) + LegacyFilterOperator.GT -> compare(field, filter.value, root, builder) { expression, value -> builder.greaterThan(expression, value) } + LegacyFilterOperator.GTE -> compare(field, filter.value, root, builder) { expression, value -> builder.greaterThanOrEqualTo(expression, value) } + LegacyFilterOperator.LT -> compare(field, filter.value, root, builder) { expression, value -> builder.lessThan(expression, value) } + LegacyFilterOperator.LTE -> compare(field, filter.value, root, builder) { expression, value -> builder.lessThanOrEqualTo(expression, value) } + } + } + + private fun starredPredicate( + filter: LegacyFieldFilter, + root: Root, + query: CriteriaQuery, + builder: CriteriaBuilder, + ): Predicate { + val subquery = query.subquery(String::class.java) + val star = subquery.from(PasteStar::class.java) + subquery.select(star.get("paste")).where(builder.equal(star.get("userId"), filter.value)) + val predicate = root.get("key").`in`(subquery) + return if (filter.operator == LegacyFilterOperator.NE) builder.not(predicate) else predicate + } + + private fun tagsPredicate( + tags: List, + root: Root, + query: CriteriaQuery, + builder: CriteriaBuilder, + operator: LegacyFilterOperator = LegacyFilterOperator.EQ, + ): Predicate { + if (tags.isEmpty()) return builder.conjunction() + val subquery = query.subquery(String::class.java) + val pasteTag = subquery.from(PasteTag::class.java) + subquery.select(pasteTag.get("paste")).where(pasteTag.get("tag").`in`(tags)) + val predicate = root.get("key").`in`(subquery) + return if (operator == LegacyFilterOperator.NE) builder.not(predicate) else predicate + } + + private fun engagementPredicate( + filter: LegacyFieldFilter, + root: Root, + query: CriteriaQuery, + builder: CriteriaBuilder, + ): Predicate { + val score = engagementExpression(root, query, builder) + val value = filter.value?.toIntOrNull() ?: 0 + return when (filter.operator) { + LegacyFilterOperator.EQ -> builder.equal(score, value) + LegacyFilterOperator.NE -> builder.notEqual(score, value) + LegacyFilterOperator.NULL -> builder.equal(score, 0) + LegacyFilterOperator.NOT_NULL -> builder.notEqual(score, 0) + LegacyFilterOperator.GT -> builder.greaterThan(score, value) + LegacyFilterOperator.GTE -> builder.greaterThanOrEqualTo(score, value) + LegacyFilterOperator.LT -> builder.lessThan(score, value) + LegacyFilterOperator.LTE -> builder.lessThanOrEqualTo(score, value) + } + } + + private fun searchPredicate(search: String, root: Root, builder: CriteriaBuilder): Predicate { + val pattern = "%${search.lowercase()}%" + return builder.or( + builder.like(builder.lower(root.get("title")), pattern), + builder.like(root.get("content"), "%$search%"), + ) + } + + private fun order( + sort: LegacySort, + root: Root, + query: CriteriaQuery, + builder: CriteriaBuilder, + ): jakarta.persistence.criteria.Order { + val field = normalizeField(sort.field) + val expression: Expression> = if (field == "engagementScore") { + engagementExpression(root, query, builder) + } else { + root.get(field) + } + return if (sort.ascending) builder.asc(expression) else builder.desc(expression) + } + + private fun engagementExpression( + root: Root, + query: CriteriaQuery, + builder: CriteriaBuilder, + ): Expression { + val subquery = query.subquery(Int::class.java) + val engagement = subquery.from(PublicPasteEngagement::class.java) + val firstEngagementId = query.subquery(Int::class.java) + val firstEngagement = firstEngagementId.from(PublicPasteEngagement::class.java) + firstEngagementId.select(builder.min(firstEngagement.get("id"))) + .where(builder.equal(firstEngagement.get("pasteId"), root.get("id"))) + subquery.select(engagement.get("score")) + .where(builder.equal(engagement.get("id"), firstEngagementId)) + return builder.coalesce(subquery, 0) + } + + private fun compare( + field: String, + rawValue: String?, + root: Root, + builder: CriteriaBuilder, + operation: (Expression>, Comparable) -> Predicate, + ): Predicate { + val value = rawValue?.let { convert(field, it) as? Comparable } ?: return builder.disjunction() + @Suppress("UNCHECKED_CAST") + val expression = root.get>(field) + return operation(expression, value) + } + + private fun normalizeField(field: String): String = FIELD_ALIASES[field] ?: field + + private fun convert(field: String, value: String): Any = when (field) { + "id", "version", "length" -> value.toIntOrNull() ?: value + "encrypted", "indexedInElastic" -> value.toBooleanStrictOrNull() ?: value.equals("true", true) + "visibility" -> runCatching { PasteVisibility.valueOf(value.uppercase()) }.getOrElse { value } + "type" -> runCatching { PasteType.valueOf(value.uppercase()) }.getOrElse { value } + "storageType" -> runCatching { StorageType.valueOf(value.uppercase()) }.getOrElse { value } + "createdAt", "updatedAt", "expireAt" -> parseInstant(value) ?: value + else -> value + } + + private fun parseInstant(value: String): Instant? = + runCatching { Instant.parse(value) } + .recoverCatching { Timestamp.valueOf(value).toInstant() } + .getOrNull() + + companion object { + private val FIELD_ALIASES = mapOf( + "created_at" to "createdAt", + "updated_at" to "updatedAt", + "expire_at" to "expireAt", + "user_id" to "userId", + "forked_from" to "forkedFrom", + "storage_type" to "storageType", + "indexed_in_elastic" to "indexedInElastic", + "engagement_score" to "engagementScore", + "starred_by" to "starredBy", + "star_count" to "starCount", + ) + } +} diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/LegacyFilterSpecificationBuilder.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/LegacyFilterSpecificationBuilder.kt new file mode 100644 index 00000000..c0111e52 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/LegacyFilterSpecificationBuilder.kt @@ -0,0 +1,157 @@ +package de.interaapps.pastefy.service.query + +import de.interaapps.pastefy.entities.TagListing +import de.interaapps.pastefy.entities.User +import de.interaapps.pastefy.enums.PasteVisibility +import jakarta.persistence.criteria.CriteriaBuilder +import jakarta.persistence.criteria.Expression +import jakarta.persistence.criteria.Predicate +import jakarta.persistence.criteria.Root +import jakarta.servlet.http.HttpServletRequest +import org.springframework.data.jpa.domain.Specification +import org.springframework.stereotype.Component +import java.sql.Timestamp +import java.time.Instant + +@Component +class LegacyFilterSpecificationBuilder( + private val parser: LegacyPasteQueryParser, +) { + fun fromRequest( + request: HttpServletRequest, + fields: Map>, + ): Specification { + val filter = parser.parseClientFilter(request) ?: return Specification { _, _, builder -> builder.conjunction() } + return Specification { root, _, builder -> predicate(filter, root, builder, fields) } + } + + private fun predicate( + filter: LegacyFilter, + root: Root, + builder: CriteriaBuilder, + fields: Map>, + ): Predicate = when (filter) { + is LegacyFilterGroup -> { + val children = filter.children.map { predicate(it, root, builder, fields) }.toTypedArray() + when (filter.operator) { + LegacyGroupOperator.AND -> if (children.isEmpty()) builder.conjunction() else builder.and(*children) + LegacyGroupOperator.OR -> if (children.isEmpty()) builder.conjunction() else builder.or(*children) + } + } + + is LegacyFieldFilter -> fieldPredicate(filter, root, builder, fields) + } + + private fun fieldPredicate( + filter: LegacyFieldFilter, + root: Root, + builder: CriteriaBuilder, + fields: Map>, + ): Predicate { + val field = fields[filter.field] ?: return builder.conjunction() + val path = root.get(field.name) + return when (filter.operator) { + LegacyFilterOperator.EQ -> filter.value?.let { builder.equal(path, field.convert(it)) } ?: builder.isNull(path) + LegacyFilterOperator.NE -> filter.value?.let { builder.notEqual(path, field.convert(it)) } ?: builder.isNotNull(path) + LegacyFilterOperator.NULL -> builder.isNull(path) + LegacyFilterOperator.NOT_NULL -> builder.isNotNull(path) + LegacyFilterOperator.GT -> compare(path, field.convert(filter.value), builder) { expression, value -> + builder.greaterThan(expression, value) + } + LegacyFilterOperator.GTE -> compare(path, field.convert(filter.value), builder) { expression, value -> + builder.greaterThanOrEqualTo(expression, value) + } + LegacyFilterOperator.LT -> compare(path, field.convert(filter.value), builder) { expression, value -> + builder.lessThan(expression, value) + } + LegacyFilterOperator.LTE -> compare(path, field.convert(filter.value), builder) { expression, value -> + builder.lessThanOrEqualTo(expression, value) + } + } + } + + @Suppress("UNCHECKED_CAST") + private fun compare( + path: Expression, + value: Any?, + builder: CriteriaBuilder, + operation: (Expression>, Comparable) -> Predicate, + ): Predicate { + if (value !is Comparable<*>) return builder.disjunction() + return operation(path as Expression>, value as Comparable) + } + + data class Field( + val name: String, + val convert: (String?) -> Any? = { it }, + ) + + companion object { + val USER_FIELDS = mapOf( + field("id"), + field("name"), + field("uniqueName"), + field("email"), + field("avatar"), + field("authId"), + field("authProvider") { value -> value?.let { User.AuthenticationProvider.valueOf(it.uppercase()) } }, + field("type") { value -> value?.let { User.Type.valueOf(it.uppercase()) } }, + field("createdAt", ::instant), + field("updatedAt", ::instant), + alias("unique_name", "uniqueName"), + alias("auth_id", "authId"), + alias("auth_provider", "authProvider") { value -> + value?.let { User.AuthenticationProvider.valueOf(it.uppercase()) } + }, + alias("created_at", "createdAt", ::instant), + alias("updated_at", "updatedAt", ::instant), + alias("eMail", "email"), + ) + + val FOLDER_FIELDS = mapOf( + field("id", ::int), + field("key"), + field("name"), + field("userId"), + field("parent"), + field("createdAt", ::instant), + field("updatedAt", ::instant), + alias("user_id", "userId"), + alias("created_at", "createdAt", ::instant), + alias("updated_at", "updatedAt", ::instant), + ) + + val TAG_LISTING_FIELDS = mapOf( + field("tag"), + field("displayName"), + field("imageUrl"), + field("description"), + field("website"), + field("icon"), + field("pasteCount", ::int), + alias("display_name", "displayName"), + alias("image_url", "imageUrl"), + alias("paste_count", "pasteCount", ::int), + ) + + private fun field( + name: String, + convert: (String?) -> Any? = { it }, + ): Pair> = name to Field(name, convert) + + private fun alias( + source: String, + target: String, + convert: (String?) -> Any? = { it }, + ): Pair> = source to Field(target, convert) + + private fun int(value: String?): Any? = value?.toIntOrNull() ?: value + + private fun instant(value: String?): Any? = + value?.let { + runCatching { Instant.parse(value) } + .recoverCatching { Timestamp.valueOf(value).toInstant() } + .getOrElse { _ -> value } + } + } +} diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQuery.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQuery.kt new file mode 100644 index 00000000..e3171b50 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQuery.kt @@ -0,0 +1,52 @@ +package de.interaapps.pastefy.service.query + +import de.interaapps.pastefy.entities.User + +data class LegacyPasteQuery( + val page: Int, + val pageLimit: Int, + val search: String?, + val sorts: List, + val filter: LegacyFilter?, + val filterTags: List, + val shortenContent: Boolean, + val withAiInfo: Boolean, + val currentUser: User?, +) { + val offset: Int get() = pageLimit * (page - 1) +} + +data class LegacySort( + val field: String, + val ascending: Boolean, +) + +sealed interface LegacyFilter + +data class LegacyFilterGroup( + val operator: LegacyGroupOperator, + val children: List, +) : LegacyFilter + +data class LegacyFieldFilter( + val field: String, + val operator: LegacyFilterOperator, + val value: String? = null, +) : LegacyFilter + +enum class LegacyGroupOperator { AND, OR } + +enum class LegacyFilterOperator(val legacyName: String) { + EQ("\$eq"), + NE("\$ne"), + NULL("\$null"), + NOT_NULL("\$notNull"), + GT("\$gt"), + GTE("\$gte"), + LT("\$lt"), + LTE("\$lte"); + + companion object { + fun fromLegacyName(value: String): LegacyFilterOperator? = entries.firstOrNull { it.legacyName == value } + } +} diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQueryParser.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQueryParser.kt new file mode 100644 index 00000000..4dd5ff06 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQueryParser.kt @@ -0,0 +1,178 @@ +package de.interaapps.pastefy.service.query + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import de.interaapps.pastefy.config.PastefyProperties +import de.interaapps.pastefy.entities.User +import de.interaapps.pastefy.enums.PasteVisibility +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.stereotype.Component + +@Component +class LegacyPasteQueryParser( + private val objectMapper: ObjectMapper, + private val properties: PastefyProperties, +) { + fun parse( + request: HttpServletRequest, + response: HttpServletResponse, + user: User?, + guarded: Boolean = true, + visibility: PasteVisibility? = null, + encrypted: Boolean? = null, + userId: String? = null, + starredBy: String? = null, + defaultSort: String = "createdAt", + additionalFilters: List = emptyList(), + ): LegacyPasteQuery { + val page = request.getParameter("page")?.toIntOrNull()?.coerceAtLeast(1) ?: 1 + val pageLimit = request.getParameter("page_limit")?.toIntOrNull()?.coerceAtLeast(1) + ?.coerceAtMost(properties.paginationPageLimit.coerceAtLeast(1)) + ?: properties.paginationPageLimit.coerceAtLeast(1) + + response.setHeader("PAGINATION_LIMIT", pageLimit.toString()) + response.setHeader("PAGINATION_PAGE", (page - 1).toString()) + + val filters = mutableListOf() + + parseClientFilter(request)?.let(filters::add) + + request.getParameter("folder")?.takeIf(String::isNotBlank) + ?.let { filters += LegacyFieldFilter("folder", LegacyFilterOperator.EQ, it) } + + visibility?.let { filters += LegacyFieldFilter("visibility", LegacyFilterOperator.EQ, it.name) } + encrypted?.let { filters += LegacyFieldFilter("encrypted", LegacyFilterOperator.EQ, it.toString()) } + userId?.let { filters += LegacyFieldFilter("userId", LegacyFilterOperator.EQ, it) } + starredBy?.let { filters += LegacyFieldFilter("starredBy", LegacyFilterOperator.EQ, it) } + filters += additionalFilters + + if (guarded && user?.isAdmin != true) { + val visible = mutableListOf(LegacyFieldFilter("visibility", LegacyFilterOperator.EQ, PasteVisibility.PUBLIC.name)) + user?.let { + visible += LegacyFieldFilter("userId", LegacyFilterOperator.EQ, it.id) + visible += LegacyFieldFilter("starredBy", LegacyFilterOperator.EQ, it.id) + } + filters += LegacyFilterGroup(LegacyGroupOperator.OR, visible) + } + + return LegacyPasteQuery( + page = page, + pageLimit = pageLimit, + search = request.getParameter("search")?.trim()?.takeIf(String::isNotEmpty), + sorts = parseSorts(request.getParameter("sort"), defaultSort), + filter = filters.compactAnd(), + filterTags = request.getParameter("filter_tags") + ?.split(',') + ?.map(String::trim) + ?.filter(String::isNotEmpty) + .orEmpty(), + shortenContent = request.getParameter("shorten_content").equals("true", true), + withAiInfo = request.getParameter("with_ai_analysis").equals("true", true), + currentUser = user, + ) + } + + private fun parseSorts(raw: String?, defaultSort: String): List { + val source = raw?.takeIf(String::isNotBlank) ?: defaultSort + return source.split(',').mapNotNull { part -> + val value = part.trim().takeIf(String::isNotEmpty) ?: return@mapNotNull null + LegacySort(field = value.removePrefix("+"), ascending = value.startsWith("+")) + } + } + + fun parseClientFilter(request: HttpServletRequest): LegacyFilter? { + request.getParameter("filters")?.trim()?.takeIf(String::isNotEmpty)?.let { + return parseJsonObject(objectMapper.readTree(it)) + } + + request.getParameter("filter")?.trim()?.takeIf { it.startsWith("{") }?.let { + return LegacyFilterGroup(LegacyGroupOperator.AND, listOf(parseJsonObject(objectMapper.readTree(it)))) + } + + val formFilter = mutableMapOf() + + request.parameterMap.forEach { (name, values) -> + val tokens = BRACKET_TOKEN.findAll(name).map { it.groupValues[1] }.toList() + if (tokens.firstOrNull() == "filter" && tokens.size > 1) { + insert(formFilter, tokens.drop(1), values.map(String::trim).filter(String::isNotEmpty)) + } + } + + if (formFilter.isEmpty()) return null + + return LegacyFilterGroup(LegacyGroupOperator.AND, listOf(parseObject(formFilter))) + } + + private fun insert(target: MutableMap, path: List, values: List) { + if (path.isEmpty()) return + val key = path.first() + if (path.size == 1) { + target[key] = values.firstOrNull().orEmpty() + return + } + val child = target.getOrPut(key) { mutableMapOf() } + if (child is MutableMap<*, *>) { + @Suppress("UNCHECKED_CAST") + insert(child as MutableMap, path.drop(1), values) + } + } + + private fun parseJsonObject(node: JsonNode): LegacyFilter = parseObject(jsonToAny(node) as Map) + + private fun jsonToAny(node: JsonNode): Any? = when { + node.isObject -> node.fields().asSequence().associate { it.key to jsonToAny(it.value) } + node.isArray -> node.map(::jsonToAny) + node.isNull -> null + node.isBoolean -> node.booleanValue().toString() + node.isNumber -> node.asText() + else -> node.asText() + } + + private fun parseObject(values: Map): LegacyFilter = + values.mapNotNull { (key, value) -> parseEntry(key, value) }.compactAnd() + ?: LegacyFilterGroup(LegacyGroupOperator.AND, emptyList()) + + private fun parseEntry(key: String, value: Any?): LegacyFilter? = when (key) { + "\$and" -> LegacyFilterGroup(LegacyGroupOperator.AND, parseGroupChildren(value)) + "\$or" -> LegacyFilterGroup(LegacyGroupOperator.OR, parseGroupChildren(value)) + else -> parseField(key, value) + } + + private fun parseGroupChildren(value: Any?): List = when (value) { + is List<*> -> value.mapNotNull { parseGroupChild(it) } + is Map<*, *> -> listOfNotNull(parseGroupChild(value)) + else -> emptyList() + } + + private fun parseGroupChild(value: Any?): LegacyFilter? = when (value) { + is Map<*, *> -> { + @Suppress("UNCHECKED_CAST") + parseObject(value as Map) + } + else -> null + } + + private fun parseField(field: String, value: Any?): LegacyFilter? { + if (value is Map<*, *>) { + @Suppress("UNCHECKED_CAST") + val operators = value as Map + val filters = operators.mapNotNull { (operator, operatorValue) -> + LegacyFilterOperator.fromLegacyName(operator) + ?.let { LegacyFieldFilter(field, it, operatorValue?.toString()) } + } + if (filters.isNotEmpty()) return filters.compactAnd() + } + return LegacyFieldFilter(field, LegacyFilterOperator.EQ, value?.toString()) + } + + private fun List.compactAnd(): LegacyFilter? = when (size) { + 0 -> null + 1 -> first() + else -> LegacyFilterGroup(LegacyGroupOperator.AND, this) + } + + companion object { + private val BRACKET_TOKEN = Regex("([^\\[\\]]+)") + } +} diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/util/RandomStrings.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/util/RandomStrings.kt new file mode 100644 index 00000000..7a5d6761 --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/util/RandomStrings.kt @@ -0,0 +1,14 @@ +package de.interaapps.pastefy.util + +import java.security.SecureRandom + +object RandomStrings { + private const val CHARACTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + private val random = SecureRandom() + + fun alphanumeric(length: Int): String = buildString(length) { + repeat(length) { + append(CHARACTERS[random.nextInt(CHARACTERS.length)]) + } + } +} diff --git a/backend-kt/src/main/kotlin/de/interaapps/pastefy/util/StringUtil.kt b/backend-kt/src/main/kotlin/de/interaapps/pastefy/util/StringUtil.kt new file mode 100644 index 00000000..ef5729bf --- /dev/null +++ b/backend-kt/src/main/kotlin/de/interaapps/pastefy/util/StringUtil.kt @@ -0,0 +1,14 @@ +package de.interaapps.pastefy.util + +fun String?.shorten(): String? { + val lineSequence = this?.lineSequence() + + if (lineSequence?.count() == 1) { + return if (this!!.length > 303) this.take(300) + "..." else this + } + + return lineSequence?.take(8)?.joinToString("\n") { line -> + if (line.length > 70) line.take(67) + "..." else line + } + ?: return this +} \ No newline at end of file diff --git a/backend-kt/src/main/resources/META-INF/spring.factories b/backend-kt/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..ecd1af44 --- /dev/null +++ b/backend-kt/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ +de.interaapps.pastefy.config.AiEnvironmentPostProcessor diff --git a/backend-kt/src/main/resources/application-local.properties b/backend-kt/src/main/resources/application-local.properties new file mode 100644 index 00000000..e082f399 --- /dev/null +++ b/backend-kt/src/main/resources/application-local.properties @@ -0,0 +1,38 @@ +# Local development profile: seed deterministic test data into the configured database. +# The seeder is idempotent and only inserts missing records. +pastefy.seeding.enabled=${PASTEFY_SEEDING_ENABLED:true} +spring.liquibase.enabled=${LIQUIBASE_ENABLED:true} +pastefy.meta-tags-enabled=${PASTEFY_META_TAGS:true} + +pastefy.cors=${HTTP_SERVER_CORS:*} +# Local docker-compose services in backend-kt/docker-compose.yaml. +pastefy.redis.enabled=${PASTEFY_REDIS_ENABLED:true} +pastefy.redis.cache-after-accesses=${PASTEFY_REDIS_CACHE_AFTER_ACCESSES:1} +spring.data.redis.host=${REDIS_HOST:localhost} +spring.data.redis.port=${REDIS_PORT:6379} +spring.data.redis.password=${REDIS_PASSWORD:} + +pastefy.s3.enabled=${PASTEFY_S3_ENABLED:true} +pastefy.s3.endpoint=${MINIO_SERVER:http://localhost:9000} +pastefy.s3.access-key=${MINIO_ACCESS_KEY:minioadmin} +pastefy.s3.secret-key=${MINIO_SECRET_KEY:minioadmin} +pastefy.s3.bucket=${MINIO_BUCKET:pastefy} +pastefy.s3.region=${MINIO_REGION:us-east-1} +pastefy.s3.paste-size-threshold=${MINIO_PASTESIZE_THRESHOLD:1024} +pastefy.s3.create-bucket=${MINIO_CREATE_BUCKET:true} + +pastefy.elasticsearch.enabled=${PASTEFY_ELASTICSEARCH_ENABLED:true} +pastefy.elasticsearch.migrations.enabled=${PASTEFY_ELASTICSEARCH_MIGRATIONS_ENABLED:true} +spring.elasticsearch.uris=${ELASTICSEARCH_URL:http://localhost:9200} +spring.elasticsearch.username=${ELASTICSEARCH_USER:} +spring.elasticsearch.password=${ELASTICSEARCH_PASSWORD:} + +pastefy.analytics.enabled=${PASTEFY_ANALYTICS_ENABLED:true} +pastefy.analytics.jdbc-url=${ANALYTICS_CLICKHOUSE_JDBC_URL:jdbc:clickhouse://localhost:8123/pastefy} +pastefy.analytics.database=${ANALYTICS_CLICKHOUSE_DATABASE:pastefy} +pastefy.analytics.table=${ANALYTICS_CLICKHOUSE_TABLE:pastefy_analytics_visits} +pastefy.analytics.user=${ANALYTICS_CLICKHOUSE_USER:pastefy} +pastefy.analytics.password=${ANALYTICS_CLICKHOUSE_PASSWORD:pastefy} +pastefy.analytics.ip-hash-salt=${ANALYTICS_IP_HASH_SALT:local-development-ip-hash-salt} +pastefy.analytics.migrations.enabled=${ANALYTICS_CLICKHOUSE_MIGRATIONS_ENABLED:true} +pastefy.analytics.migrations.repair-failed-history=${ANALYTICS_CLICKHOUSE_REPAIR_FAILED_HISTORY:true} diff --git a/backend-kt/src/main/resources/application.properties b/backend-kt/src/main/resources/application.properties new file mode 100644 index 00000000..c59c7d7e --- /dev/null +++ b/backend-kt/src/main/resources/application.properties @@ -0,0 +1,182 @@ +spring.application.name=pastefy-api + +spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:mysql://${DATABASE_HOST:localhost}:${DATABASE_PORT:3306}/${DATABASE_NAME:pastefy}?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC&cachePrepStmts=${DATABASE_CUSTOMPARAMS_CACHE_PREP_STMTS:true}&prepStmtCacheSize=${DATABASE_CUSTOMPARAMS_PREP_STMT_CACHE_SIZE:250}&prepStmtCacheSqlLimit=${DATABASE_CUSTOMPARAMS_PREP_STMT_CACHE_SQL_LIMIT:2048}&useServerPrepStmts=${DATABASE_CUSTOMPARAMS_USE_SERVER_PREP_STMTS:true}&cacheResultSetMetadata=${DATABASE_CUSTOMPARAMS_CACHE_RESULT_SET_METADATA:true}&cacheServerConfiguration=${DATABASE_CUSTOMPARAMS_CACHE_SERVER_CONFIGURATION:true}&maintainTimeStats=${DATABASE_CUSTOMPARAMS_MAINTAIN_TIME_STATS:true}} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME:${DATABASE_USER:pastefy}} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:${DATABASE_PASSWORD:pastefy}} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver +spring.datasource.hikari.pool-name=${DATABASE_POOL_NAME:pastefy-mysql} +spring.datasource.hikari.maximum-pool-size=${DATABASE_POOL_MAX_SIZE:${DATABASE_POOL_MAXIMUM_POOL_SIZE:10}} +spring.datasource.hikari.minimum-idle=${DATABASE_POOL_MIN_IDLE:${DATABASE_POOL_MINIMUM_IDLE:2}} +spring.datasource.hikari.connection-timeout=${DATABASE_POOL_CONNECTION_TIMEOUT_MS:30000} +spring.datasource.hikari.idle-timeout=${DATABASE_POOL_IDLE_TIMEOUT_MS:600000} +spring.datasource.hikari.max-lifetime=${DATABASE_POOL_MAX_LIFETIME_MS:1800000} +spring.datasource.hikari.validation-timeout=${DATABASE_POOL_VALIDATION_TIMEOUT_MS:5000} +spring.datasource.hikari.leak-detection-threshold=${DATABASE_POOL_LEAK_DETECTION_THRESHOLD_MS:0} + +# Schema changes are handled externally. The API only verifies the deployed schema. +spring.jpa.hibernate.ddl-auto=${SPRING_JPA_HIBERNATE_DDL_AUTO:validate} +spring.jpa.open-in-view=false +spring.jpa.show-sql=${SPRING_JPA_SHOW_SQL:false} +spring.jpa.properties.hibernate.format_sql=false +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect + +# Liquibase owns the MySQL schema lifecycle. Enable it explicitly during deployment. +spring.liquibase.enabled=${LIQUIBASE_ENABLED:${PASTEFY_AUTOMIGRATE:false}} +spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.yaml +# Boot Flyway auto-configuration targets the primary datasource. ClickHouse Flyway is configured separately below. +spring.flyway.enabled=false + +# Pastefy's public API contract uses snake_case for request and response JSON. +# Springdoc derives /v3/api-docs schemas from the same Jackson naming strategy. +spring.jackson.property-naming-strategy=SNAKE_CASE +server.port=${HTTP_SERVER_PORT:${SERVER_PORT:8080}} +server.forward-headers-strategy=${SERVER_FORWARD_HEADERS_STRATEGY:NATIVE} + +pastefy.cors=${HTTP_SERVER_CORS:} +pastefy.login-required-read=${PASTEFY_LOGIN_REQUIRED_READ:${PASTEFY_LOGIN_REQUIRED:false}} +pastefy.login-required-create=${PASTEFY_LOGIN_REQUIRED_CREATE:${PASTEFY_LOGIN_REQUIRED:false}} +pastefy.public-pastes-enabled=${PASTEFY_PUBLIC_PASTES_ENABLED:${PASTEFY_PUBLIC_PASTES:true}} +pastefy.public-stats=${PASTEFY_PUBLIC_STATS:false} +pastefy.meta-tags-enabled=${PASTEFY_META_TAGS:false} +pastefy.meta-tags-preview-length=${PASTEFY_META_TAGS_PREVIEW_LENGTH:4096} +pastefy.server-name=${SERVER_NAME:http://localhost} +pastefy.list-pastes=${PASTEFY_LIST_PASTES:false} +pastefy.pagination-page-limit=${PASTEFY_PAGINATION_PAGE_LIMIT:50} +pastefy.encryption-default=${PASTEFY_ENCRYPTION_DEFAULT:false} +pastefy.custom-logo=${PASTEFY_CUSTOM_LOGO:${PASTEFY_INFO_CUSTOM_LOGO:}} +pastefy.custom-name=${PASTEFY_CUSTOM_NAME:${PASTEFY_INFO_CUSTOM_NAME:}} +pastefy.custom-footer=${PASTEFY_CUSTOM_FOOTER:${PASTEFY_INFO_CUSTOM_FOOTER:}} +pastefy.custom-header=${PASTEFY_CUSTOM_HEADER:} +pastefy.custom-body=${PASTEFY_CUSTOM_BODY:} +pastefy.grant-access-required=${PASTEFY_GRANT_ACCESS_REQUIRED:false} +pastefy.oauth-state-secret=${PASTEFY_OAUTH_STATE_SECRET:pastefydefaultstatechangeitinfuture} +pastefy.oauth.callback-base-url=${PASTEFY_OAUTH_CALLBACK_BASE_URL:http://localhost:8080} +pastefy.oauth.interaapps.client-id=${OAUTH2_INTERAAPPS_CLIENT_ID:} +pastefy.oauth.interaapps.client-secret=${OAUTH2_INTERAAPPS_CLIENT_SECRET:} +pastefy.oauth.google.client-id=${OAUTH2_GOOGLE_CLIENT_ID:} +pastefy.oauth.google.client-secret=${OAUTH2_GOOGLE_CLIENT_SECRET:} +pastefy.oauth.github.client-id=${OAUTH2_GITHUB_CLIENT_ID:} +pastefy.oauth.github.client-secret=${OAUTH2_GITHUB_CLIENT_SECRET:} +pastefy.oauth.twitch.client-id=${OAUTH2_TWITCH_CLIENT_ID:} +pastefy.oauth.twitch.client-secret=${OAUTH2_TWITCH_CLIENT_SECRET:} +pastefy.oauth.discord.client-id=${OAUTH2_DISCORD_CLIENT_ID:} +pastefy.oauth.discord.client-secret=${OAUTH2_DISCORD_CLIENT_SECRET:} +pastefy.oauth.oidc.client-id=${OAUTH2_OIDC_CLIENT_ID:} +pastefy.oauth.oidc.client-secret=${OAUTH2_OIDC_CLIENT_SECRET:} +pastefy.oauth.oidc.authorization-endpoint=${OAUTH2_OIDC_AUTHORIZATION_ENDPOINT:${OAUTH2_OIDC_AUTH_ENDPOINT:}} +pastefy.oauth.oidc.token-endpoint=${OAUTH2_OIDC_TOKEN_ENDPOINT:} +pastefy.oauth.oidc.user-info-endpoint=${OAUTH2_OIDC_USER_INFO_ENDPOINT:${OAUTH2_OIDC_USERINFO_ENDPOINT:}} +pastefy.rate-limiter.enabled=${PASTEFY_RATE_LIMITER_ENABLED:true} +pastefy.rate-limiter.window-millis=${PASTEFY_RATE_LIMITER_WINDOW_MILLIS:5000} +pastefy.rate-limiter.limit=${PASTEFY_RATE_LIMITER_LIMIT:5} + +pastefy.seo.render-timeout-enabled=${PASTEFY_SEO_RENDER_TIMEOUT_ENABLED:false} +pastefy.seo.render-timeout-millis=${PASTEFY_SEO_RENDER_TIMEOUT_MILLIS:500} +pastefy.seo.executor-threads=${PASTEFY_SEO_EXECUTOR_THREADS:4} +pastefy.seo.executor-queue-capacity=${PASTEFY_SEO_EXECUTOR_QUEUE_CAPACITY:128} + +pastefy.cache.default-ttl-seconds=${PASTEFY_CACHE_DEFAULT_TTL_SECONDS:300} +pastefy.cache.seo-ttl-seconds=${PASTEFY_SEO_CACHE_TTL_SECONDS:3600} +pastefy.cache.max-size=${PASTEFY_CACHE_MAX_SIZE:10000} + +pastefy.redis.enabled=${PASTEFY_REDIS_ENABLED:false} +pastefy.redis.content-ttl-seconds=${PASTEFY_REDIS_CONTENT_TTL_SECONDS:1800} +pastefy.redis.access-count-ttl-seconds=${PASTEFY_REDIS_ACCESS_COUNT_TTL_SECONDS:1800} +pastefy.redis.metrics-ttl-seconds=${PASTEFY_REDIS_METRICS_TTL_SECONDS:30} +pastefy.redis.cache-after-accesses=${PASTEFY_REDIS_CACHE_AFTER_ACCESSES:10} +spring.data.redis.host=${REDIS_HOST:localhost} +spring.data.redis.port=${REDIS_PORT:6379} +spring.data.redis.password=${REDIS_PASSWORD:} +spring.data.redis.connect-timeout=${REDIS_CONNECT_TIMEOUT:1s} +spring.data.redis.timeout=${REDIS_TIMEOUT:1s} +spring.data.redis.lettuce.pool.enabled=${REDIS_POOL_ENABLED:${PASTEFY_REDIS_POOL_ENABLED:true}} +spring.data.redis.lettuce.pool.max-active=${REDIS_POOL_MAX:128} +spring.data.redis.lettuce.pool.max-idle=${REDIS_POOL_MAX_IDLE:64} +spring.data.redis.lettuce.pool.min-idle=${REDIS_POOL_MIN_IDLE:16} +spring.data.redis.lettuce.pool.max-wait=${REDIS_POOL_TIMEOUT:1000ms} +spring.data.redis.lettuce.pool.time-between-eviction-runs=${REDIS_POOL_TIME_BETWEEN_EVICTION_MILLIS:30000ms} +spring.data.redis.repositories.enabled=false + +pastefy.s3.enabled=${PASTEFY_S3_ENABLED:false} +pastefy.s3.endpoint=${MINIO_SERVER:} +pastefy.s3.access-key=${MINIO_ACCESS_KEY:} +pastefy.s3.secret-key=${MINIO_SECRET_KEY:} +pastefy.s3.bucket=${MINIO_BUCKET:pastefy} +pastefy.s3.region=${MINIO_REGION:} +pastefy.s3.paste-size-threshold=${MINIO_PASTESIZE_THRESHOLD:-1} +pastefy.s3.create-bucket=${MINIO_CREATE_BUCKET:false} +pastefy.s3.pool.max-idle-connections=${MINIO_POOL:50} +pastefy.s3.pool.keep-alive-minutes=${MINIO_POOL_KEEP_ALIVE_MINUTES:2} +pastefy.s3.pool.connect-timeout-seconds=${MINIO_CONNECT_TIMEOUT_SECONDS:4} +pastefy.s3.pool.read-timeout-seconds=${MINIO_READ_TIMEOUT_SECONDS:30} +pastefy.s3.pool.write-timeout-seconds=${MINIO_WRITE_TIMEOUT_SECONDS:30} +pastefy.s3.pool.retry-on-connection-failure=${MINIO_RETRY_ON_CONNECTION_FAILURE:true} + +pastefy.elasticsearch.enabled=${PASTEFY_ELASTICSEARCH_ENABLED:false} +pastefy.elasticsearch.api-key=${ELASTICSEARCH_API_KEY:} +pastefy.elasticsearch.index-name=${PASTEFY_ELASTICSEARCH_INDEX_NAME:pastefy_pastes} +pastefy.elasticsearch.index-prefix=${PASTEFY_ELASTICSEARCH_INDEX_PREFIX:pastefy_pastes} +pastefy.elasticsearch.legacy-index-name=${PASTEFY_ELASTICSEARCH_LEGACY_INDEX_NAME:pastefy_pastes} +pastefy.elasticsearch.number-of-shards=${PASTEFY_ELASTICSEARCH_NUMBER_OF_SHARDS:1} +pastefy.elasticsearch.number-of-replicas=${PASTEFY_ELASTICSEARCH_NUMBER_OF_REPLICAS:1} +# Elasticsearch mappings are migrated at startup unless explicitly disabled. +pastefy.elasticsearch.migrations.enabled=${PASTEFY_ELASTICSEARCH_MIGRATIONS_ENABLED:true} +pastefy.elasticsearch.pool.max-connections=${ELASTICSEARCH_POOL_MAX:50} +pastefy.elasticsearch.pool.max-connections-per-route=${ELASTICSEARCH_POOL_MAX_PER_ROUTE:${ELASTICSEARCH_POOL_MAX:50}} +spring.elasticsearch.uris=${ELASTICSEARCH_URL:http://localhost:9200} +spring.elasticsearch.username=${ELASTICSEARCH_USER:} +spring.elasticsearch.password=${ELASTICSEARCH_PASSWORD:} +spring.elasticsearch.connection-timeout=${ELASTICSEARCH_CONNECTION_TIMEOUT:2s} +spring.elasticsearch.socket-timeout=${ELASTICSEARCH_SOCKET_TIMEOUT:5s} +spring.data.elasticsearch.repositories.enabled=false + +pastefy.ai.enabled=${PASTEFY_AI_ENABLED:false} +pastefy.ai.provider=${PASTEFY_AI_PROVIDER:${AI_PROVIDER:}} +pastefy.ai.model=${PASTEFY_AI_MODEL:${AI_MODEL:}} +pastefy.ai.engagement-threshold=${PASTEFY_AI_ENGAGEMENT_THRESHOLD:${AI_ENGAGEMENT_THRESHOLD:100}} +pastefy.ai.jobs.workers=${PASTEFY_AI_JOBS_WORKERS:${AI_JOB_WORKERS:2}} +pastefy.ai.jobs.poll-interval-millis=${PASTEFY_AI_JOBS_POLL_INTERVAL_MILLIS:${AI_JOB_POLL_INTERVAL_MILLIS:5000}} +pastefy.ai.jobs.lease-seconds=${PASTEFY_AI_JOBS_LEASE_SECONDS:${AI_JOB_LEASE_SECONDS:120}} +pastefy.ai.jobs.max-attempts=${PASTEFY_AI_JOBS_MAX_ATTEMPTS:${AI_JOB_MAX_ATTEMPTS:3}} +pastefy.ai.jobs.retry-delay-seconds=${PASTEFY_AI_JOBS_RETRY_DELAY_SECONDS:${AI_JOB_RETRY_DELAY_SECONDS:60}} +pastefy.ai.jobs.sweeper-enabled=${PASTEFY_AI_JOBS_SWEEPER_ENABLED:${AI_JOB_SWEEPER_ENABLED:false}} +pastefy.ai.jobs.sweep-interval-millis=${PASTEFY_AI_JOBS_SWEEP_INTERVAL_MILLIS:${AI_JOB_SWEEP_INTERVAL_MILLIS:300000}} +spring.ai.anthropic.api-key=${AI_ANTHROPIC_TOKEN:} +spring.ai.anthropic.chat.options.model=${AI_ANTHROPIC_MODEL:claude-3-5-haiku-latest} +spring.ai.google.genai.api-key=${AI_GOOGLE_TOKEN:} +spring.ai.google.genai.chat.options.model=${AI_GOOGLE_MODEL:gemini-2.5-flash-lite} +spring.ai.model.chat=${SPRING_AI_MODEL_CHAT:${AI_PROVIDER:none}} + +pastefy.analytics.enabled=${PASTEFY_ANALYTICS_ENABLED:false} +pastefy.analytics.jdbc-url=${ANALYTICS_CLICKHOUSE_JDBC_URL:${ANALYTICS_CLICKHOUSE_URL:}} +pastefy.analytics.database=${ANALYTICS_CLICKHOUSE_DATABASE:default} +pastefy.analytics.table=${ANALYTICS_CLICKHOUSE_TABLE:pastefy_analytics_visits} +pastefy.analytics.user=${ANALYTICS_CLICKHOUSE_USER:default} +pastefy.analytics.password=${ANALYTICS_CLICKHOUSE_PASSWORD:} +pastefy.analytics.retention-days=${ANALYTICS_RETENTION_DAYS:90} +pastefy.analytics.batch-size=${ANALYTICS_BATCH_SIZE:1000} +pastefy.analytics.queue-capacity=${ANALYTICS_QUEUE_CAPACITY:100000} +pastefy.analytics.flush-interval-millis=${ANALYTICS_FLUSH_INTERVAL_MILLIS:1000} +pastefy.analytics.http-connect-timeout-millis=${ANALYTICS_HTTP_CONNECT_TIMEOUT_MILLIS:2000} +pastefy.analytics.http-request-timeout-millis=${ANALYTICS_HTTP_REQUEST_TIMEOUT_MILLIS:5000} +pastefy.analytics.pool.maximum-pool-size=${ANALYTICS_CLICKHOUSE_POOL_MAX_SIZE:${ANALYTICS_CLICKHOUSE_POOL_MAXIMUM_POOL_SIZE:5}} +pastefy.analytics.pool.minimum-idle=${ANALYTICS_CLICKHOUSE_POOL_MIN_IDLE:${ANALYTICS_CLICKHOUSE_POOL_MINIMUM_IDLE:1}} +pastefy.analytics.pool.connection-timeout-millis=${ANALYTICS_CLICKHOUSE_POOL_CONNECTION_TIMEOUT_MS:5000} +pastefy.analytics.pool.idle-timeout-millis=${ANALYTICS_CLICKHOUSE_POOL_IDLE_TIMEOUT_MS:60000} +pastefy.analytics.pool.max-lifetime-millis=${ANALYTICS_CLICKHOUSE_POOL_MAX_LIFETIME_MS:600000} +pastefy.analytics.pool.validation-timeout-millis=${ANALYTICS_CLICKHOUSE_POOL_VALIDATION_TIMEOUT_MS:2000} +pastefy.analytics.ip-hash-salt=${ANALYTICS_IP_HASH_SALT:} +pastefy.analytics.ip-source=${ANALYTICS_IP_SOURCE:direct} +pastefy.analytics.ip-header=${ANALYTICS_IP_HEADER:} +pastefy.analytics.geo-ip-mmdb-path=${ANALYTICS_GEOIP_MMDB_PATH:} +pastefy.analytics.track-bots=${ANALYTICS_TRACK_BOTS:true} +# Flyway owns ClickHouse schema changes. Enable only in the deployment step that applies migrations. +pastefy.analytics.migrations.enabled=${ANALYTICS_CLICKHOUSE_MIGRATIONS_ENABLED:${ANALYTICS_CLICKHOUSE_AUTOMIGRATE:false}} +pastefy.analytics.migrations.repair-failed-history=${ANALYTICS_CLICKHOUSE_REPAIR_FAILED_HISTORY:false} + +# Local test fixtures. The seeder is disabled by default and does not run with the prod Spring profile. +pastefy.seeding.enabled=${PASTEFY_SEEDING_ENABLED:false} + +springdoc.api-docs.path=/v3/api-docs +springdoc.swagger-ui.path=/swagger-ui.html +springdoc.model-converters.pageable-converter.enabled=true diff --git a/backend-kt/src/main/resources/db/README.md b/backend-kt/src/main/resources/db/README.md new file mode 100644 index 00000000..7e1524bf --- /dev/null +++ b/backend-kt/src/main/resources/db/README.md @@ -0,0 +1,23 @@ +# Database migrations + +MySQL and ClickHouse migrations are deliberately separated: + +- Liquibase owns MySQL changes. Its root changelog is `changelog/db.changelog-master.yaml`. It intentionally contains no changesets yet. +- Flyway owns ClickHouse changes. Versioned SQL files live in `migration/clickhouse/`. + +Both migration paths are disabled by default so regular application startup never changes production schemas implicitly. + +Apply MySQL migrations in the deployment migration step with: + +```bash +LIQUIBASE_ENABLED=true +``` + +Apply ClickHouse migrations in a separate deployment migration step with: + +```bash +ANALYTICS_CLICKHOUSE_MIGRATIONS_ENABLED=true +ANALYTICS_CLICKHOUSE_JDBC_URL=jdbc:clickhouse://clickhouse:8123/default +``` + +The ClickHouse migration runner does not require analytics event processing to be enabled. diff --git a/backend-kt/src/main/resources/db/changelog/001-baseline.yaml b/backend-kt/src/main/resources/db/changelog/001-baseline.yaml new file mode 100644 index 00000000..f5c277de --- /dev/null +++ b/backend-kt/src/main/resources/db/changelog/001-baseline.yaml @@ -0,0 +1,910 @@ +databaseChangeLog: +- changeSet: + id: 1780596708247-1 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: false + primaryKey: true + name: id + type: INT + - column: + constraints: + unique: true + name: key + type: VARCHAR(60) + - column: + name: api_key + type: VARCHAR(160) + - column: + name: user_id + type: VARCHAR(8) + - column: + defaultValue: 'API' + name: type + type: ENUM('API', 'USER', 'ACCESS_TOKEN') + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() on update current_timestamp() + name: created_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: CURRENT_TIMESTAMP + name: updated_at + type: TIMESTAMP(0) + - column: + name: scopes + type: TEXT + - column: + name: access_token + type: VARCHAR(255) + - column: + name: refresh_token + type: VARCHAR(255) + tableName: pastefy_auth_keys +- changeSet: + id: 1780596708247-2 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + constraints: + nullable: false + primaryKey: true + name: key + type: VARCHAR(255) + - column: + defaultValueComputed: 'NULL' + name: type + type: ENUM('PASTE_AI_INFO') + - column: + defaultValueComputed: 'NULL' + name: entity_id + type: INT + - column: + defaultValueComputed: 'NULL' + name: source_version + type: INT + - column: + defaultValueComputed: 'NULL' + name: prompt_version + type: INT + - column: + defaultValue: 'PENDING' + name: status + type: ENUM('PENDING', 'RUNNING', 'DONE', 'FAILED') + - column: + defaultValueComputed: 'NULL' + name: attempts + type: INT + - column: + defaultValueComputed: 'NULL' + name: available_at + type: TIMESTAMP(0) + - column: + defaultValueComputed: 'NULL' + name: lease_until + type: TIMESTAMP(0) + - column: + name: lease_token + type: VARCHAR(64) + - column: + name: last_error + type: VARCHAR(2048) + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() + name: created_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() on update current_timestamp() + name: updated_at + type: TIMESTAMP(0) + tableName: pastefy_background_jobs +- changeSet: + id: 1780596708247-3 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: false + primaryKey: true + name: id + type: INT + - column: + name: key + type: VARCHAR(8) + - column: + name: name + type: TEXT + - column: + name: user_id + type: VARCHAR(8) + - column: + name: parent + type: TEXT + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() on update current_timestamp() + name: created_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: CURRENT_TIMESTAMP + name: updated_at + type: TIMESTAMP(0) + tableName: pastefy_folder +- changeSet: + id: 1780596708247-4 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: false + primaryKey: true + name: id + type: INT + - column: + name: message + type: TEXT + - column: + name: user_id + type: VARCHAR(8) + - column: + name: url + type: TEXT + - column: + name: already_read + type: BIT + - column: + name: received + type: BIT + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() on update current_timestamp() + name: created_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: CURRENT_TIMESTAMP + name: updated_at + type: TIMESTAMP(0) + tableName: pastefy_notification +- changeSet: + id: 1780596708247-5 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + constraints: + nullable: false + primaryKey: true + name: paste_id + type: INT + - column: + defaultValueComputed: 'NULL' + name: source_paste_version + type: INT + - column: + defaultValueComputed: 'NULL' + name: prompt_version + type: INT + - column: + name: provider + type: VARCHAR(30) + - column: + name: model + type: VARCHAR(100) + - column: + name: description + type: VARCHAR(500) + - column: + name: tags_json + type: VARCHAR(2048) + - column: + name: warnings_json + type: VARCHAR(4096) + - column: + name: dangerous + type: BIT + - column: + defaultValueComputed: 'NULL' + name: max_severity + type: INT + - column: + name: suggested_filename + type: VARCHAR(255) + - column: + defaultValueComputed: 'NULL' + name: generated_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() + name: created_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() on update current_timestamp() + name: updated_at + type: TIMESTAMP(0) + tableName: pastefy_paste_ai_info +- changeSet: + id: 1780596708247-6 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: false + primaryKey: true + name: id + type: INT + - column: + constraints: + nullable: false + name: paste + type: VARCHAR(8) + - column: + constraints: + nullable: false + name: user_id + type: VARCHAR(8) + - column: + defaultValueComputed: 'NULL' + name: parent_id + type: INT + - column: + constraints: + nullable: false + name: content + type: VARCHAR(2000) + - column: + defaultValueComputed: 'NULL' + name: line_from + type: INT + - column: + defaultValueComputed: 'NULL' + name: line_to + type: INT + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() + name: created_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() on update current_timestamp() + name: updated_at + type: TIMESTAMP(0) + tableName: pastefy_paste_comments +- changeSet: + id: 1780596708247-7 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: false + primaryKey: true + name: id + type: INT + - column: + name: paste + type: VARCHAR(8) + - column: + name: user_id + type: VARCHAR(8) + tableName: pastefy_paste_stars +- changeSet: + id: 1780596708247-8 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: false + primaryKey: true + name: id + type: INT + - column: + name: paste + type: VARCHAR(8) + - column: + name: tag + type: VARCHAR(30) + tableName: pastefy_paste_tags +- changeSet: + id: 1780596708247-9 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: false + primaryKey: true + name: id + type: INT + - column: + name: key + type: VARCHAR(8) + - column: + name: title + type: TEXT + - column: + name: content + type: LONGTEXT + - column: + name: user_id + type: VARCHAR(8) + - column: + name: encrypted + type: BIT + - column: + name: folder + type: VARCHAR(8) + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() + name: created_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: CURRENT_TIMESTAMP + name: updated_at + type: TIMESTAMP(0) + - column: + defaultValue: 'PASTE' + name: type + type: ENUM('PASTE', 'MULTI_PASTE') + - column: + name: forked_from + type: VARCHAR(8) + - column: + defaultValue: 'UNLISTED' + name: visibility + type: ENUM('UNLISTED', 'PUBLIC', 'PRIVATE') + - column: + defaultValueComputed: 'NULL' + name: expire_at + type: TIMESTAMP(0) + - column: + defaultValue: 'DATABASE' + name: storage_type + type: ENUM('DATABASE', 'S3', 'HTTP') + - column: + defaultValueComputed: 'NULL' + name: version + type: INT + - column: + name: indexed_in_elastic + type: BIT + - column: + defaultValueComputed: 'NULL' + name: length + type: INT + - column: + name: hash + type: VARCHAR(64) + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-10 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: false + primaryKey: true + name: id + type: INT + - column: + defaultValueComputed: 'NULL' + name: paste_id + type: INT + - column: + defaultValueComputed: 'NULL' + name: score + type: INT + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() on update current_timestamp() + name: created_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: CURRENT_TIMESTAMP + name: updated_at + type: TIMESTAMP(0) + tableName: pastefy_public_paste_engagements +- changeSet: + id: 1780596708247-11 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + autoIncrement: true + constraints: + nullable: false + primaryKey: true + name: id + type: INT + - column: + name: user_id + type: VARCHAR(8) + - column: + name: target_id + type: VARCHAR(8) + - column: + name: paste + type: TEXT + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() on update current_timestamp() + name: created_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: CURRENT_TIMESTAMP + name: updated_at + type: TIMESTAMP(0) + tableName: pastefy_shared_pastes +- changeSet: + id: 1780596708247-12 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + constraints: + nullable: false + primaryKey: true + name: tag + type: VARCHAR(30) + - column: + name: display_name + type: TEXT + - column: + name: image_url + type: TEXT + - column: + name: description + type: TEXT + - column: + name: website + type: TEXT + - column: + name: icon + type: TEXT + - column: + defaultValueComputed: 'NULL' + name: paste_count + type: INT + tableName: pastefy_tag_listing +- changeSet: + id: 1780596708247-13 + author: juliangojani (generated) + changes: + - createTable: + columns: + - column: + constraints: + nullable: false + primaryKey: true + name: id + type: VARCHAR(8) + - column: + name: name + type: TEXT + - column: + name: unique_name + type: VARCHAR(33) + - column: + name: email + type: TEXT + - column: + name: avatar + type: TEXT + - column: + name: auth_id + type: VARCHAR(455) + - column: + name: auth_provider + type: ENUM('INTERAAPPS', 'GOOGLE', 'GITHUB', 'TWITCH', 'DISCORD') + - column: + constraints: + nullable: false + defaultValueComputed: current_timestamp() on update current_timestamp() + name: created_at + type: TIMESTAMP(0) + - column: + constraints: + nullable: false + defaultValueComputed: CURRENT_TIMESTAMP + name: updated_at + type: TIMESTAMP(0) + - column: + defaultValue: 'USER' + name: type + type: ENUM('USER', 'ADMIN', 'BLOCKED', 'AWAITING_ACCESS') + tableName: pastefy_users +- changeSet: + id: 1780596708247-14 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: user_id + indexName: pastefy_auth_keys_user_id_index + tableName: pastefy_auth_keys +- changeSet: + id: 1780596708247-15 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: status + - column: + defaultValueComputed: 'NULL' + name: available_at + indexName: pastefy_background_jobs_status_available_at_index + tableName: pastefy_background_jobs +- changeSet: + id: 1780596708247-16 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: status + - column: + defaultValueComputed: 'NULL' + name: lease_until + indexName: pastefy_background_jobs_status_lease_until_index + tableName: pastefy_background_jobs +- changeSet: + id: 1780596708247-17 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: type + - column: + defaultValueComputed: 'NULL' + name: entity_id + indexName: pastefy_background_jobs_type_entity_id_index + tableName: pastefy_background_jobs +- changeSet: + id: 1780596708247-18 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: key + indexName: pastefy_folder_key_index + tableName: pastefy_folder +- changeSet: + id: 1780596708247-19 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: user_id + indexName: pastefy_folder_user_id_index + tableName: pastefy_folder +- changeSet: + id: 1780596708247-20 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: user_id + - column: + name: received + indexName: pastefy_notification_user_id_received_index + tableName: pastefy_notification +- changeSet: + id: 1780596708247-21 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + defaultValueComputed: 'NULL' + name: parent_id + indexName: pastefy_paste_comments_parent_id_index + tableName: pastefy_paste_comments +- changeSet: + id: 1780596708247-22 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: paste + indexName: pastefy_paste_comments_paste_index + tableName: pastefy_paste_comments +- changeSet: + id: 1780596708247-23 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: paste + - column: + defaultValueComputed: 'NULL' + name: line_from + indexName: pastefy_paste_comments_paste_line_index + tableName: pastefy_paste_comments +- changeSet: + id: 1780596708247-24 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: user_id + indexName: pastefy_paste_comments_user_id_index + tableName: pastefy_paste_comments +- changeSet: + id: 1780596708247-25 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: paste + indexName: pastefy_paste_stars_paste_index + tableName: pastefy_paste_stars +- changeSet: + id: 1780596708247-26 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: paste + - column: + name: user_id + indexName: pastefy_paste_stars_paste_user_id_index + tableName: pastefy_paste_stars +- changeSet: + id: 1780596708247-27 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: paste + - column: + name: tag + indexName: pastefy_paste_tags_paste_tag_index + tableName: pastefy_paste_tags +- changeSet: + id: 1780596708247-28 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + defaultValueComputed: 'NULL' + name: expire_at + indexName: pastefy_pastes_expire_at_index + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-29 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: folder + indexName: pastefy_pastes_folder_index + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-30 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: hash + indexName: pastefy_pastes_hash_index + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-31 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: indexed_in_elastic + indexName: pastefy_pastes_indexed_in_elastic_index + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-32 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: key + indexName: pastefy_pastes_key_index + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-33 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + defaultValueComputed: 'NULL' + name: length + indexName: pastefy_pastes_length_index + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-34 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: storage_type + indexName: pastefy_pastes_storage_type_index + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-35 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: user_id + - column: + name: folder + indexName: pastefy_pastes_user_folder_index + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-36 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: user_id + indexName: pastefy_pastes_user_id_index + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-37 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: visibility + indexName: pastefy_pastes_visibility_index + tableName: pastefy_pastes +- changeSet: + id: 1780596708247-38 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + defaultValueComputed: 'NULL' + name: paste_id + - column: + defaultValueComputed: 'NULL' + name: score + indexName: pastefy_public_paste_engagements_id_score_index + tableName: pastefy_public_paste_engagements +- changeSet: + id: 1780596708247-39 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + defaultValueComputed: 'NULL' + name: paste_id + indexName: pastefy_public_paste_engagements_paste_id_index + tableName: pastefy_public_paste_engagements +- changeSet: + id: 1780596708247-40 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: target_id + indexName: pastefy_shared_pastes_target_id_index + tableName: pastefy_shared_pastes +- changeSet: + id: 1780596708247-41 + author: juliangojani (generated) + changes: + - createIndex: + associatedWith: '' + columns: + - column: + name: user_id + indexName: pastefy_shared_pastes_user_id_index + tableName: pastefy_shared_pastes + diff --git a/backend-kt/src/main/resources/db/changelog/002-json-transformations.yaml b/backend-kt/src/main/resources/db/changelog/002-json-transformations.yaml new file mode 100644 index 00000000..df61051d --- /dev/null +++ b/backend-kt/src/main/resources/db/changelog/002-json-transformations.yaml @@ -0,0 +1,18 @@ +databaseChangeLog: + - changeSet: + id: 002-auth-keys-scopes-json + author: juliangojani + preConditions: + - onFail: HALT + - sqlCheck: + expectedResult: 0 + sql: > + SELECT COUNT(*) + FROM pastefy_auth_keys + WHERE scopes IS NOT NULL + AND JSON_VALID(scopes) = 0 + changes: + - modifyDataType: + tableName: pastefy_auth_keys + columnName: scopes + newDataType: JSON \ No newline at end of file diff --git a/backend-kt/src/main/resources/db/changelog/db.changelog-master.yaml b/backend-kt/src/main/resources/db/changelog/db.changelog-master.yaml new file mode 100644 index 00000000..98a68c59 --- /dev/null +++ b/backend-kt/src/main/resources/db/changelog/db.changelog-master.yaml @@ -0,0 +1,7 @@ +databaseChangeLog: + - include: + file: 001-baseline.yaml + relativeToChangelogFile: true + - include: + file: 002-json-transformations.yaml + relativeToChangelogFile: true \ No newline at end of file diff --git a/backend-kt/src/main/resources/db/migration/clickhouse/V1__create_analytics_visits.sql b/backend-kt/src/main/resources/db/migration/clickhouse/V1__create_analytics_visits.sql new file mode 100644 index 00000000..8a4a36a2 --- /dev/null +++ b/backend-kt/src/main/resources/db/migration/clickhouse/V1__create_analytics_visits.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS `${analyticsDatabase}`.`${analyticsTable}` +( + paste_key FixedString(8), + paste_visibility LowCardinality(String), + paste_user_id Nullable(FixedString(8)), + visit_type LowCardinality(String), + visited_at DateTime64(3, 'UTC') CODEC(DoubleDelta, ZSTD(1)), + country LowCardinality(String), + region LowCardinality(String), + city LowCardinality(String), + visitor_user_id Nullable(FixedString(8)), + browser LowCardinality(String), + device_type LowCardinality(String), + os LowCardinality(String), + ip_hash Nullable(UInt64), + referer_host String, + acquisition LowCardinality(String), + is_bot UInt8 +) +ENGINE = MergeTree +PARTITION BY toYYYYMM(visited_at) +ORDER BY (paste_key, visited_at) +TTL visited_at + INTERVAL ${retentionDays} DAY DELETE; \ No newline at end of file diff --git a/backend-kt/src/main/resources/fonts/JetBrainsMono.ttf b/backend-kt/src/main/resources/fonts/JetBrainsMono.ttf new file mode 100644 index 00000000..b60e77f5 Binary files /dev/null and b/backend-kt/src/main/resources/fonts/JetBrainsMono.ttf differ diff --git a/backend-kt/src/main/resources/fonts/PlusJakartaSans.ttf b/backend-kt/src/main/resources/fonts/PlusJakartaSans.ttf new file mode 100644 index 00000000..bdd49850 Binary files /dev/null and b/backend-kt/src/main/resources/fonts/PlusJakartaSans.ttf differ diff --git a/backend-kt/src/main/resources/static/index.html b/backend-kt/src/main/resources/static/index.html new file mode 100644 index 00000000..2f099e52 --- /dev/null +++ b/backend-kt/src/main/resources/static/index.html @@ -0,0 +1,56 @@ + + + + + + + + + + + Pastefy + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + diff --git a/backend-kt/src/main/resources/templates/thumbnail-svg.svg b/backend-kt/src/main/resources/templates/thumbnail-svg.svg new file mode 100644 index 00000000..74dd8695 --- /dev/null +++ b/backend-kt/src/main/resources/templates/thumbnail-svg.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + %title% + + 1 + %content_1% + + 2 + %content_2% + + 3 + %content_3% + + 4 + %content_4% + + 5 + %content_5% + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend-kt/src/test/http/controller-parity.http b/backend-kt/src/test/http/controller-parity.http new file mode 100644 index 00000000..3006f1d2 --- /dev/null +++ b/backend-kt/src/test/http/controller-parity.http @@ -0,0 +1,36 @@ +@host = http://localhost:8080 +@authKey = replace-me +@pasteId = replace-me +@folderId = replace-me +@tag = kotlin + +### Stats +GET {{host}}/api/v2/app/stats +Accept: application/json + +### Folder list +GET {{host}}/api/v2/folder?page=1&page_limit=10 +Accept: application/json +X-Auth-Key: {{authKey}} + +### Folder detail +GET {{host}}/api/v2/folder/{{folderId}} +Accept: application/json +X-Auth-Key: {{authKey}} + +### Paste comments +GET {{host}}/api/v2/paste/{{pasteId}}/comments?page=1&page_limit=10 +Accept: application/json +X-Auth-Key: {{authKey}} + +### Raw paste +GET {{host}}/{{pasteId}}/raw +Accept: text/plain + +### Thumbnail +GET {{host}}/{{pasteId}}/thumbnail.png +Accept: image/png + +### Tag SEO metadata +GET {{host}}/tags/{{tag}} +Accept: text/html diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/controller/ControllerHttpTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/controller/ControllerHttpTest.kt new file mode 100644 index 00000000..132f8e25 --- /dev/null +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/controller/ControllerHttpTest.kt @@ -0,0 +1,255 @@ +package de.interaapps.pastefy.controller + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import de.interaapps.pastefy.auth.annotations.CurrentAuthKey +import de.interaapps.pastefy.auth.annotations.CurrentUser +import de.interaapps.pastefy.auth.oauth.OAuth2ProviderRegistry +import de.interaapps.pastefy.config.PastefyProperties +import de.interaapps.pastefy.controller.public.PublicUserController +import de.interaapps.pastefy.controller.seo.PasteMetaSSRController +import de.interaapps.pastefy.controller.pastes.PasteRawController +import de.interaapps.pastefy.controller.stats.StatsController +import de.interaapps.pastefy.controller.user.UserController +import de.interaapps.pastefy.dto.app.StatsResponse +import de.interaapps.pastefy.entities.AuthKey +import de.interaapps.pastefy.entities.User +import de.interaapps.pastefy.infrastructure.ai.PasteAI +import de.interaapps.pastefy.infrastructure.analytics.AnalyticsService +import de.interaapps.pastefy.repositories.PasteAIInfoRepository +import de.interaapps.pastefy.repositories.PasteRepository +import de.interaapps.pastefy.repositories.SharedPasteRepository +import de.interaapps.pastefy.service.FolderService +import de.interaapps.pastefy.service.FrontendIndexService +import de.interaapps.pastefy.service.PasteService +import de.interaapps.pastefy.service.PasteQueryService +import de.interaapps.pastefy.service.PublicPasteEngagementService +import de.interaapps.pastefy.service.SeoPageCacheService +import de.interaapps.pastefy.service.SeoPageContentService +import de.interaapps.pastefy.service.SeoRenderer +import de.interaapps.pastefy.service.StatsService +import de.interaapps.pastefy.service.UserService +import jakarta.servlet.RequestDispatcher +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.springframework.beans.factory.support.StaticListableBeanFactory +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter +import org.springframework.http.converter.StringHttpMessageConverter +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer +import org.springframework.core.MethodParameter +import org.springframework.web.bind.support.WebDataBinderFactory + +class ControllerHttpTest { + private val objectMapper = ObjectMapper().setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + + @Test + fun `raw endpoint returns legacy plain text 404`() { + val pasteService = mock(PasteService::class.java) + `when`(pasteService.get("missing")).thenReturn(null) + val mvc = mockMvc( + PasteRawController( + pasteService, + mock(PublicPasteEngagementService::class.java), + StaticListableBeanFactory().getBeanProvider(AnalyticsService::class.java), + objectMapper, + ), + ) + + mvc.get("/missing/raw") + .andExpect { + status { isNotFound() } + content { contentType("text/plain;charset=UTF-8") } + content { string("404 - Paste not found") } + } + } + + @Test + fun `stats endpoint serializes snake case response`() { + val stats = mock(StatsService::class.java) + `when`(stats.get()).thenReturn(StatsResponse(createdPastes = 12, loggedInPastes = 5)) + val mvc = mockMvc(StatsController(PastefyProperties(publicStats = true), stats)) + + mvc.get("/api/v2/app/stats") + .andExpect { + status { isOk() } + jsonPath("$.created_pastes") { value(12) } + jsonPath("$.logged_in_pastes") { value(5) } + jsonPath("$.createdPastes") { doesNotExist() } + } + } + + @Test + fun `app info serializes configuration using snake case`() { + val beans = StaticListableBeanFactory() + val properties = PastefyProperties( + customName = "Pastefy", + encryptionDefault = true, + publicPastesEnabled = true, + ) + val mvc = mockMvc( + AppController( + properties, + beans.getBeanProvider(PasteAI::class.java), + beans.getBeanProvider(AnalyticsService::class.java), + ), + ) + + mvc.get("/api/v2/app/info") + .andExpect { + status { isOk() } + jsonPath("$.custom_name") { value("Pastefy") } + jsonPath("$.encryption_is_default") { value(true) } + jsonPath("$.public_pastes_enabled") { value(true) } + jsonPath("$.ai_enabled") { value(false) } + } + } + + @Test + fun `public user route returns legacy public shape`() { + val users = mock(UserService::class.java) + `when`(users.getByName("julian")).thenReturn( + User( + id = "user-01", + uniqueName = "julian", + name = "Julian", + ), + ) + val mvc = mockMvc(PublicUserController(users)) + + mvc.get("/api/v2/public/user/julian") + .andExpect { + status { isOk() } + jsonPath("$.name") { value("julian") } + jsonPath("$.display_name") { value("Julian") } + } + } + + @Test + fun `logged out user response still exposes configured auth types`() { + val providers = mock(OAuth2ProviderRegistry::class.java) + `when`(providers.names()).thenReturn(linkedSetOf("interaapps", "github")) + val mvc = mockMvc( + UserController( + mock(UserService::class.java), + mock(FolderService::class.java), + mock(PasteQueryService::class.java), + mock(PasteRepository::class.java), + mock(SharedPasteRepository::class.java), + providers, + ), + ) + + mvc.get("/api/v2/user") + .andExpect { + status { isOk() } + jsonPath("$.logged_in") { value(false) } + jsonPath("$.auth_types[0]") { value("interaapps") } + jsonPath("$.auth_types[1]") { value("github") } + } + } + + @Test + fun `frontend root serves prepared index`() { + val properties = PastefyProperties(customHeader = "") + val mvc = mockMvc(FrontendController(FrontendIndexService(properties))) + + mvc.get("/") + .andExpect { + status { isOk() } + content { contentType("text/html;charset=UTF-8") } + content { string(org.hamcrest.Matchers.containsString("
")) } + content { string(org.hamcrest.Matchers.containsString("")) } + } + } + + @Test + fun `frontend error fallback serves prepared index for spa routes`() { + val mvc = mockMvc(FrontendController(FrontendIndexService(PastefyProperties()))) + + mvc.get("/error") { + requestAttr(RequestDispatcher.ERROR_STATUS_CODE, 404) + requestAttr(RequestDispatcher.ERROR_REQUEST_URI, "/fewnadscfknjajsf/whrsdefncwas") + }.andExpect { + status { isOk() } + content { contentType("text/html;charset=UTF-8") } + content { string(org.hamcrest.Matchers.containsString("
")) } + } + } + + @Test + fun `frontend fallback does not swallow api routes`() { + val mvc = mockMvc(FrontendController(FrontendIndexService(PastefyProperties()))) + + mvc.get("/error") { + requestAttr(RequestDispatcher.ERROR_STATUS_CODE, 404) + requestAttr(RequestDispatcher.ERROR_REQUEST_URI, "/api/does-not-exist") + } + .andExpect { + status { isNotFound() } + } + } + + @Test + fun `frontend error fallback also serves prepared index for missing assets`() { + val mvc = mockMvc(FrontendController(FrontendIndexService(PastefyProperties()))) + + mvc.get("/error") { + requestAttr(RequestDispatcher.ERROR_STATUS_CODE, 404) + requestAttr(RequestDispatcher.ERROR_REQUEST_URI, "/assets/missing.js") + }.andExpect { + status { isOk() } + content { contentType("text/html;charset=UTF-8") } + } + } + + @Test + fun `missing paste seo route falls back to frontend index`() { + val pasteService = mock(PasteService::class.java) + `when`(pasteService.get("missing1")).thenReturn(null) + val properties = PastefyProperties() + val frontend = FrontendIndexService(properties) + val mvc = mockMvc( + PasteMetaSSRController( + pasteService, + mock(PasteAIInfoRepository::class.java), + properties, + SeoRenderer(properties, frontend), + mock(SeoPageCacheService::class.java), + mock(SeoPageContentService::class.java), + frontend, + ), + ) + + mvc.get("/missing1") + .andExpect { + status { isOk() } + content { contentType("text/html;charset=UTF-8") } + content { string(org.hamcrest.Matchers.containsString("
")) } + } + } + + private fun mockMvc(controller: Any): MockMvc = + MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(NullableAuthArgumentResolver()) + .setMessageConverters(StringHttpMessageConverter(), MappingJackson2HttpMessageConverter(objectMapper)) + .build() +} + +private class NullableAuthArgumentResolver : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean = + parameter.hasParameterAnnotation(CurrentUser::class.java) && parameter.parameterType == User::class.java || + parameter.hasParameterAnnotation(CurrentAuthKey::class.java) && parameter.parameterType == AuthKey::class.java + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): Any? = null +} diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/repositories/PasteCommentRepositoryIntegrationTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/repositories/PasteCommentRepositoryIntegrationTest.kt new file mode 100644 index 00000000..d8a0567f --- /dev/null +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/repositories/PasteCommentRepositoryIntegrationTest.kt @@ -0,0 +1,147 @@ +package de.interaapps.pastefy.repositories + +import com.fasterxml.jackson.databind.ObjectMapper +import de.interaapps.pastefy.entities.AuthKey +import de.interaapps.pastefy.entities.Paste +import de.interaapps.pastefy.entities.PasteComment +import de.interaapps.pastefy.entities.PasteStar +import de.interaapps.pastefy.entities.PublicPasteEngagement +import de.interaapps.pastefy.enums.PasteVisibility +import de.interaapps.pastefy.config.PastefyProperties +import de.interaapps.pastefy.service.query.JpaPasteQueryAdapter +import de.interaapps.pastefy.service.query.LegacyPasteQueryParser +import jakarta.persistence.EntityManager +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.data.domain.PageRequest +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.testcontainers.containers.MySQLContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import java.time.Instant + +@DataJpaTest( + properties = [ + "spring.jpa.hibernate.ddl-auto=create", + "spring.liquibase.enabled=false", + ], +) +@Testcontainers(disabledWithoutDocker = true) +class PasteCommentRepositoryIntegrationTest( + @Autowired private val comments: PasteCommentRepository, + @Autowired private val pastes: PasteRepository, + @Autowired private val engagements: PublicPasteEngagementRepository, + @Autowired private val authKeys: AuthKeyRepository, + @Autowired private val stars: PasteStarRepository, + @Autowired private val entityManager: EntityManager, +) { + @Test + fun `loads top level comments newest first and replies oldest first`() { + val root = comments.save(comment("paste-01", "root", createdAt = Instant.parse("2026-01-01T00:00:00Z"))) + comments.save(comment("paste-01", "reply-2", parentId = root.id, createdAt = Instant.parse("2026-01-03T00:00:00Z"))) + comments.save(comment("paste-01", "reply-1", parentId = root.id, createdAt = Instant.parse("2026-01-02T00:00:00Z"))) + + assertEquals( + listOf("root"), + comments.findAllByPasteAndParentIdIsNullOrderByCreatedAtDesc("paste-01", PageRequest.of(0, 10)).map { it.content }, + ) + assertEquals( + listOf("reply-1", "reply-2"), + comments.findAllByParentIdOrderByCreatedAtAsc(requireNotNull(root.id)).map { it.content }, + ) + } + + @Test + fun `orders trending public pastes by engagement score`() { + val low = pastes.save( + Paste(key = "trend001", title = "low", visibility = PasteVisibility.PUBLIC).apply { + setDatabaseContent("low") + }, + ) + val high = pastes.save( + Paste(key = "trend002", title = "high", visibility = PasteVisibility.PUBLIC).apply { + setDatabaseContent("high") + }, + ) + engagements.save(PublicPasteEngagement(pasteId = requireNotNull(low.id), score = 1)) + engagements.save(PublicPasteEngagement(pasteId = requireNotNull(high.id), score = 9)) + + assertEquals( + listOf("trend002", "trend001"), + pastes.findTrending(null, PageRequest.of(0, 10)).map(Paste::key), + ) + } + + @Test + fun `loads api keys by user and type`() { + authKeys.save(AuthKey(key = "api-key", userId = "user-001", type = AuthKey.Type.API)) + + assertEquals( + listOf("api-key"), + authKeys.findAllByUserIdAndType("user-001", AuthKey.Type.API).map(AuthKey::key), + ) + } + + @Test + fun `legacy paste query filters with bracket parameters`() { + pastes.save(paste("query001", "Kotlin Public", PasteVisibility.PUBLIC)) + pastes.save(paste("query002", "Kotlin Private", PasteVisibility.PRIVATE)) + entityManager.flush() + + val request = MockHttpServletRequest().apply { + addParameter("search", "kotlin") + addParameter("filter[visibility]", "PUBLIC") + } + + assertEquals( + listOf("query001"), + queryAdapter().find(parser().parse(request, MockHttpServletResponse(), user = null, guarded = false)).map(Paste::key), + ) + } + + @Test + fun `legacy paste query supports starredBy filters json`() { + val paste = pastes.save(paste("query003", "Starred Paste", PasteVisibility.UNLISTED)) + stars.save(PasteStar(paste = paste.key, userId = "user-001")) + entityManager.flush() + + val request = MockHttpServletRequest().apply { + addParameter("filters", """{"starredBy":"user-001"}""") + } + + assertEquals( + listOf("query003"), + queryAdapter().find(parser().parse(request, MockHttpServletResponse(), user = null, guarded = false)).map(Paste::key), + ) + } + + private fun comment(paste: String, content: String, parentId: Int? = null, createdAt: Instant) = + PasteComment(paste = paste, userId = "user-001", content = content, parentId = parentId, createdAt = createdAt) + + private fun paste(key: String, title: String, visibility: PasteVisibility): Paste = + Paste(key = key, title = title, visibility = visibility).apply { setDatabaseContent(title.lowercase()) } + + private fun parser() = LegacyPasteQueryParser(ObjectMapper(), PastefyProperties()) + + private fun queryAdapter() = JpaPasteQueryAdapter(entityManager) + + companion object { + @Container + @JvmStatic + val mysql = MySQLContainer("mysql:8.4") + + @DynamicPropertySource + @JvmStatic + fun datasource(registry: DynamicPropertyRegistry) { + registry.add("spring.datasource.url", mysql::getJdbcUrl) + registry.add("spring.datasource.username", mysql::getUsername) + registry.add("spring.datasource.password", mysql::getPassword) + registry.add("spring.datasource.driver-class-name", mysql::getDriverClassName) + } + } +} diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/FolderServiceTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/FolderServiceTest.kt new file mode 100644 index 00000000..a1523995 --- /dev/null +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/FolderServiceTest.kt @@ -0,0 +1,30 @@ +package de.interaapps.pastefy.service + +import de.interaapps.pastefy.config.PastefyProperties +import de.interaapps.pastefy.exceptions.PermissionsDeniedException +import de.interaapps.pastefy.repositories.FolderRepository +import de.interaapps.pastefy.repositories.PasteRepository +import de.interaapps.pastefy.service.query.LegacyFilterSpecificationBuilder +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.Mockito.mock +import org.springframework.mock.web.MockHttpServletRequest + +class FolderServiceTest { + @Test + fun `rejects anonymous folder listings unless public listing is enabled`() { + val service = FolderService( + mock(FolderRepository::class.java), + mock(PasteRepository::class.java), + mock(PasteService::class.java), + mock(PasteResponseMapper::class.java), + mock(PasteMetricsService::class.java), + mock(LegacyFilterSpecificationBuilder::class.java), + PastefyProperties(listPastes = false), + ) + + assertThrows { + service.list(MockHttpServletRequest(), null) + } + } +} diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/InteraAppsExternalAccessServiceTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/InteraAppsExternalAccessServiceTest.kt new file mode 100644 index 00000000..bbf42c04 --- /dev/null +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/InteraAppsExternalAccessServiceTest.kt @@ -0,0 +1,45 @@ +package de.interaapps.pastefy.service + +import de.interaapps.pastefy.auth.oauth.OAuth2Provider +import de.interaapps.pastefy.auth.oauth.OAuth2ProviderRegistry +import de.interaapps.pastefy.config.PastefyProperties +import de.interaapps.pastefy.dto.auth.InteraAppsExternalAccessRequest +import de.interaapps.pastefy.entities.AuthKey +import de.interaapps.pastefy.entities.User +import de.interaapps.pastefy.repositories.AuthKeyRepository +import de.interaapps.pastefy.repositories.UserRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` + +class InteraAppsExternalAccessServiceTest { + @Test + fun `creates access token with requested app scopes`() { + val properties = PastefyProperties().apply { + oauth.interaapps.clientId = "app-id" + oauth.interaapps.clientSecret = "app-secret" + } + val providers = mock(OAuth2ProviderRegistry::class.java) + val users = mock(UserRepository::class.java) + val keys = mock(AuthKeyRepository::class.java) + `when`(providers.get("interaapps")).thenReturn(mock(OAuth2Provider::class.java)) + `when`(users.findByAuthIdAndAuthProvider("external-user", User.AuthenticationProvider.INTERAAPPS)) + .thenReturn(User(id = "user-123", authId = "external-user", authProvider = User.AuthenticationProvider.INTERAAPPS)) + `when`(keys.save(any(AuthKey::class.java))).thenAnswer { invocation -> + invocation.getArgument(0).apply { key = "issued-key" } + } + + val key = InteraAppsExternalAccessService(properties, providers, users, keys).issue( + InteraAppsExternalAccessRequest( + appId = "app-id", + appSecret = "app-secret", + appScopeList = listOf("pastes:read", "comments:create"), + userId = "external-user", + ), + ) + + assertEquals("issued-key", key) + } +} diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/PasteCommentServiceTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/PasteCommentServiceTest.kt new file mode 100644 index 00000000..22ba6d0e --- /dev/null +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/PasteCommentServiceTest.kt @@ -0,0 +1,45 @@ +package de.interaapps.pastefy.service + +import de.interaapps.pastefy.dto.pastes.CreatePasteCommentRequest +import de.interaapps.pastefy.exceptions.HttpException +import de.interaapps.pastefy.repositories.PasteCommentRepository +import de.interaapps.pastefy.repositories.UserRepository +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.Mockito.mock + +class PasteCommentServiceTest { + private val service = PasteCommentService( + mock(PasteService::class.java), + mock(PasteCommentRepository::class.java), + mock(UserRepository::class.java), + mock(PasteMetricsService::class.java), + ) + + @Test + fun `accepts a valid line range`() { + assertDoesNotThrow { + service.validate(CreatePasteCommentRequest(content = "Useful note", lineFrom = 3, lineTo = 8)) + } + } + + @Test + fun `rejects blank comments`() { + val exception = assertThrows { + service.validate(CreatePasteCommentRequest(content = " ")) + } + + assertEquals("Comment content is required", exception.message) + } + + @Test + fun `rejects line to without line from`() { + val exception = assertThrows { + service.validate(CreatePasteCommentRequest(content = "note", lineTo = 3)) + } + + assertEquals("line_to requires line_from", exception.message) + } +} diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/PasteThumbnailServiceTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/PasteThumbnailServiceTest.kt new file mode 100644 index 00000000..77353898 --- /dev/null +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/PasteThumbnailServiceTest.kt @@ -0,0 +1,21 @@ +package de.interaapps.pastefy.service + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class PasteThumbnailServiceTest { + @Test + fun `renders png for xml-sensitive paste content`() { + val png = PasteThumbnailService().render("", "first & second\n<xml>") + + assertTrue(png.size > PNG_SIGNATURE.size) + assertArrayEquals(PNG_SIGNATURE, png.copyOf(PNG_SIGNATURE.size)) + } + + companion object { + private val PNG_SIGNATURE = byteArrayOf( + 0x89.toByte(), 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + ) + } +} diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/SeoRendererTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/SeoRendererTest.kt new file mode 100644 index 00000000..004da1a7 --- /dev/null +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/SeoRendererTest.kt @@ -0,0 +1,28 @@ +package de.interaapps.pastefy.service + +import de.interaapps.pastefy.config.PastefyProperties +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SeoRendererTest { + @Test + fun `injects escaped metadata and canonical url`() { + val properties = PastefyProperties(metaTagsEnabled = true, serverName = "https://pastefy.example/") + val renderer = SeoRenderer( + properties, + FrontendIndexService(properties), + ) + + val html = renderer.render( + renderer.page("/tags/kotlin", "Kotlin & JVM | Pastefy", "Public <pastes>") + .content("<main>content</main>"), + ) + + assertNotNull(html) + assertTrue(html!!.contains("<title>Kotlin & JVM | Pastefy")) + assertTrue(html.contains("")) + assertTrue(html.contains("content=\"Public <pastes>\"")) + assertTrue(html.contains("
content
")) + } +} diff --git a/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQueryParserTest.kt b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQueryParserTest.kt new file mode 100644 index 00000000..fb86ad58 --- /dev/null +++ b/backend-kt/src/test/kotlin/de/interaapps/pastefy/service/query/LegacyPasteQueryParserTest.kt @@ -0,0 +1,52 @@ +package de.interaapps.pastefy.service.query + +import com.fasterxml.jackson.databind.ObjectMapper +import de.interaapps.pastefy.config.PastefyProperties +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse + +class LegacyPasteQueryParserTest { + private val parser = LegacyPasteQueryParser(ObjectMapper(), PastefyProperties(paginationPageLimit = 25)) + + @Test + fun `parses legacy bracket filters and range operators`() { + val request = MockHttpServletRequest().apply { + addParameter("filter[visibility]", "PUBLIC") + addParameter("filter[createdAt][\$gt]", "2026-01-01T00:00:00Z") + addParameter("page_limit", "100") + addParameter("sort", "+title,createdAt") + } + val response = MockHttpServletResponse() + + val query = parser.parse(request, response, user = null, guarded = false) + val fields = query.filter.collectFields() + + assertEquals(25, query.pageLimit) + assertEquals("25", response.getHeader("PAGINATION_LIMIT")) + assertEquals(listOf(LegacySort("title", true), LegacySort("createdAt", false)), query.sorts) + assertTrue(fields.contains(LegacyFieldFilter("visibility", LegacyFilterOperator.EQ, "PUBLIC"))) + assertTrue(fields.contains(LegacyFieldFilter("createdAt", LegacyFilterOperator.GT, "2026-01-01T00:00:00Z"))) + } + + @Test + fun `parses filters json with or groups`() { + val request = MockHttpServletRequest().apply { + addParameter("filters", """{"${'$'}or":[{"visibility":"PUBLIC"},{"userId":"user-001"}]}""") + } + + val query = parser.parse(request, MockHttpServletResponse(), user = null, guarded = false) + val group = query.filter as LegacyFilterGroup + + assertEquals(LegacyGroupOperator.OR, group.operator) + assertEquals(2, group.children.size) + } + + private fun LegacyFilter?.collectFields(): List = when (this) { + null -> emptyList() + is LegacyFieldFilter -> listOf(this) + is LegacyFilterGroup -> children.flatMap { it.collectFields() } + } +} diff --git a/backend/src/main/java/de/interaapps/pastefy/model/database/Paste.java b/backend/src/main/java/de/interaapps/pastefy/model/database/Paste.java index ca4765da..15680ded 100644 --- a/backend/src/main/java/de/interaapps/pastefy/model/database/Paste.java +++ b/backend/src/main/java/de/interaapps/pastefy/model/database/Paste.java @@ -346,6 +346,7 @@ public void save() { super.save(); boolean bigEnoughForS3 = content != null && content.length() > Pastefy.getInstance().getConfig().getInt("minio.pastesize.threshold", -1); + if (Pastefy.getInstance().isMinioEnabled() && (storageType == StorageType.S3 || bigEnoughForS3)) { Pastefy.getInstance().executeAsync(() -> MinioPaste.store(this)); } diff --git a/frontend/index.html b/frontend/index.html index aa932ece..92916982 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,10 @@ + + + + Pastefy diff --git a/frontend/package.json b/frontend/package.json index e313dd4f..01273281 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,7 +18,7 @@ "@fontsource-variable/jetbrains-mono": "^5.2.5", "@fontsource-variable/plus-jakarta-sans": "^5.2.5", "@primeuix/themes": "^1.0.0", - "@tabler/icons-webfont": "^3.31.0", + "@tabler/icons-webfont": "^3.44.0", "@tailwindcss/vite": "^4.0.14", "@types/crypto-js": "^4.2.2", "@types/dom-to-image": "^2.6.7", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 08906140..ab18ca73 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -24,7 +24,7 @@ importers: specifier: ^1.0.0 version: 1.2.5 '@tabler/icons-webfont': - specifier: ^3.31.0 + specifier: ^3.44.0 version: 3.44.0 '@tailwindcss/vite': specifier: ^4.0.14 diff --git a/frontend/src/components/Highlighted.vue b/frontend/src/components/Highlighted.vue index 7b6ced72..f444d9e9 100644 --- a/frontend/src/components/Highlighted.vue +++ b/frontend/src/components/Highlighted.vue @@ -20,6 +20,7 @@ const props = defineProps<{ getCopyContents?: () => Promise lineCommentMarkers?: PasteCommentMarker[] enableLineComments?: boolean + small?: boolean }>() const emit = defineEmits<{ @@ -31,6 +32,7 @@ const markerByLine = computed( () => new Map(props.lineCommentMarkers?.map((marker) => [marker.line, marker]) || []), ) const lineCount = computed(() => { + if (!props.contents) return 0 let count = 1 for (let index = 0; index < props.contents.length; index++) { if (props.contents.charCodeAt(index) === 10) count++ @@ -135,8 +137,8 @@ onMounted(async () => {