diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml deleted file mode 100644 index 6bb91364..00000000 --- a/.github/workflows/coverage.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Coverage - -on: - pull_request: -permissions: - pull-requests: write - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'temurin' - - name: Run JUnit - uses: gradle/gradle-build-action@v2.8.1 - with: - arguments: koverXmlReport -x integrationTest - - name: Add coverage report to PR - if: always() - id: kover - uses: mi-kas/kover-report@v1 - with: - path: | - ${{ github.workspace }}/build/reports/kover/report.xml - token: ${{ secrets.GITHUB_TOKEN }} - title: Code Coverage - update-comment: true - min-coverage-overall: 80 - min-coverage-changed-files: 80 - coverage-counter-type: LINE diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml deleted file mode 100644 index 1788e609..00000000 --- a/.github/workflows/integration-test.yml +++ /dev/null @@ -1,67 +0,0 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. -# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle - -name: Integration Test - -on: - pull_request: - branches: [ "develop" ] - -permissions: - contents: read - checks: write - id-token: write - -jobs: - integration-test: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - name: Cache - uses: actions/cache@v3.3.2 - with: - path: ~/.gradle/wrapper - key: gradle-wrapper-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }} - - name: Cache - uses: actions/cache@v3.3.2 - with: - path: | - ~/.gradle/caches/jars-* - ~/.gradle/caches/transforms-* - ~/.gradle/caches/modules-* - key: gradle-dependencies-${{ steps.cache-key.outputs.week }}-${{ hashFiles('**/*.gradle.kts') }} - restore-keys: gradle-dependencies-${{ steps.cache-key.outputs.week }}- - - name: Cache - uses: actions/cache@v3.3.2 - with: - path: | - ~/.gradle/caches/build-cache-* - ~/.gradle/caches/[0-9]*.* - .gradle - key: ${{ runner.os }}-gradle-build-${{ github.workflow }}-${{ steps.cache-key.outputs.week }}-${{ github.sha }} - restore-keys: ${{ runner.os }}-gradle-build-${{ github.workflow }}-${{ steps.cache-key.outputs.week }}- - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'temurin' - - name: MongoDB in GitHub Actions - uses: supercharge/mongodb-github-action@1.10.0 - with: - mongodb-version: latest - - name: Run Integration Test - uses: gradle/gradle-build-action@v2.8.1 - with: - arguments: integrationTest - - name: Publish Test Report - uses: mikepenz/action-junit-report@v2 - if: always() - with: - report_paths: '**/build/test-results/test/TEST-*.xml' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 745cde8e..00000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,61 +0,0 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. -# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle - -name: Lint - -on: - pull_request: - branches: [ "develop" ] - -permissions: - contents: read - pull-requests: write - -jobs: - lint: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Cache - uses: actions/cache@v3.3.2 - with: - path: ~/.gradle/wrapper - key: gradle-wrapper-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }} - - name: Cache - uses: actions/cache@v3.3.2 - with: - path: | - ~/.gradle/caches/jars-* - ~/.gradle/caches/transforms-* - ~/.gradle/caches/modules-* - key: gradle-dependencies-${{ steps.cache-key.outputs.week }}-${{ hashFiles('**/*.gradle.kts') }} - restore-keys: gradle-dependencies-${{ steps.cache-key.outputs.week }}- - - name: Cache - uses: actions/cache@v3.3.2 - with: - path: | - ~/.gradle/caches/build-cache-* - ~/.gradle/caches/[0-9]*.* - .gradle - key: ${{ runner.os }}-gradle-build-${{ github.workflow }}-${{ steps.cache-key.outputs.week }}-${{ github.sha }} - restore-keys: ${{ runner.os }}-gradle-build-${{ github.workflow }}-${{ steps.cache-key.outputs.week }}- - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'temurin' - - name: Build with Gradle - uses: gradle/gradle-build-action@67421db6bd0bf253fb4bd25b31ebb98943c375e1 - with: - arguments: detektMain - - name: "reviewdog-suggester: Suggest any code changes based on diff with reviewdog" - if: ${{ always() }} - uses: reviewdog/action-suggester@v1 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pull-request-merge-check.yml b/.github/workflows/pull-request-merge-check.yml new file mode 100644 index 00000000..7d1a26f2 --- /dev/null +++ b/.github/workflows/pull-request-merge-check.yml @@ -0,0 +1,412 @@ +name: PullRequest Merge Check + +on: + pull_request: + branches: + - "develop" + + +permissions: + contents: read + checks: write + id-token: write + pull-requests: write + +jobs: + setup: + name: Setup + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Gradle Wrapper Cache + uses: actions/cache@v3.3.2 + with: + path: ~/.gradle/wrapper + key: gradle-wrapper-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }} + + - name: Dependencies Cache + uses: actions/cache@v3.3.2 + with: + path: | + ~/.gradle/cache/jars-* + ~/.gradle/caches/transforms-* + ~/.gradle/caches/modules-* + key: gradle-dependencies-${{ hashFiles('**/*.gradle.kts') }} + restore-keys: gradle-dependencies- + + - name: Cache + uses: actions/cache@v3.3.2 + with: + path: | + ~/.gradle/caches/build-cache-* + ~/.gradle/caches/[0-9]*.* + .gradle + key: ${{ runner.os }}-gradle-build-${{ github.workflow }}-${{ github.sha }} + restore-keys: ${{ runner.os }}-gradle-build-${{ github.workflow }}- + + - name: Build Cache + uses: actions/cache@v3.3.2 + with: + path: | + build + key: gradle-build-${{ hashFiles('**/*.gradle.kts') }}-${{ hashFiles('**/*.kt') }}-${{ github.sha }} + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + - name: Build + uses: gradle/gradle-build-action@v2.8.1 + with: + arguments: testClasses + + unit-test: + name: Unit Test + needs: [ setup ] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Gradle Wrapper Cache + uses: actions/cache@v3.3.2 + with: + path: ~/.gradle/wrapper + key: gradle-wrapper-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }} + + - name: Dependencies Cache + uses: actions/cache@v3.3.2 + with: + path: | + ~/.gradle/cache/jars-* + ~/.gradle/caches/transforms-* + ~/.gradle/caches/modules-* + key: gradle-dependencies-${{ hashFiles('**/*.gradle.kts') }} + restore-keys: gradle-dependencies- + + - name: Cache + uses: actions/cache@v3.3.2 + with: + path: | + ~/.gradle/caches/build-cache-* + ~/.gradle/caches/[0-9]*.* + .gradle + key: ${{ runner.os }}-gradle-build-${{ github.workflow }}-${{ github.sha }} + restore-keys: ${{ runner.os }}-gradle-build-${{ github.workflow }}- + + - name: Build Cache + uses: actions/cache@v3.3.2 + with: + path: | + build + key: gradle-build-${{ hashFiles('**/*.gradle.kts') }}-${{ hashFiles('src') }}-${{ github.sha }} + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Unit Test + uses: gradle/gradle-build-action@v2.8.1 + with: + arguments: test + + - name: Save Test Report + if: always() + uses: actions/cache/save@v3 + with: + path: build/test-results + key: unit-test-report-${{ github.sha }} + + integration-test: + name: Integration Test + needs: [ setup ] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Gradle Wrapper Cache + uses: actions/cache@v3.3.2 + with: + path: ~/.gradle/wrapper + key: gradle-wrapper-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }} + + - name: Dependencies Cache + uses: actions/cache@v3.3.2 + with: + path: | + ~/.gradle/cache/jars-* + ~/.gradle/caches/transforms-* + ~/.gradle/caches/modules-* + key: gradle-dependencies-${{ hashFiles('**/*.gradle.kts') }} + restore-keys: gradle-dependencies- + + - name: Cache + uses: actions/cache@v3.3.2 + with: + path: | + ~/.gradle/caches/build-cache-* + ~/.gradle/caches/[0-9]*.* + .gradle + key: ${{ runner.os }}-gradle-build-${{ github.workflow }}-${{ github.sha }} + restore-keys: ${{ runner.os }}-gradle-build-${{ github.workflow }}- + + - name: Build Cache + uses: actions/cache@v3.3.2 + with: + path: | + build + key: gradle-build-${{ hashFiles('**/*.gradle.kts') }}-${{ hashFiles('src') }}-${{ github.sha }} + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: MongoDB in GitHub Actions + uses: supercharge/mongodb-github-action@1.10.0 + with: + mongodb-version: latest + + - name: Unit Test + uses: gradle/gradle-build-action@v2.8.1 + with: + arguments: integrationTest + + - name: Save Test Report + if: always() + uses: actions/cache/save@v3 + with: + path: build/test-results + key: integration-test-report-${{ github.sha }} + + coverage: + name: Coverage + needs: [ setup ] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Gradle Wrapper Cache + uses: actions/cache@v3.3.2 + with: + path: ~/.gradle/wrapper + key: gradle-wrapper-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }} + + - name: Dependencies Cache + uses: actions/cache@v3.3.2 + with: + path: | + ~/.gradle/cache/jars-* + ~/.gradle/caches/transforms-* + ~/.gradle/caches/modules-* + key: gradle-dependencies-${{ hashFiles('**/*.gradle.kts') }} + restore-keys: gradle-dependencies- + + - name: Cache + uses: actions/cache@v3.3.2 + with: + path: | + ~/.gradle/caches/build-cache-* + ~/.gradle/caches/[0-9]*.* + .gradle + key: ${{ runner.os }}-gradle-build-${{ github.workflow }}-${{ github.sha }} + restore-keys: ${{ runner.os }}-gradle-build-${{ github.workflow }}- + + - name: Build Cache + uses: actions/cache@v3.3.2 + with: + path: | + build + key: gradle-build-${{ hashFiles('**/*.gradle.kts') }}-${{ hashFiles('src') }}-${{ github.sha }} + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Run Kover + uses: gradle/gradle-build-action@v2.8.1 + with: + arguments: koverXmlReport -x integrationTest -x e2eTest + + - name: Add coverage report to PR + if: always() + id: kover + uses: mi-kas/kover-report@v1 + with: + path: | + ${{ github.workspace }}/build/reports/kover/report.xml + token: ${{ secrets.GITHUB_TOKEN }} + title: Code Coverage + update-comment: true + min-coverage-overall: 80 + min-coverage-changed-files: 80 + coverage-counter-type: LINE + + report-tests: + name: Report Tests + if: success() || failure() + needs: [ unit-test,integration-test,e2e-test ] + runs-on: ubuntu-latest + steps: + - name: Restore Test Report + uses: actions/cache/restore@v3 + with: + path: build/test-results + key: unit-test-report-${{ github.sha }} + + - name: Restore Test Report + uses: actions/cache/restore@v3 + with: + path: build/test-results + key: integration-test-report-${{ github.sha }} + + - name: Restore Test Report + uses: actions/cache/restore@v3 + with: + path: build/test-results + key: e2e-test-report-${{ github.sha }} + + - name: JUnit Test Report + uses: mikepenz/action-junit-report@v2 + with: + report_paths: '**/TEST-*.xml' + + lint: + name: Lint + needs: [ setup ] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Gradle Wrapper Cache + uses: actions/cache@v3.3.2 + with: + path: ~/.gradle/wrapper + key: gradle-wrapper-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }} + + - name: Dependencies Cache + uses: actions/cache@v3.3.2 + with: + path: | + ~/.gradle/cache/jars-* + ~/.gradle/caches/transforms-* + ~/.gradle/caches/modules-* + key: gradle-dependencies-${{ hashFiles('**/*.gradle.kts') }} + restore-keys: gradle-dependencies- + + - name: Cache + uses: actions/cache@v3.3.2 + with: + path: | + ~/.gradle/caches/build-cache-* + ~/.gradle/caches/[0-9]*.* + .gradle + key: ${{ runner.os }}-gradle-build-${{ github.workflow }}-${{ github.sha }} + restore-keys: ${{ runner.os }}-gradle-build-${{ github.workflow }}- + + - name: Build Cache + uses: actions/cache@v3.3.2 + with: + path: | + build + key: gradle-build-${{ hashFiles('**/*.gradle.kts') }}-${{ hashFiles('src') }}-${{ github.sha }} + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: Build with Gradle + uses: gradle/gradle-build-action@67421db6bd0bf253fb4bd25b31ebb98943c375e1 + with: + arguments: detektMain + + - name: "reviewdog-suggester: Suggest any code changes based on diff with reviewdog" + if: ${{ always() }} + uses: reviewdog/action-suggester@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + + e2e-test: + name: E2E Test + needs: [ setup ] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Gradle Wrapper Cache + uses: actions/cache@v3.3.2 + with: + path: ~/.gradle/wrapper + key: gradle-wrapper-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }} + + - name: Dependencies Cache + uses: actions/cache@v3.3.2 + with: + path: | + ~/.gradle/cache/jars-* + ~/.gradle/caches/transforms-* + ~/.gradle/caches/modules-* + key: gradle-dependencies-${{ hashFiles('**/*.gradle.kts') }} + restore-keys: gradle-dependencies- + + - name: Cache + uses: actions/cache@v3.3.2 + with: + path: | + ~/.gradle/caches/build-cache-* + ~/.gradle/caches/[0-9]*.* + .gradle + key: ${{ runner.os }}-gradle-build-${{ github.workflow }}-${{ github.sha }} + restore-keys: ${{ runner.os }}-gradle-build-${{ github.workflow }}- + + - name: Build Cache + uses: actions/cache@v3.3.2 + with: + path: | + build + key: gradle-build-${{ hashFiles('**/*.gradle.kts') }}-${{ hashFiles('src') }}-${{ github.sha }} + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: MongoDB in GitHub Actions + uses: supercharge/mongodb-github-action@1.10.0 + with: + mongodb-version: latest + + - name: setup-chrome + id: setup-chrome + uses: browser-actions/setup-chrome@v1.4.0 + + - name: Add Path + run: echo ${{ steps.setup-chrome.outputs.chrome-path }} >> $GITHUB_PATH + + - name: E2E Test + uses: gradle/gradle-build-action@v2.8.1 + with: + arguments: e2eTest + + + - name: Save Test Report + if: always() + uses: actions/cache/save@v3 + with: + path: build/test-results + key: e2e-test-report-${{ github.sha }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index dca2a97b..00000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,63 +0,0 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. -# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-gradle - -name: Test - -on: - pull_request: - branches: [ "develop" ] - -permissions: - contents: read - checks: write - id-token: write - -jobs: - test: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - name: Cache - uses: actions/cache@v3.3.2 - with: - path: ~/.gradle/wrapper - key: gradle-wrapper-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }} - - name: Cache - uses: actions/cache@v3.3.2 - with: - path: | - ~/.gradle/caches/jars-* - ~/.gradle/caches/transforms-* - ~/.gradle/caches/modules-* - key: gradle-dependencies-${{ steps.cache-key.outputs.week }}-${{ hashFiles('**/*.gradle.kts') }} - restore-keys: gradle-dependencies-${{ steps.cache-key.outputs.week }}- - - name: Cache - uses: actions/cache@v3.3.2 - with: - path: | - ~/.gradle/caches/build-cache-* - ~/.gradle/caches/[0-9]*.* - .gradle - key: ${{ runner.os }}-gradle-build-${{ github.workflow }}-${{ steps.cache-key.outputs.week }}-${{ github.sha }} - restore-keys: ${{ runner.os }}-gradle-build-${{ github.workflow }}-${{ steps.cache-key.outputs.week }}- - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'temurin' - - name: Run JUnit - uses: gradle/gradle-build-action@v2.8.1 - with: - arguments: test - - name: Publish Test Report - uses: mikepenz/action-junit-report@v2 - if: always() - with: - report_paths: '**/build/test-results/test/TEST-*.xml' diff --git a/.gitignore b/.gitignore index 5f98beae..84c07d25 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ out/ /src/main/web/generated/ /stats.html /tomcat/ +/tomcat-e2e/ +/e2eTest.log +/files/ diff --git a/build.gradle.kts b/build.gradle.kts index b5f5cb83..c8d31ca7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,5 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.openapitools.generator.gradle.plugin.tasks.GenerateTask -import kotlin.math.max val ktor_version: String by project val kotlin_version: String by project @@ -13,7 +12,7 @@ plugins { kotlin("jvm") version "1.8.21" id("org.graalvm.buildtools.native") version "0.9.21" id("io.gitlab.arturbosch.detekt") version "1.23.1" - id("org.springframework.boot") version "3.1.3" + id("org.springframework.boot") version "3.2.0" kotlin("plugin.spring") version "1.8.21" id("org.openapi.generator") version "7.0.1" id("org.jetbrains.kotlinx.kover") version "0.7.4" @@ -32,6 +31,10 @@ sourceSets { compileClasspath += sourceSets.main.get().output runtimeClasspath += sourceSets.main.get().output } + create("e2eTest") { + compileClasspath += sourceSets.main.get().output + runtimeClasspath += sourceSets.main.get().output + } } val intTestImplementation by configurations.getting { @@ -41,6 +44,14 @@ val intTestRuntimeOnly by configurations.getting { extendsFrom(configurations.runtimeOnly.get()) } +val e2eTestImplementation by configurations.getting { + extendsFrom(configurations.implementation.get()) +} + +val e2eTestRuntimeOnly by configurations.getting { + extendsFrom(configurations.runtimeOnly.get()) +} + val integrationTest = task("integrationTest") { description = "Runs integration tests." group = "verification" @@ -50,19 +61,26 @@ val integrationTest = task("integrationTest") { shouldRunAfter("test") useJUnitPlatform() - - testLogging { - events("passed") - } } -tasks.check { dependsOn(integrationTest) } +val e2eTest = task("e2eTest") { + description = "Runs e2e tests." + group = "verification" + + testClassesDirs = sourceSets["e2eTest"].output.classesDirs + classpath = sourceSets["e2eTest"].runtimeClasspath + shouldRunAfter("test") + + useJUnitPlatform() +} + +tasks.check { + dependsOn(integrationTest) + dependsOn(e2eTest) +} tasks.withType { useJUnitPlatform() - val cpus = Runtime.getRuntime().availableProcessors() - maxParallelForks = max(1, cpus - 1) - setForkEvery(4) doFirst { jvmArgs = arrayOf( "--add-opens", "java.base/java.lang=ALL-UNNAMED" @@ -180,6 +198,7 @@ dependencies { implementation("org.postgresql:postgresql:42.6.0") implementation("com.twelvemonkeys.imageio:imageio-webp:3.10.0") implementation("org.apache.tika:tika-core:2.9.1") + implementation("org.apache.tika:tika-parsers:2.9.1") implementation("net.coobird:thumbnailator:0.4.20") implementation("org.bytedeco:javacv-platform:1.5.9") implementation("org.flywaydb:flyway-core") @@ -206,6 +225,18 @@ dependencies { intTestImplementation("org.springframework.boot:spring-boot-starter-test") intTestImplementation("org.springframework.security:spring-security-test") + intTestImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") + intTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") + intTestImplementation("org.mockito.kotlin:mockito-kotlin:4.1.0") + + e2eTestImplementation("org.springframework.boot:spring-boot-starter-test") + e2eTestImplementation("org.springframework.security:spring-security-test") + e2eTestImplementation("org.springframework.boot:spring-boot-starter-webflux") + e2eTestImplementation("org.jsoup:jsoup:1.17.1") + e2eTestImplementation("com.intuit.karate:karate-junit5:1.4.1") + + + } diff --git a/detekt.yml b/detekt.yml index c4fee204..a74f1251 100644 --- a/detekt.yml +++ b/detekt.yml @@ -3,9 +3,7 @@ build: weights: Indentation: 0 MagicNumber: 0 - InjectDispatcher: 0 EnumEntryNameCase: 0 - ReplaceSafeCallChainWithRun: 0 VariableNaming: 0 NoNameShadowing: 0 @@ -78,7 +76,7 @@ complexity: active: true ReplaceSafeCallChainWithRun: - active: true + active: false StringLiteralDuplication: active: false @@ -172,3 +170,5 @@ potential-bugs: coroutines: RedundantSuspendModifier: active: false + InjectDispatcher: + active: false diff --git a/gradle.properties b/gradle.properties index a7ffe13f..5516464a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ ktor_version=2.3.0 -kotlin_version=1.8.21 +kotlin_version=1.9.21 logback_version=1.4.6 kotlin.code.style=official exposed_version=0.44.0 diff --git a/src/e2eTest/kotlin/AssertionUtil.kt b/src/e2eTest/kotlin/AssertionUtil.kt new file mode 100644 index 00000000..8083b825 --- /dev/null +++ b/src/e2eTest/kotlin/AssertionUtil.kt @@ -0,0 +1,28 @@ +import dev.usbharu.hideout.core.infrastructure.exposedrepository.Users +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.select +import org.jetbrains.exposed.sql.selectAll +import java.net.MalformedURLException +import java.net.URL + +object AssertionUtil { + + @JvmStatic + fun assertUserExist(username: String, domain: String): Boolean { + val s = try { + val url = URL(domain) + url.host + ":" + url.port.toString().takeIf { it != "-1" }.orEmpty() + } catch (e: MalformedURLException) { + domain + } + + val selectAll = Users.selectAll() + println(selectAll.fetchSize) + + println(selectAll.toList().size) + + selectAll.map { "@${it[Users.name]}@${it[Users.domain]}" }.forEach { println(it) } + + return Users.select { Users.name eq username and (Users.domain eq s) }.empty().not() + } +} diff --git a/src/e2eTest/kotlin/KarateUtil.kt b/src/e2eTest/kotlin/KarateUtil.kt new file mode 100644 index 00000000..c71cd44b --- /dev/null +++ b/src/e2eTest/kotlin/KarateUtil.kt @@ -0,0 +1,28 @@ +import com.intuit.karate.junit5.Karate + +object KarateUtil { + fun springBootKarateTest(path: String, scenario: String, clazz: Class<*>, port: String): Karate { + if (scenario.isEmpty()) { + return Karate.run(path).relativeTo(clazz).systemProperty("karate.port", port).karateEnv("dev") + } else { + return Karate.run(path).scenarioName(scenario).relativeTo(clazz).systemProperty("karate.port", port) + .karateEnv("dev") + } + } + + fun e2eTest(path: String, scenario: String = "", properties: Map, clazz: Class<*>): Karate { + val run = Karate.run(path) + + val karate = if (scenario.isEmpty()) { + run + } else { + run.scenarioName(scenario) + } + + var relativeTo = karate.relativeTo(clazz) + + properties.map { relativeTo = relativeTo.systemProperty(it.key, it.value) } + + return relativeTo.karateEnv("dev") + } +} diff --git a/src/e2eTest/kotlin/federation/FollowAcceptTest.kt b/src/e2eTest/kotlin/federation/FollowAcceptTest.kt new file mode 100644 index 00000000..3c7cd02d --- /dev/null +++ b/src/e2eTest/kotlin/federation/FollowAcceptTest.kt @@ -0,0 +1,85 @@ +package federation + +import AssertionUtil +import KarateUtil +import com.intuit.karate.core.MockServer +import com.intuit.karate.junit5.Karate +import dev.usbharu.hideout.SpringApplication +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.flywaydb.core.Flyway +import org.junit.jupiter.api.* +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.transaction.annotation.Transactional +import java.net.MalformedURLException +import java.net.URL + +@SpringBootTest( + classes = [SpringApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT +) +@Transactional +class FollowAcceptTest { + @LocalServerPort + private var port = "" + + @Karate.Test + @TestFactory + @Disabled + fun `FollowAcceptTest`(): Karate { + return KarateUtil.e2eTest( + "FollowAcceptTest", "Follow Accept Test", + mapOf("karate.port" to port), + javaClass + ) + } + + companion object { + lateinit var server: MockServer + + lateinit var _remotePort: String + + @JvmStatic + fun assertUserExist(username: String, domain: String) = runBlocking { + val s = try { + val url = URL(domain) + url.host + ":" + url.port.toString().takeIf { it != "-1" }.orEmpty() + } catch (e: MalformedURLException) { + domain + } + + var check = false + + repeat(10) { + delay(1000) + check = AssertionUtil.assertUserExist(username, s) or check + if (check) { + return@repeat + } + } + + Assertions.assertTrue(check, "User @$username@$s not exist.") + } + + @JvmStatic + fun getRemotePort(): String = _remotePort + + @BeforeAll + @JvmStatic + fun beforeAll(@Autowired flyway: Flyway) { + server = MockServer.feature("classpath:federation/FollowAcceptMockServer.feature").http(0).build() + _remotePort = server.port.toString() + + flyway.clean() + flyway.migrate() + } + + @AfterAll + @JvmStatic + fun afterAll() { + server.stop() + } + } +} diff --git a/src/e2eTest/kotlin/federation/InboxCommonTest.kt b/src/e2eTest/kotlin/federation/InboxCommonTest.kt new file mode 100644 index 00000000..33d595af --- /dev/null +++ b/src/e2eTest/kotlin/federation/InboxCommonTest.kt @@ -0,0 +1,129 @@ +package federation + +import AssertionUtil +import KarateUtil +import com.intuit.karate.core.MockServer +import com.intuit.karate.junit5.Karate +import dev.usbharu.hideout.SpringApplication +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.flywaydb.core.Flyway +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.TestFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.transaction.annotation.Transactional + +@SpringBootTest( + classes = [SpringApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT +) +@Transactional +class InboxCommonTest { + @LocalServerPort + private var port = "" + + @Karate.Test + @TestFactory + fun `inboxにHTTP Signature付きのリクエストがあったらリモートに取得しに行く`(): Karate { + return KarateUtil.e2eTest( + "InboxCommonTest", + "inboxにHTTP Signature付きのリクエストがあったらリモートに取得しに行く", + mapOf( + "karate.port" to port, + "karate.remotePort" to _remotePort + ), + javaClass + ) + } + + @Karate.Test + @TestFactory + fun `user-inboxにHTTP Signature付きのリクエストがあったらリモートに取得しに行く`(): Karate { + return KarateUtil.e2eTest( + "InboxCommonTest", + "user-inboxにHTTP Signature付きのリクエストがあったらリモートに取得しに行く", + mapOf( + "karate.port" to port, + "karate.remotePort" to _remotePort + ), + javaClass + ) + } + + @Karate.Test + @TestFactory + fun `inboxにHTTP Signatureがないリクエストがきたら401を返す`(): Karate { + return KarateUtil.e2eTest( + "InboxCommonTest", + "inboxにHTTP Signatureがないリクエストがきたら401を返す", + mapOf("karate.port" to port), + javaClass + ) + } + + @Karate.Test + @TestFactory + fun `user-inboxにHTTP Signatureがないリクエストがきたら401を返す`(): Karate { + return KarateUtil.e2eTest( + "InboxCommonTest", + "user-inboxにHTTP Signatureがないリクエストがきたら401を返す", + mapOf("karate.port" to port), + javaClass + ) + } + + @Karate.Test + @TestFactory + fun `inboxにConetnt-Type application *+json以外が来たら415を返す`(): Karate { + return KarateUtil.e2eTest( + "InboxCommonTest", + "inboxにContent-Type application/json以外が来たら415を返す", + mapOf("karate.port" to port), + javaClass + ) + } + + companion object { + lateinit var server: MockServer + + lateinit var _remotePort: String + + @JvmStatic + fun assertUserExist(username: String, domain: String) = runBlocking { + var check = false + + repeat(10) { + delay(1000) + check = AssertionUtil.assertUserExist(username, domain) or check + if (check) { + return@repeat + } + } + + assertTrue(check, "User @$username@$domain not exist.") + } + + @JvmStatic + fun getRemotePort(): String = _remotePort + + @BeforeAll + @JvmStatic + fun beforeAll(@Autowired flyway: Flyway) { + server = MockServer.feature("classpath:federation/InboxxCommonMockServerTest.feature").http(0).build() + _remotePort = server.port.toString() + + flyway.clean() + flyway.migrate() + } + + @AfterAll + @JvmStatic + fun afterAll() { + server.stop() + } + } +} diff --git a/src/e2eTest/kotlin/oauth2/OAuth2LoginTest.kt b/src/e2eTest/kotlin/oauth2/OAuth2LoginTest.kt new file mode 100644 index 00000000..c13bd810 --- /dev/null +++ b/src/e2eTest/kotlin/oauth2/OAuth2LoginTest.kt @@ -0,0 +1,54 @@ +package oauth2 + +import KarateUtil +import com.intuit.karate.junit5.Karate +import dev.usbharu.hideout.SpringApplication +import org.flywaydb.core.Flyway +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.TestFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.test.context.jdbc.Sql + +@SpringBootTest( + classes = [SpringApplication::class], + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, +) +@Sql("/oauth2/user.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +class OAuth2LoginTest { + + @LocalServerPort + private var port = "" + + @Karate.Test + @TestFactory + fun `スコープwrite readを持ったトークンの作成`(): Karate { + return KarateUtil.springBootKarateTest( + "Oauth2LoginTest", + "スコープwrite readを持ったトークンの作成", + javaClass, + port + ) + } + + @Karate.Test + @TestFactory + fun `スコープread_statuses write_statusesを持ったトークンの作成`(): Karate { + return KarateUtil.springBootKarateTest( + "Oauth2LoginTest", + "スコープread:statuses write:statusesを持ったトークンの作成", + javaClass, + port + ) + } + + companion object { + @JvmStatic + @AfterAll + fun dropDatabase(@Autowired flyway: Flyway) { + flyway.clean() + flyway.migrate() + } + } +} diff --git a/src/e2eTest/resources/application.yml b/src/e2eTest/resources/application.yml new file mode 100644 index 00000000..330660e2 --- /dev/null +++ b/src/e2eTest/resources/application.yml @@ -0,0 +1,40 @@ +hideout: + url: "https://localhost:8080" + use-mongodb: false + security: + jwt: + generate: true + key-id: a + private-key: "MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC7VJTUt9Us8cKjMzEfYyjiWA4R4/M2bS1GB4t7NXp98C3SC6dVMvDuictGeurT8jNbvJZHtCSuYEvuNMoSfm76oqFvAp8Gy0iz5sxjZmSnXyCdPEovGhLa0VzMaQ8s+CLOyS56YyCFGeJZqgtzJ6GR3eqoYSW9b9UMvkBpZODSctWSNGj3P7jRFDO5VoTwCQAWbFnOjDfH5Ulgp2PKSQnSJP3AJLQNFNe7br1XbrhV//eO+t51mIpGSDCUv3E0DDFcWDTH9cXDTTlRZVEiR2BwpZOOkE/Z0/BVnhZYL71oZV34bKfWjQIt6V/isSMahdsAASACp4ZTGtwiVuNd9tybAgMBAAECggEBAKTmjaS6tkK8BlPXClTQ2vpz/N6uxDeS35mXpqasqskVlaAidgg/sWqpjXDbXr93otIMLlWsM+X0CqMDgSXKejLS2jx4GDjI1ZTXg++0AMJ8sJ74pWzVDOfmCEQ/7wXs3+cbnXhKriO8Z036q92Qc1+N87SI38nkGa0ABH9CN83HmQqt4fB7UdHzuIRe/me2PGhIq5ZBzj6h3BpoPGzEP+x3l9YmK8t/1cN0pqI+dQwYdgfGjackLu/2qH80MCF7IyQaseZUOJyKrCLtSD/Iixv/hzDEUPfOCjFDgTpzf3cwta8+oE4wHCo1iI1/4TlPkwmXx4qSXtmw4aQPz7IDQvECgYEA8KNThCO2gsC2I9PQDM/8Cw0O983WCDY+oi+7JPiNAJwv5DYBqEZB1QYdj06YD16XlC/HAZMsMku1na2TN0driwenQQWzoev3g2S7gRDoS/FCJSI3jJ+kjgtaA7Qmzlgk1TxODN+G1H91HW7t0l7VnL27IWyYo2qRRK3jzxqUiPUCgYEAx0oQs2reBQGMVZnApD1jeq7n4MvNLcPvt8b/eU9iUv6Y4Mj0Suo/AU8lYZXm8ubbqAlwz2VSVunD2tOplHyMUrtCtObAfVDUAhCndKaA9gApgfb3xw1IKbuQ1u4IF1FJl3VtumfQn//LiH1B3rXhcdyo3/vIttEk48RakUKClU8CgYEAzV7W3COOlDDcQd935DdtKBFRAPRPAlspQUnzMi5eSHMD/ISLDY5IiQHbIH83D4bvXq0X7qQoSBSNP7Dvv3HYuqMhf0DaegrlBuJllFVVq9qPVRnKxt1Il2HgxOBvbhOT+9in1BzA+YJ99UzC85O0Qz06A+CmtHEy4aZ2kj5hHjECgYEAmNS4+A8Fkss8Js1RieK2LniBxMgmYml3pfVLKGnzmng7H2+cwPLhPIzIuwytXywh2bzbsYEfYx3EoEVgMEpPhoarQnYPukrJO4gwE2o5Te6T5mJSZGlQJQj9q4ZB2Dfzet6INsK0oG8XVGXSpQvQh3RUYekCZQkBBFcpqWpbIEsCgYAnM3DQf3FJoSnXaMhrVBIovic5l0xFkEHskAjFTevO86Fsz1C2aSeRKSqGFoOQ0tmJzBEs1R6KqnHInicDTQrKhArgLXX4v3CddjfTRJkFWDbE/CkvKZNOrcf1nhaGCPspRJj2KUkj1Fhl9Cncdn/RsYEONbwQSjIfMPkvxF+8HQ==" + public-key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u+qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyehkd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdgcKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbcmwIDAQAB" + storage: + type: local + +spring: + flyway: + enabled: true + clean-disabled: false + datasource: + driver-class-name: org.h2.Driver + url: "jdbc:h2:./e2e-test;MODE=POSTGRESQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;CASE_INSENSITIVE_IDENTIFIERS=true;TRACE_LEVEL_FILE=4" + username: "" + password: + data: + mongodb: + auto-index-creation: true + host: localhost + port: 27017 + database: hideout + h2: + console: + enabled: true + +# exposed: +# generate-ddl: true +# excluded-packages: dev.usbharu.hideout.core.infrastructure.kjobexposed +server: + port: 8080 + tomcat: + basedir: tomcat-e2e + accesslog: + enabled: true diff --git a/src/e2eTest/resources/federation/FollowAcceptMockServer.feature b/src/e2eTest/resources/federation/FollowAcceptMockServer.feature new file mode 100644 index 00000000..60793fde --- /dev/null +++ b/src/e2eTest/resources/federation/FollowAcceptMockServer.feature @@ -0,0 +1,140 @@ +Feature: Follow Accept Mock Server + + Background: + * def assertInbox = Java.type(`federation.FollowAcceptTest`) + * def req = {req: []} + + Scenario: pathMatches('/users/test-follower') && methodIs('get') + * def remoteUrl = 'http://localhost:' + assertInbox.getRemotePort() + * def username = 'test-follower' + * def userUrl = remoteUrl + '/users/' + username + + + * def person = + """ + { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "toot": "http://joinmastodon.org/ns#", + "featured": { + "@id": "toot:featured", + "@type": "@id" + }, + "featuredTags": { + "@id": "toot:featuredTags", + "@type": "@id" + }, + "alsoKnownAs": { + "@id": "as:alsoKnownAs", + "@type": "@id" + }, + "movedTo": { + "@id": "as:movedTo", + "@type": "@id" + }, + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value", + "discoverable": "toot:discoverable", + "Device": "toot:Device", + "Ed25519Signature": "toot:Ed25519Signature", + "Ed25519Key": "toot:Ed25519Key", + "Curve25519Key": "toot:Curve25519Key", + "EncryptedMessage": "toot:EncryptedMessage", + "publicKeyBase64": "toot:publicKeyBase64", + "deviceId": "toot:deviceId", + "claim": { + "@type": "@id", + "@id": "toot:claim" + }, + "fingerprintKey": { + "@type": "@id", + "@id": "toot:fingerprintKey" + }, + "identityKey": { + "@type": "@id", + "@id": "toot:identityKey" + }, + "devices": { + "@type": "@id", + "@id": "toot:devices" + }, + "messageFranking": "toot:messageFranking", + "messageType": "toot:messageType", + "cipherText": "toot:cipherText", + "suspended": "toot:suspended", + "focalPoint": { + "@container": "@list", + "@id": "toot:focalPoint" + } + } + ], + "id": #(userUrl), + "type": "Person", + "following": #(userUrl + '/following'), + "followers": #(userUrl + '/followers'), + "inbox": #(userUrl + '/inbox'), + "outbox": #(userUrl + '/outbox'), + "featured": #(userUrl + '/collections/featured'), + "featuredTags": #(userUrl + '/collections/tags'), + "preferredUsername": #(username), + "name": #(username), + "summary": "E2E Test User Jaga/Cotlin/Winter Boot/Ktol\nYonTude: https://example.com\nY(Tvvitter): https://example.com\n", + "url": #(userUrl + '/@' + username), + "manuallyApprovesFollowers": false, + "discoverable": true, + "published": "2016-03-16T00:00:00Z", + "devices": #(userUrl + '/collections/devices'), + "alsoKnownAs": [ + #( 'https://example.com/users/' + username) + ], + "publicKey": { + "id": #(userUrl + '#main-key'), + "owner": #(userUrl), + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvmtKo0xYGXR4M0LQQhK4\nBkKpRvvUxrqGV6Ew4CBHSyzdnbFsiBqHUWz4JvRiQvAPqQ4jQFpxVZCPr9xx6lJp\nx7EAAKIdVTnBBV4CYfu7QPsRqtjbB5408q+mo5oUXNs8xg2tcC42p2SJ5CRJX/fr\nOgCZwo3LC9pOBdCQZ+tiiPmWNBTNby99JZn4D/xNcwuhV04qcPoHYD9OPuxxGyzc\naVJ2mqJmvi/lewQuR8qnUIbz+Gik+xvyG6LmyuDoa1H2LDQfQXYb62G70HsYdu7a\ndObvZovytp+kkjP/cUaIYkhhOAYqAA4zCwVRY4NHK0MAMq9sMoUfNJa8U+zR9NvD\noQIDAQAB\n-----END PUBLIC KEY-----\n" + }, + "tag": [], + "attachment": [ + { + "type": "PropertyValue", + "name": "Pixib Fan-Bridge", + "value": "\u003ca href=\"https://example.com/hideout\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\"\u003e\u003cspan class=\"invisible\"\u003ehttps://www.\u003c/span\u003e\u003cspan class=\"\"\u003eexample.com/hideout\u003c/span\u003e\u003cspan class=\"invisible\"\u003e\u003c/span\u003e\u003c/a\u003e" + }, + { + "type": "PropertyValue", + "name": "GitHub", + "value": "\u003ca href=\"https://github.com/usbharu/hideout\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\"\u003e\u003cspan class=\"invisible\"\u003ehttps://\u003c/span\u003e\u003cspan class=\"\"\u003egithub.com/usbharu/hideout\u003c/span\u003e\u003cspan class=\"invisible\"\u003e\u003c/span\u003e\u003c/a\u003e" + } + ], + "endpoints": { + "sharedInbox": #(remoteUrl + '/inbox') + }, + "icon": { + "type": "Image", + "mediaType": "image/jpeg", + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/Destroyer_Vozbuzhdenyy.jpg/320px-Destroyer_Vozbuzhdenyy.jpg" + }, + "image": { + "type": "Image", + "mediaType": "image/jpeg", + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/63/Views_of_Mount_Fuji_from_%C5%8Cwakudani_20211202.jpg/320px-Views_of_Mount_Fuji_from_%C5%8Cwakudani_20211202.jpg" + } +} + """ + + * set req.req[] = '/users/' + username + * def response = person + + Scenario: pathMatches('/inbox') && methodIs('post') + * set req.req[] = '/inbox' + * def responseStatus = 202 + + Scenario: pathMatches('/internal-assertion-api/requests') && methodIs('get') + * def response = req + + Scenario: pathMatches('/internal-assertion-api/requests/deleteAll') && methodIs('post') + * set req.req = [] + * def responseStatus = 200 diff --git a/src/e2eTest/resources/federation/FollowAcceptTest.feature b/src/e2eTest/resources/federation/FollowAcceptTest.feature new file mode 100644 index 00000000..7cdb39e5 --- /dev/null +++ b/src/e2eTest/resources/federation/FollowAcceptTest.feature @@ -0,0 +1,29 @@ +Feature: Follow Accept Test + + Background: + * url baseUrl + * def assertionUtil = Java.type('AssertionUtil') + + Scenario: Follow Accept Test + + * def follow = + """ + {"type": "Follow","actor": #(remoteUrl + '/users/test-follower'),"object": #(baseUrl + '/users/test-user')} + """ + + Given path '/inbox' + And header Signature = 'keyId="https://test-hideout.usbharu.dev/users/c#pubkey", algorithm="rsa-sha256", headers="x-request-id tpp-redirect-uri digest psu-id", signature="e/91pFiI5bRffP33EMrqoI5A0xjkg3Ar0kzRGHC/1RsLrDW0zV50dHly/qJJ5xrYHRlss3+vd0mznTLBs1X0hx0uXjpfvCvwclpSi8u+sqn+Y2bcQKzf7ah0vAONQd6zeTYW7e/1kDJreP43PsJyz29KZD16Yop8nM++YeEs6C5eWiyYXKoQozXnfmTOX3y6bhxfKKQWVcxA5aLOTmTZRYTsBsTy9zn8NjDQaRI/0gcyYPqpq+2g8j2DbyJu3Z6zP6VmwbGGlQU/s9Pa7G5LqUPH/sBMSlIeqh+Hvm2pL7B3/BMFvGtTD+e2mR60BFnLIxMYx+oX4o33J2XkFIODLQ=="' + And request follow + When method post + Then status 202 + + And retry until assertionUtil.assertUserExist('test-follower',remoteUrl) + + * url remoteUrl + + Given path '/internal-assertion-api/requests' + When method get + Then status 200 + And match response.req contains ['/users/test-follower'] + + * url baseUrl diff --git a/src/e2eTest/resources/federation/InboxCommonTest.feature b/src/e2eTest/resources/federation/InboxCommonTest.feature new file mode 100644 index 00000000..848e5630 --- /dev/null +++ b/src/e2eTest/resources/federation/InboxCommonTest.feature @@ -0,0 +1,158 @@ +Feature: Inbox Common Test + + Background: + * url baseUrl + + Scenario: inboxにHTTP Signature付きのリクエストがあったらリモートに取得しに行く + + * url remoteUrl + + Given path '/internal-assertion-api/requests/deleteAll' + When method post + Then status 200 + + * url baseUrl + + * def inbox = + """ + { "type": "Follow" } + """ + + Given path `/inbox` + And request inbox +# And header Signature = 'keyId="'+ remoteUrl +'/users/test-user#pubkey", algorithm="rsa-sha256", headers="(request-target)", signature="a"' + And header Signature = 'keyId="'+ remoteUrl +'/users/test-user#pubkey", algorithm="rsa-sha256", headers="(request-target) date host digest", signature="FfpkmBogW70FMo94yovGpl15L/m4bDjVIFb9mSZUstPE3H00nHiqNsjAq671qFMJsGOO1uWfLEExcdvzwTiC3wuHShzingvxQUbTgcgRTRZcHbtrOZxT8hYHGndpCXGv/NOLkfXDtZO9v5u0fnA2yJFokzyPHOPJ1cJliWlXP38Bl/pO4H5rBLQBZKpM2jYIjMyI78G2rDXNHEeGrGiyfB5SKb3H6zFQL+X9QpXUI4n0f07VsnwaDyp63oUopmzNUyBEuSqB+8va/lbfcWwrxpZnKGzQRZ+VBcV7jDoKGNOP9/O1xEI2CwB8sh+h6KVHdX3EQEvO1slaaLzcwRRqrQ=="' + When method post + Then status 202 + + * def assertInbox = Java.type(`federation.InboxCommonTest`) + + And assertInbox.assertUserExist('test-user',remoteUrl) + + * url remoteUrl + + Given path '/internal-assertion-api/requests' + When method get + Then status 200 + + * url baseUrl + + * print response + Then match response.req == ['/users/test-user'] + + + Scenario: inboxにHTTP Signatureがないリクエストがきたら401を返す + + * def inbox = + """ + {"type": "Follow"} + """ + + Given path '/inbox' + And request inbox + When method post + Then status 401 + + + Scenario: user-inboxにHTTP Signature付きのリクエストがあったらリモートに取得しに行く + + * url remoteUrl + + Given path '/internal-assertion-api/requests/deleteAll' + When method post + Then status 200 + + * url baseUrl + + * def inbox = + """ + { "type": "Follow" } + """ + + Given path `/inbox` + And request inbox +# And header Signature = 'keyId="'+ remoteUrl +'/users/test-user#pubkey", algorithm="rsa-sha256", headers="(request-target)", signature="a"' + And header Signature = 'keyId="'+ remoteUrl +'/users/test-user2#pubkey", algorithm="rsa-sha256", headers="(request-target) date host digest", signature="FfpkmBogW70FMo94yovGpl15L/m4bDjVIFb9mSZUstPE3H00nHiqNsjAq671qFMJsGOO1uWfLEExcdvzwTiC3wuHShzingvxQUbTgcgRTRZcHbtrOZxT8hYHGndpCXGv/NOLkfXDtZO9v5u0fnA2yJFokzyPHOPJ1cJliWlXP38Bl/pO4H5rBLQBZKpM2jYIjMyI78G2rDXNHEeGrGiyfB5SKb3H6zFQL+X9QpXUI4n0f07VsnwaDyp63oUopmzNUyBEuSqB+8va/lbfcWwrxpZnKGzQRZ+VBcV7jDoKGNOP9/O1xEI2CwB8sh+h6KVHdX3EQEvO1slaaLzcwRRqrQ=="' + When method post + Then status 202 + + * def assertInbox = Java.type(`federation.InboxCommonTest`) + + And assertInbox.assertUserExist('test-user2',remoteUrl) + + + * url remoteUrl + + Given path '/internal-assertion-api/requests' + When method get + Then status 200 + + * url baseUrl + + * print response + Then match response.req == ['/users/test-user2'] + + Scenario: user-inboxにHTTP Signatureがないリクエストがきたら401を返す + + * def inbox = + """ + {"type": "Follow"} + """ + + Given path '/inbox' + And request inbox + When method post + Then status 401 + + + Scenario: inboxにContent-Type application/json以外が来たら415を返す + + * def inbox = + """ + {"type": "Follow"} + """ + + Given path '/inbox' + And request inbox + And header Signature = 'keyId="'+ remoteUrl +'/users/test-user#pubkey", algorithm="rsa-sha256", headers="(request-target) date host digest", signature="FfpkmBogW70FMo94yovGpl15L/m4bDjVIFb9mSZUstPE3H00nHiqNsjAq671qFMJsGOO1uWfLEExcdvzwTiC3wuHShzingvxQUbTgcgRTRZcHbtrOZxT8hYHGndpCXGv/NOLkfXDtZO9v5u0fnA2yJFokzyPHOPJ1cJliWlXP38Bl/pO4H5rBLQBZKpM2jYIjMyI78G2rDXNHEeGrGiyfB5SKb3H6zFQL+X9QpXUI4n0f07VsnwaDyp63oUopmzNUyBEuSqB+8va/lbfcWwrxpZnKGzQRZ+VBcV7jDoKGNOP9/O1xEI2CwB8sh+h6KVHdX3EQEvO1slaaLzcwRRqrQ=="' + And header Accept = 'application/activity+json' + And header Content-Type = 'application/json' + When method post + Then status 202 + + Given path '/inbox' + And request inbox + And header Signature = 'keyId="'+ remoteUrl +'/users/test-user#pubkey", algorithm="rsa-sha256", headers="(request-target) date host digest", signature="FfpkmBogW70FMo94yovGpl15L/m4bDjVIFb9mSZUstPE3H00nHiqNsjAq671qFMJsGOO1uWfLEExcdvzwTiC3wuHShzingvxQUbTgcgRTRZcHbtrOZxT8hYHGndpCXGv/NOLkfXDtZO9v5u0fnA2yJFokzyPHOPJ1cJliWlXP38Bl/pO4H5rBLQBZKpM2jYIjMyI78G2rDXNHEeGrGiyfB5SKb3H6zFQL+X9QpXUI4n0f07VsnwaDyp63oUopmzNUyBEuSqB+8va/lbfcWwrxpZnKGzQRZ+VBcV7jDoKGNOP9/O1xEI2CwB8sh+h6KVHdX3EQEvO1slaaLzcwRRqrQ=="' + And header Accept = 'application/activity+json' + And header Content-Type = 'application/activity+json' + When method post + Then status 202 + + Given path '/inbox' + And request inbox + And header Signature = 'keyId="'+ remoteUrl +'/users/test-user#pubkey", algorithm="rsa-sha256", headers="(request-target) date host digest", signature="FfpkmBogW70FMo94yovGpl15L/m4bDjVIFb9mSZUstPE3H00nHiqNsjAq671qFMJsGOO1uWfLEExcdvzwTiC3wuHShzingvxQUbTgcgRTRZcHbtrOZxT8hYHGndpCXGv/NOLkfXDtZO9v5u0fnA2yJFokzyPHOPJ1cJliWlXP38Bl/pO4H5rBLQBZKpM2jYIjMyI78G2rDXNHEeGrGiyfB5SKb3H6zFQL+X9QpXUI4n0f07VsnwaDyp63oUopmzNUyBEuSqB+8va/lbfcWwrxpZnKGzQRZ+VBcV7jDoKGNOP9/O1xEI2CwB8sh+h6KVHdX3EQEvO1slaaLzcwRRqrQ=="' + And header Accept = 'application/activity+json' + And header Content-Type = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' + When method post + Then status 202 + + Given path '/inbox' + And header Signature = 'keyId="'+ remoteUrl +'/users/test-user#pubkey", algorithm="rsa-sha256", headers="(request-target) date host digest", signature="FfpkmBogW70FMo94yovGpl15L/m4bDjVIFb9mSZUstPE3H00nHiqNsjAq671qFMJsGOO1uWfLEExcdvzwTiC3wuHShzingvxQUbTgcgRTRZcHbtrOZxT8hYHGndpCXGv/NOLkfXDtZO9v5u0fnA2yJFokzyPHOPJ1cJliWlXP38Bl/pO4H5rBLQBZKpM2jYIjMyI78G2rDXNHEeGrGiyfB5SKb3H6zFQL+X9QpXUI4n0f07VsnwaDyp63oUopmzNUyBEuSqB+8va/lbfcWwrxpZnKGzQRZ+VBcV7jDoKGNOP9/O1xEI2CwB8sh+h6KVHdX3EQEvO1slaaLzcwRRqrQ=="' + And header Accept = 'application/activity+json' + When method post + Then status 415 + + * def html = + """ + + + +""" + + Given path '/inbox' + And header Signature = 'keyId="'+ remoteUrl +'/users/test-user#pubkey", algorithm="rsa-sha256", headers="(request-target) date host digest", signature="FfpkmBogW70FMo94yovGpl15L/m4bDjVIFb9mSZUstPE3H00nHiqNsjAq671qFMJsGOO1uWfLEExcdvzwTiC3wuHShzingvxQUbTgcgRTRZcHbtrOZxT8hYHGndpCXGv/NOLkfXDtZO9v5u0fnA2yJFokzyPHOPJ1cJliWlXP38Bl/pO4H5rBLQBZKpM2jYIjMyI78G2rDXNHEeGrGiyfB5SKb3H6zFQL+X9QpXUI4n0f07VsnwaDyp63oUopmzNUyBEuSqB+8va/lbfcWwrxpZnKGzQRZ+VBcV7jDoKGNOP9/O1xEI2CwB8sh+h6KVHdX3EQEvO1slaaLzcwRRqrQ=="' + And header Accept = 'application/activity+json' + And header Content-Type = 'text/html' + And request html + When method post + Then status 415 diff --git a/src/e2eTest/resources/federation/InboxxCommonMockServerTest.feature b/src/e2eTest/resources/federation/InboxxCommonMockServerTest.feature new file mode 100644 index 00000000..6d114c04 --- /dev/null +++ b/src/e2eTest/resources/federation/InboxxCommonMockServerTest.feature @@ -0,0 +1,136 @@ +Feature: InboxCommonMockServer + + Background: + * def assertInbox = Java.type(`federation.InboxCommonTest`) + * def req = {req: []} + + Scenario: pathMatches('/users/{username}') && methodIs('get') + * def remoteUrl = 'http://localhost:' + assertInbox.getRemotePort() + * def username = pathParams.username + * def userUrl = remoteUrl + '/users/' + username + + + * def person = + """ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "toot": "http://joinmastodon.org/ns#", + "featured": { + "@id": "toot:featured", + "@type": "@id" + }, + "featuredTags": { + "@id": "toot:featuredTags", + "@type": "@id" + }, + "alsoKnownAs": { + "@id": "as:alsoKnownAs", + "@type": "@id" + }, + "movedTo": { + "@id": "as:movedTo", + "@type": "@id" + }, + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value", + "discoverable": "toot:discoverable", + "Device": "toot:Device", + "Ed25519Signature": "toot:Ed25519Signature", + "Ed25519Key": "toot:Ed25519Key", + "Curve25519Key": "toot:Curve25519Key", + "EncryptedMessage": "toot:EncryptedMessage", + "publicKeyBase64": "toot:publicKeyBase64", + "deviceId": "toot:deviceId", + "claim": { + "@type": "@id", + "@id": "toot:claim" + }, + "fingerprintKey": { + "@type": "@id", + "@id": "toot:fingerprintKey" + }, + "identityKey": { + "@type": "@id", + "@id": "toot:identityKey" + }, + "devices": { + "@type": "@id", + "@id": "toot:devices" + }, + "messageFranking": "toot:messageFranking", + "messageType": "toot:messageType", + "cipherText": "toot:cipherText", + "suspended": "toot:suspended", + "focalPoint": { + "@container": "@list", + "@id": "toot:focalPoint" + } + } + ], + "id": #(userUrl), + "type": "Person", + "following": #(userUrl + '/following'), + "followers": #(userUrl + '/followers'), + "inbox": #(userUrl + '/inbox'), + "outbox": #(userUrl + '/outbox'), + "featured": #(userUrl + '/collections/featured'), + "featuredTags": #(userUrl + '/collections/tags'), + "preferredUsername": #(username), + "name": #(username), + "summary": "E2E Test User Jaga/Cotlin/Winter Boot/Ktol\nYonTude: https://example.com\nY(Tvvitter): https://example.com\n", + "url": #(userUrl + '/@' + username), + "manuallyApprovesFollowers": false, + "discoverable": true, + "published": "2016-03-16T00:00:00Z", + "devices": #(userUrl + '/collections/devices'), + "alsoKnownAs": [ + #( 'https://example.com/users/' + username) + ], + "publicKey": { + "id": #(userUrl + '#main-key'), + "owner": #(userUrl), + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvmtKo0xYGXR4M0LQQhK4\nBkKpRvvUxrqGV6Ew4CBHSyzdnbFsiBqHUWz4JvRiQvAPqQ4jQFpxVZCPr9xx6lJp\nx7EAAKIdVTnBBV4CYfu7QPsRqtjbB5408q+mo5oUXNs8xg2tcC42p2SJ5CRJX/fr\nOgCZwo3LC9pOBdCQZ+tiiPmWNBTNby99JZn4D/xNcwuhV04qcPoHYD9OPuxxGyzc\naVJ2mqJmvi/lewQuR8qnUIbz+Gik+xvyG6LmyuDoa1H2LDQfQXYb62G70HsYdu7a\ndObvZovytp+kkjP/cUaIYkhhOAYqAA4zCwVRY4NHK0MAMq9sMoUfNJa8U+zR9NvD\noQIDAQAB\n-----END PUBLIC KEY-----\n" + }, + "tag": [], + "attachment": [ + { + "type": "PropertyValue", + "name": "Pixib Fan-Bridge", + "value": "\u003ca href=\"https://example.com/hideout\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\"\u003e\u003cspan class=\"invisible\"\u003ehttps://www.\u003c/span\u003e\u003cspan class=\"\"\u003eexample.com/hideout\u003c/span\u003e\u003cspan class=\"invisible\"\u003e\u003c/span\u003e\u003c/a\u003e" + }, + { + "type": "PropertyValue", + "name": "GitHub", + "value": "\u003ca href=\"https://github.com/usbharu/hideout\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\"\u003e\u003cspan class=\"invisible\"\u003ehttps://\u003c/span\u003e\u003cspan class=\"\"\u003egithub.com/usbharu/hideout\u003c/span\u003e\u003cspan class=\"invisible\"\u003e\u003c/span\u003e\u003c/a\u003e" + } + ], + "endpoints": { + "sharedInbox": #(remoteUrl + '/inbox') + }, + "icon": { + "type": "Image", + "mediaType": "image/jpeg", + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/Destroyer_Vozbuzhdenyy.jpg/320px-Destroyer_Vozbuzhdenyy.jpg" + }, + "image": { + "type": "Image", + "mediaType": "image/jpeg", + "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/63/Views_of_Mount_Fuji_from_%C5%8Cwakudani_20211202.jpg/320px-Views_of_Mount_Fuji_from_%C5%8Cwakudani_20211202.jpg" + } +} + + """ + * set req.req[] = '/users/' + username + * def response = person + + Scenario: pathMatches('/internal-assertion-api/requests') && methodIs('get') + * def response = req + + Scenario: pathMatches('/internal-assertion-api/requests/deleteAll') && methodIs('post') + * set req.req = [] + * def responseStatus = 200 diff --git a/src/e2eTest/resources/karate-config.js b/src/e2eTest/resources/karate-config.js new file mode 100644 index 00000000..a83c2bb4 --- /dev/null +++ b/src/e2eTest/resources/karate-config.js @@ -0,0 +1,30 @@ +function fn() { + var env = karate.env; // get java system property 'karate.env' + karate.log('karate.env system property was:', env); + if (!env) { + env = 'dev'; // a custom 'intelligent' default + karate.log('karate.env set to "dev" as default.'); + } + let config; + if (env === 'test') { + let remotePort = karate.properties['karate.remotePort'] || '8081' + config = { + baseUrl: 'https://test-hideout.usbharu.dev', + remoteUrl: 'http://localhost:' + remotePort + } + } else if (env === 'dev') { + let port = karate.properties['karate.port'] || '8080' + let remotePort = karate.properties['karate.remotePort'] || '8081' + config = { + baseUrl: 'http://localhost:' + port, + remoteUrl: 'http://localhost:' + remotePort + } + } else { + throw 'Unknown environment [' + env + '].' + } + // don't waste time waiting for a connection or if servers don't respond within 0,3 seconds + + karate.configure('connectTimeout', 1000); + karate.configure('readTimeout', 1000); + return config; +} diff --git a/src/e2eTest/resources/logback.xml b/src/e2eTest/resources/logback.xml new file mode 100644 index 00000000..c21752ee --- /dev/null +++ b/src/e2eTest/resources/logback.xml @@ -0,0 +1,21 @@ + + + ./e2eTest.log + + UTF-8 + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{x-request-id}] %logger{36} - %msg%n + + + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{x-request-id}] %logger{36} - %msg%n + + + + + + + + + + diff --git a/src/e2eTest/resources/oauth2/Oauth2LoginTest.feature b/src/e2eTest/resources/oauth2/Oauth2LoginTest.feature new file mode 100644 index 00000000..af203344 --- /dev/null +++ b/src/e2eTest/resources/oauth2/Oauth2LoginTest.feature @@ -0,0 +1,95 @@ +Feature: OAuth2 Login Test + + Background: + * url baseUrl + * configure driver = { type: 'chrome',start: true, headless: true, showDriverLog: true, addOptions: [ '--headless=new' ] } + + Scenario: スコープwrite readを持ったトークンの作成 + + * def apps = + """ + { + "client_name": "oauth2-test-client-1", + "redirect_uris": "https://usbharu.dev", + "scopes": "write read" + } + """ + + Given path '/api/v1/apps' + And request apps + When method post + Then status 200 + + * def client_id = response.client_id + * def client_secret = response.client_secret + + * def authorizeEndpoint = baseUrl + '/oauth/authorize?response_type=code&redirect_uri=https://usbharu.dev&client_id=' + client_id + '&scope=write%20read' + + Given driver authorizeEndpoint + And driver.input('#username','test-user') + And driver.input('#password','password') + + When driver.submit().click('body > div > form > button') + Then driver.waitForUrl(authorizeEndpoint + "&continue") + And driver.click('#read') + And driver.click('#write') + + When driver.submit().click('#submit-consent') + Then driver.waitUntil("location.host == 'usbharu.dev'") + + * def code = script("new URLSearchParams(document.location.search).get('code')") + + Given path '/oauth/token' + And form field client_id = client_id + And form field client_secret = client_secret + And form field redirect_uri = 'https://usbharu.dev' + And form field grant_type = 'authorization_code' + And form field code = code + And form field scope = 'write read' + When method post + Then status 200 + + Scenario: スコープread:statuses write:statusesを持ったトークンの作成 + + * def apps = + """ + { + "client_name": "oauth2-test-client-2", + "redirect_uris": "https://usbharu.dev", + "scopes": "read:statuses write:statuses" + } + """ + + Given path '/api/v1/apps' + And request apps + When method post + Then status 200 + + * def client_id = response.client_id + * def client_secret = response.client_secret + + * def authorizeEndpoint = baseUrl + '/oauth/authorize?response_type=code&redirect_uri=https://usbharu.dev&client_id=' + client_id + '&scope=read:statuses+write:statuses' + + Given driver authorizeEndpoint + And driver.input('#username','test-user') + And driver.input('#password','password') + + When driver.submit().click('body > div > form > button') + Then driver.waitForUrl(authorizeEndpoint + "&continue") + And driver.click('/html/body/div/div[4]/div/form/div[1]/input') + And driver.click('/html/body/div/div[4]/div/form/div[2]/input') + + When driver.submit().click('#submit-consent') + Then driver.waitUntil("location.host == 'usbharu.dev'") + + * def code = script("new URLSearchParams(document.location.search).get('code')") + + Given path '/oauth/token' + And form field client_id = client_id + And form field client_secret = client_secret + And form field redirect_uri = 'https://usbharu.dev' + And form field grant_type = 'authorization_code' + And form field code = code + And form field scope = 'write read' + When method post + Then status 200 diff --git a/src/e2eTest/resources/oauth2/user.sql b/src/e2eTest/resources/oauth2/user.sql new file mode 100644 index 00000000..15aa977f --- /dev/null +++ b/src/e2eTest/resources/oauth2/user.sql @@ -0,0 +1,46 @@ +insert into "USERS" (ID, NAME, DOMAIN, SCREEN_NAME, DESCRIPTION, PASSWORD, INBOX, OUTBOX, URL, PUBLIC_KEY, PRIVATE_KEY, + CREATED_AT, KEY_ID, FOLLOWING, FOLLOWERS, INSTANCE) +VALUES (1730415786666758144, 'test-user', 'localhost', 'Im test user.', 'THis account is test user.', + '$2a$10$/mWC/n7nC7X3l9qCEOKnredxne2zewoqEsJWTOdlKfg2zXKJ0F9Em', 'http://localhost/users/test-user/inbox', + 'http://localhost/users/test-user/outbox', 'http://localhost/users/test-user', + '-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAi4mifRg6huAIn6DXk3Vn +5tkRC0AO32ZJvczwXr9xDj4HJvrSUHBAxIwwIeuCceAYtiuZk4JmEKydeB6SRkoO +Nty93XZXS1SMmiHCvWOY5YlpnfFU1kLqW3fkXcLNls4XmzujLt1i2sT8mYkENAsP +h6K4SRtmktOVYZOWcVEcfLGKbJvaDD/+lKikNC1XCouylfGV/bA/FPY5vuI+7cdM +Mjana28JdiWlPWSdzcxtCSgN+nGWPjk2WWm8K+wK2zXqMxA0U0p4odyyILBGALxX +zMqObIQvpwPh/t+b6ohem4eq70/0/SwDhd+IzHkT3x4UzG1oxSQS/juPkO7uuS8p +uwIDAQAB +-----END PUBLIC KEY----- +', + '-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCLiaJ9GDqG4Aif +oNeTdWfm2RELQA7fZkm9zPBev3EOPgcm+tJQcEDEjDAh64Jx4Bi2K5mTgmYQrJ14 +HpJGSg423L3ddldLVIyaIcK9Y5jliWmd8VTWQupbd+Rdws2WzhebO6Mu3WLaxPyZ +iQQ0Cw+HorhJG2aS05Vhk5ZxURx8sYpsm9oMP/6UqKQ0LVcKi7KV8ZX9sD8U9jm+ +4j7tx0wyNqdrbwl2JaU9ZJ3NzG0JKA36cZY+OTZZabwr7ArbNeozEDRTSnih3LIg +sEYAvFfMyo5shC+nA+H+35vqiF6bh6rvT/T9LAOF34jMeRPfHhTMbWjFJBL+O4+Q +7u65Lym7AgMBAAECggEADJLa7v3LbFLsxAGY22NFeRJPTF252VycwXshn9ANbnSd +bWBFqlTrKSrevXe82ekRIP09ygKCkvcS+3t5v9a1gDEU9MtQo2ubfdoT87/xS6G9 +wCs6c1I1Twe3LtG6d9/bVbQiiLsPSNpeTrF/jPcAL780bvYGoK1rNQ85C7383Kl6 +1nwZCD0itjkmzbO0nGMRCduW46OdQKiOMuEC7z0zwynH3cK3wGvdlKyLG4L3pPZm +1/Uz7AZTieqSCjSgcgmaut7dmS49e3j8ujfb3wcKscfHoofyqNWsW1xyU1WytO9a +QLh9wlqfvGlfwQWkY6z6uFmc4XfRVZSC8nic4cAW3QKBgQC4PYbR5AuylDcfc6Am +jpL5mcF6qEMnEPgnL9z5VvuLs1f/JEyx5VgzQreDOKc1KOxDX7Xhok4gpvIJv1fi +zimviszEmIpHdPvgS7mP2hu42bSIjwVaXpny5aEEZbB6HQ9pGDW/MSsgmb6x31Kx +o+sslpqf9cpalI35UPtkNaEJNwKBgQDB4tVUQ5gGPKllEfCN64B/B7wodWr5cUNU +UpUXdFPCu+HXnRen6GKLo+25wmCUGtcIuvCY1Xm+tL0Z7jrI+oOD4CL9ob7BJrPF +XCq0jUhaEzWFGp1SOa6n+35fWPkCfG4EwfsK8+PWoZsZc1eykMxIJmBln3vufuHz +qybfhy0VnQKBgD2tAxvyXmQar9VMjLk7k0IRUa6w80H5sUjVAgFKOA0NLZEQ4sfO +wdbvJ6W66mamW2k2ehmdjs/pcy8GKfKYF2ZXbbMGaYwAQm1UjDr2xb78yi3Iyv70 +mk6wxlVFgW1vmwAQhbWKTSitryO2YeVrvUeA5yRTULk/78Mdc/qY5V7DAoGAAu3I +RzOWMlHsRSiWN66dDE4zm3DaotYBLF7q/aW2NjTcXoNy/ghWpMFfL/UtvE8DfJBG +XiirZCQazy94F90g63cRUD+HQCezg4G2629O7n1ny5DxW3Kfns3/xLT1XgI/Lzc2 +8Z1pja53R1Ukt//T9isOPbrBBoNIKoQlXC8QkUkCgYEAsib3uOMAIOJab5jc8FSj +VG+Cg2H63J5DgUUwx2Y0DPENugdGyYzCDMVPBNaB0Ru1SpqbUjgqh+YHynunSVeu +hDXMOteeyeVHUGw8mvcCEt53uRYVNW/rzXTMqfLVxbsJZHCsJBtFpwcgD2w4NjS2 +Ja15+ZWbOA4vJA9pOh3x4XM= +-----END PRIVATE KEY----- +', 1701398248417, + 'http://localhost/users/test-user#pubkey', 'http://localhost/users/test-user/following', + 'http://localhost/users/test-users/followers', null); diff --git a/src/intTest/kotlin/activitypub/inbox/InboxTest.kt b/src/intTest/kotlin/activitypub/inbox/InboxTest.kt index 92fe3aa2..a1d2c64f 100644 --- a/src/intTest/kotlin/activitypub/inbox/InboxTest.kt +++ b/src/intTest/kotlin/activitypub/inbox/InboxTest.kt @@ -1,6 +1,8 @@ package activitypub.inbox import dev.usbharu.hideout.SpringApplication +import org.flywaydb.core.Flyway +import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired @@ -45,6 +47,7 @@ class InboxTest { content = "{}" contentType = MediaType.APPLICATION_JSON } + .asyncDispatch() .andExpect { status { isUnauthorized() } } } @@ -55,6 +58,7 @@ class InboxTest { .post("/inbox") { content = "{}" contentType = MediaType.APPLICATION_JSON + header("Signature", "") } .asyncDispatch() .andExpect { status { isAccepted() } } @@ -68,6 +72,7 @@ class InboxTest { content = "{}" contentType = MediaType.APPLICATION_JSON } + .asyncDispatch() .andExpect { status { isUnauthorized() } } } @@ -78,6 +83,7 @@ class InboxTest { .post("/users/hoge/inbox") { content = "{}" contentType = MediaType.APPLICATION_JSON + header("Signature", "") } .asyncDispatch() .andExpect { status { isAccepted() } } @@ -88,4 +94,13 @@ class InboxTest { @Bean fun testTransaction() = TestTransaction } + + companion object { + @JvmStatic + @AfterAll + fun dropDatabase(@Autowired flyway: Flyway) { + flyway.clean() + flyway.migrate() + } + } } diff --git a/src/intTest/kotlin/activitypub/note/NoteTest.kt b/src/intTest/kotlin/activitypub/note/NoteTest.kt index a559ca66..d2067aa7 100644 --- a/src/intTest/kotlin/activitypub/note/NoteTest.kt +++ b/src/intTest/kotlin/activitypub/note/NoteTest.kt @@ -1,6 +1,8 @@ package activitypub.note import dev.usbharu.hideout.SpringApplication +import org.flywaydb.core.Flyway +import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired @@ -178,4 +180,13 @@ class NoteTest { .andExpect { jsonPath("\$.attachment[1].type") { value("Document") } } .andExpect { jsonPath("\$.attachment[1].url") { value("https://example.com/media/test-media2.png") } } } + + companion object { + @JvmStatic + @AfterAll + fun dropDatabase(@Autowired flyway: Flyway) { + flyway.clean() + flyway.migrate() + } + } } diff --git a/src/intTest/kotlin/activitypub/webfinger/WebFingerTest.kt b/src/intTest/kotlin/activitypub/webfinger/WebFingerTest.kt index d0faaa7f..8e0b0294 100644 --- a/src/intTest/kotlin/activitypub/webfinger/WebFingerTest.kt +++ b/src/intTest/kotlin/activitypub/webfinger/WebFingerTest.kt @@ -2,6 +2,8 @@ package activitypub.webfinger import dev.usbharu.hideout.SpringApplication import dev.usbharu.hideout.application.external.Transaction +import org.flywaydb.core.Flyway +import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc @@ -24,6 +26,7 @@ class WebFingerTest { @Test @Sql("/sql/test-user.sql") + fun `webfinger 存在するユーザーを取得`() { mockMvc .get("/.well-known/webfinger?resource=acct:test-user@example.com") @@ -52,7 +55,7 @@ class WebFingerTest { @Test fun `webfinger 存在しないユーザーに404`() { mockMvc - .get("/.well-known/webfinger?resource=acct:test-user@example.com") + .get("/.well-known/webfinger?resource=acct:invalid-user-notfound-afdjashfal@example.com") .andExpect { status { isNotFound() } } } @@ -80,4 +83,13 @@ class WebFingerTest { @Bean fun testTransaction(): Transaction = TestTransaction } + + companion object { + @JvmStatic + @AfterAll + fun dropDatabase(@Autowired flyway: Flyway) { + flyway.clean() + flyway.migrate() + } + } } diff --git a/src/intTest/kotlin/mastodon/account/AccountApiTest.kt b/src/intTest/kotlin/mastodon/account/AccountApiTest.kt new file mode 100644 index 00000000..fb8d66c6 --- /dev/null +++ b/src/intTest/kotlin/mastodon/account/AccountApiTest.kt @@ -0,0 +1,170 @@ +package mastodon.account + +import dev.usbharu.hideout.SpringApplication +import dev.usbharu.hideout.core.infrastructure.exposedquery.UserQueryServiceImpl +import kotlinx.coroutines.test.runTest +import org.flywaydb.core.Flyway +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.context.support.WithAnonymousUser +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt +import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity +import org.springframework.test.context.jdbc.Sql +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.post +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.context.WebApplicationContext + +@SpringBootTest(classes = [SpringApplication::class]) +@AutoConfigureMockMvc +@Transactional +@Sql("/sql/test-user.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +class AccountApiTest { + + @Autowired + private lateinit var userQueryServiceImpl: UserQueryServiceImpl + + @Autowired + private lateinit var context: WebApplicationContext + + private lateinit var mockMvc: MockMvc + + @BeforeEach + fun setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(springSecurity()) + .build() + } + + @Test + fun `apiV1AccountsVerifyCredentialsGetにreadでアクセスできる`() { + mockMvc + .get("/api/v1/accounts/verify_credentials") { + with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read"))) + } + .asyncDispatch() + .andDo { print() } + .andExpect { status { isOk() } } + } + + @Test + fun `apiV1AccountsVerifyCredentialsGetにread_accountsでアクセスできる`() { + mockMvc + .get("/api/v1/accounts/verify_credentials") { + with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read:accounts"))) + } + .asyncDispatch() + .andDo { print() } + .andExpect { status { isOk() } } + } + + @Test + @WithAnonymousUser + fun apiV1AccountsVerifyCredentialsGetに匿名でアクセスすると401() { + mockMvc + .get("/api/v1/accounts/verify_credentials") + .andExpect { status { isUnauthorized() } } + } + + @Test + @WithAnonymousUser + fun apiV1AccountsPostに匿名でPOSTしたらアカウントを作成できる() = runTest { + mockMvc + .post("/api/v1/accounts") { + contentType = MediaType.APPLICATION_FORM_URLENCODED + param("username", "api-test-user-1") + param("password", "very-secure-password") + param("email", "test@example.com") + param("agreement", "true") + param("locale", "") + with(SecurityMockMvcRequestPostProcessors.csrf()) + } + .asyncDispatch() + .andExpect { status { isFound() } } + + userQueryServiceImpl.findByNameAndDomain("api-test-user-1", "localhost") + } + + @Test + @WithAnonymousUser + fun apiV1AccountsPostで必須パラメーター以外を省略しても作成できる() = runTest { + mockMvc + .post("/api/v1/accounts") { + contentType = MediaType.APPLICATION_FORM_URLENCODED + param("username", "api-test-user-2") + param("password", "very-secure-password") + with(SecurityMockMvcRequestPostProcessors.csrf()) + } + .asyncDispatch() + .andExpect { status { isFound() } } + + userQueryServiceImpl.findByNameAndDomain("api-test-user-2", "localhost") + } + + @Test + @WithAnonymousUser + fun apiV1AccountsPostでusernameパラメーターを省略したら400() = runTest { + mockMvc + .post("/api/v1/accounts") { + contentType = MediaType.APPLICATION_FORM_URLENCODED + param("username", "api-test-user-3") + with(SecurityMockMvcRequestPostProcessors.csrf()) + } + .andExpect { status { isBadRequest() } } + } + + @Test + @WithAnonymousUser + fun apiV1AccountsPostでpasswordパラメーターを省略したら400() = runTest { + mockMvc + .post("/api/v1/accounts") { + contentType = MediaType.APPLICATION_FORM_URLENCODED + param("username", "api-test-user-4") + with(SecurityMockMvcRequestPostProcessors.csrf()) + } + .andExpect { status { isBadRequest() } } + } + + @Test + @WithAnonymousUser + fun apiV1AccountsPostでJSONで作ろうとしても400() { + mockMvc + .post("/api/v1/accounts") { + contentType = MediaType.APPLICATION_JSON + content = """{"username":"api-test-user-5","password":"very-very-secure-password"}""" + with(SecurityMockMvcRequestPostProcessors.csrf()) + } + .andExpect { status { isUnsupportedMediaType() } } + } + + @Test + @WithAnonymousUser + fun apiV1AccountsPostにCSRFトークンは必要() { + mockMvc + .post("/api/v1/accounts") { + contentType = MediaType.APPLICATION_FORM_URLENCODED + param("username", "api-test-user-2") + param("password", "very-secure-password") + } + .andExpect { status { isForbidden() } } + } + + companion object { + @JvmStatic + @AfterAll + fun dropDatabase(@Autowired flyway: Flyway) { + flyway.clean() + flyway.migrate() + } + } +} diff --git a/src/intTest/kotlin/mastodon/apps/AppTest.kt b/src/intTest/kotlin/mastodon/apps/AppTest.kt new file mode 100644 index 00000000..026a9163 --- /dev/null +++ b/src/intTest/kotlin/mastodon/apps/AppTest.kt @@ -0,0 +1,99 @@ +package mastodon.apps + +import dev.usbharu.hideout.SpringApplication +import dev.usbharu.hideout.core.infrastructure.springframework.oauth2.RegisteredClient +import org.assertj.core.api.Assertions.assertThat +import org.flywaydb.core.Flyway +import org.jetbrains.exposed.sql.select +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.security.test.context.support.WithAnonymousUser +import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.post +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.context.WebApplicationContext + +@SpringBootTest(classes = [SpringApplication::class]) +@AutoConfigureMockMvc +@Transactional +class AppTest { + + @Autowired + private lateinit var context: WebApplicationContext + + private lateinit var mockMvc: MockMvc + + + @BeforeEach + fun setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(SecurityMockMvcConfigurers.springSecurity()) + .build() + } + + @Test + @WithAnonymousUser + fun apiV1AppsPostにformで匿名でappを作成できる() { + mockMvc + .post("/api/v1/apps") { + contentType = MediaType.APPLICATION_FORM_URLENCODED + param("client_name", "test-client") + param("redirect_uris", "https://example.com") + param("scopes", "write read") + param("website", "https://example.com") + } + .asyncDispatch() + .andExpect { status { isOk() } } + + + val app = RegisteredClient + .select { RegisteredClient.clientName eq "test-client" } + .single() + + assertThat(app[RegisteredClient.clientName]).isEqualTo("test-client") + assertThat(app[RegisteredClient.redirectUris]).isEqualTo("https://example.com") + assertThat(app[RegisteredClient.scopes]).isEqualTo("read,write") + } + + @Test + @WithAnonymousUser + fun apiV1AppsPostにjsonで匿名でappを作成できる() { + mockMvc + .post("/api/v1/apps") { + contentType = MediaType.APPLICATION_JSON + content = """{ + "client_name": "test-client-2", + "redirect_uris": "https://example.com", + "scopes": "write read", + "website": "https;//example.com" +}""" + } + .asyncDispatch() + .andExpect { status { isOk() } } + + val app = RegisteredClient + .select { RegisteredClient.clientName eq "test-client-2" } + .single() + + assertThat(app[RegisteredClient.clientName]).isEqualTo("test-client-2") + assertThat(app[RegisteredClient.redirectUris]).isEqualTo("https://example.com") + assertThat(app[RegisteredClient.scopes]).isEqualTo("read,write") + } + + companion object { + @JvmStatic + @AfterAll + fun dropDatabase(@Autowired flyway: Flyway) { + flyway.clean() + flyway.migrate() + } + } +} diff --git a/src/intTest/kotlin/mastodon/media/MediaTest.kt b/src/intTest/kotlin/mastodon/media/MediaTest.kt new file mode 100644 index 00000000..43a881cf --- /dev/null +++ b/src/intTest/kotlin/mastodon/media/MediaTest.kt @@ -0,0 +1,124 @@ +package mastodon.media + +import dev.usbharu.hideout.SpringApplication +import dev.usbharu.hideout.core.service.media.MediaDataStore +import dev.usbharu.hideout.core.service.media.MediaSaveRequest +import dev.usbharu.hideout.core.service.media.SuccessSavedMedia +import kotlinx.coroutines.test.runTest +import org.flywaydb.core.Flyway +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.mock.web.MockMultipartFile +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt +import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers +import org.springframework.test.context.jdbc.Sql +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.multipart +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.context.WebApplicationContext + +@SpringBootTest(classes = [SpringApplication::class]) +@AutoConfigureMockMvc +@Transactional +@Sql("/sql/test-user.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +class MediaTest { + + @Autowired + private lateinit var context: WebApplicationContext + + private lateinit var mockMvc: MockMvc + + + @MockBean + private lateinit var mediaDataStore: MediaDataStore + + @BeforeEach + fun setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(SecurityMockMvcConfigurers.springSecurity()) + .build() + } + + @Test + fun メディアをアップロードできる() = runTest { + whenever(mediaDataStore.save(any())).doReturn(SuccessSavedMedia("", "a", "a")) + + mockMvc + .multipart("/api/v1/media") { + + file( + MockMultipartFile( + "file", + "400x400.png", + "image/png", + String.javaClass.classLoader.getResourceAsStream("media/400x400.png") + ) + ) + with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write"))) + } + .asyncDispatch() + .andExpect { status { isOk() } } + } + + @Test + fun write_mediaスコープでメディアをアップロードできる() = runTest { + whenever(mediaDataStore.save(any())).doReturn(SuccessSavedMedia("", "b", "b")) + + mockMvc + .multipart("/api/v1/media") { + + file( + MockMultipartFile( + "file", + "400x400.png", + "image/png", + String.javaClass.classLoader.getResourceAsStream("media/400x400.png") + ) + ) + with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write:media"))) + } + .asyncDispatch() + .andExpect { status { isOk() } } + } + + @Test + fun 権限がないと403() = runTest { + whenever(mediaDataStore.save(any())).doReturn(SuccessSavedMedia("", "", "")) + + mockMvc + .multipart("/api/v1/media") { + + file( + MockMultipartFile( + "file", + "400x400.png", + "image/png", + String.javaClass.classLoader.getResourceAsStream("media/400x400.png") + ) + ) + with(jwt().jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read"))) + } + .andExpect { status { isForbidden() } } + } + + companion object { + @JvmStatic + @AfterAll + fun dropDatabase(@Autowired flyway: Flyway) { + flyway.clean() + flyway.migrate() + } + } + +} diff --git a/src/intTest/kotlin/mastodon/status/StatusTest.kt b/src/intTest/kotlin/mastodon/status/StatusTest.kt new file mode 100644 index 00000000..54b61c9d --- /dev/null +++ b/src/intTest/kotlin/mastodon/status/StatusTest.kt @@ -0,0 +1,156 @@ +package mastodon.status + +import dev.usbharu.hideout.SpringApplication +import org.flywaydb.core.Flyway +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.context.support.WithAnonymousUser +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf +import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers +import org.springframework.test.context.jdbc.Sql +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.post +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.context.WebApplicationContext + +@SpringBootTest(classes = [SpringApplication::class]) +@AutoConfigureMockMvc +@Transactional +@Sql("/sql/test-user.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +class StatusTest { + + @Autowired + private lateinit var context: WebApplicationContext + + private lateinit var mockMvc: MockMvc + + + @BeforeEach + fun setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(SecurityMockMvcConfigurers.springSecurity()) + .build() + } + + @Test + fun 投稿できる() { + mockMvc + .post("/api/v1/statuses") { + contentType = MediaType.APPLICATION_JSON + content = """{"status":"hello"}""" + with( + SecurityMockMvcRequestPostProcessors.jwt() + .jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write")) + ) + } + .asyncDispatch() + .andExpect { status { isOk() } } + } + + @Test + fun write_statusesスコープで投稿できる() { + mockMvc + .post("/api/v1/statuses") { + contentType = MediaType.APPLICATION_JSON + content = """{"status":"hello"}""" + with( + SecurityMockMvcRequestPostProcessors.jwt() + .jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write:statuses")) + ) + } + .asyncDispatch() + .andExpect { status { isOk() } } + } + + @Test + fun 権限がないと403() { + mockMvc + .post("/api/v1/statuses") { + contentType = MediaType.APPLICATION_JSON + content = """{"status":"hello"}""" + with( + SecurityMockMvcRequestPostProcessors.jwt() + .jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_read")) + ) + } + .andExpect { status { isForbidden() } } + } + + @Test + @WithAnonymousUser + fun 匿名だと401() { + mockMvc + .post("/api/v1/statuses") { + contentType = MediaType.APPLICATION_JSON + content = """{"status":"hello"}""" + with(csrf()) + } + .andExpect { status { isUnauthorized() } } + } + + @Test + @WithAnonymousUser + fun 匿名の場合通常はcsrfが無いので403() { + mockMvc + .post("/api/v1/statuses") { + contentType = MediaType.APPLICATION_JSON + content = """{"status":"hello"}""" + } + .andExpect { status { isForbidden() } } + } + + @Test + fun formでも投稿できる() { + mockMvc + .post("/api/v1/statuses") { + contentType = MediaType.APPLICATION_FORM_URLENCODED + param("status", "hello") + with( + SecurityMockMvcRequestPostProcessors.jwt() + .jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write:statuses")) + ) + } + .asyncDispatch() + .andExpect { status { isOk() } } + } + + @Test + @Sql("/sql/test-post.sql") + fun in_reply_to_idを指定したら返信として処理される() { + mockMvc + .post("/api/v1/statuses") { + contentType = MediaType.APPLICATION_JSON + //language=JSON + content = """{ + "status": "hello", + "in_reply_to_id": "1" +}""" + with( + SecurityMockMvcRequestPostProcessors.jwt() + .jwt { it.claim("uid", "1") }.authorities(SimpleGrantedAuthority("SCOPE_write")) + ) + } + .asyncDispatch() + .andDo { print() } + .andExpect { status { isOk() } } + .andExpect { jsonPath("\$.in_reply_to_id") { value("1") } } + } + + companion object { + @JvmStatic + @AfterAll + fun dropDatabase(@Autowired flyway: Flyway) { + flyway.clean() + flyway.migrate() + } + } +} diff --git a/src/intTest/resources/application.yml b/src/intTest/resources/application.yml index 9760e828..c73fc1f3 100644 --- a/src/intTest/resources/application.yml +++ b/src/intTest/resources/application.yml @@ -8,18 +8,15 @@ hideout: private-key: "MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC7VJTUt9Us8cKjMzEfYyjiWA4R4/M2bS1GB4t7NXp98C3SC6dVMvDuictGeurT8jNbvJZHtCSuYEvuNMoSfm76oqFvAp8Gy0iz5sxjZmSnXyCdPEovGhLa0VzMaQ8s+CLOyS56YyCFGeJZqgtzJ6GR3eqoYSW9b9UMvkBpZODSctWSNGj3P7jRFDO5VoTwCQAWbFnOjDfH5Ulgp2PKSQnSJP3AJLQNFNe7br1XbrhV//eO+t51mIpGSDCUv3E0DDFcWDTH9cXDTTlRZVEiR2BwpZOOkE/Z0/BVnhZYL71oZV34bKfWjQIt6V/isSMahdsAASACp4ZTGtwiVuNd9tybAgMBAAECggEBAKTmjaS6tkK8BlPXClTQ2vpz/N6uxDeS35mXpqasqskVlaAidgg/sWqpjXDbXr93otIMLlWsM+X0CqMDgSXKejLS2jx4GDjI1ZTXg++0AMJ8sJ74pWzVDOfmCEQ/7wXs3+cbnXhKriO8Z036q92Qc1+N87SI38nkGa0ABH9CN83HmQqt4fB7UdHzuIRe/me2PGhIq5ZBzj6h3BpoPGzEP+x3l9YmK8t/1cN0pqI+dQwYdgfGjackLu/2qH80MCF7IyQaseZUOJyKrCLtSD/Iixv/hzDEUPfOCjFDgTpzf3cwta8+oE4wHCo1iI1/4TlPkwmXx4qSXtmw4aQPz7IDQvECgYEA8KNThCO2gsC2I9PQDM/8Cw0O983WCDY+oi+7JPiNAJwv5DYBqEZB1QYdj06YD16XlC/HAZMsMku1na2TN0driwenQQWzoev3g2S7gRDoS/FCJSI3jJ+kjgtaA7Qmzlgk1TxODN+G1H91HW7t0l7VnL27IWyYo2qRRK3jzxqUiPUCgYEAx0oQs2reBQGMVZnApD1jeq7n4MvNLcPvt8b/eU9iUv6Y4Mj0Suo/AU8lYZXm8ubbqAlwz2VSVunD2tOplHyMUrtCtObAfVDUAhCndKaA9gApgfb3xw1IKbuQ1u4IF1FJl3VtumfQn//LiH1B3rXhcdyo3/vIttEk48RakUKClU8CgYEAzV7W3COOlDDcQd935DdtKBFRAPRPAlspQUnzMi5eSHMD/ISLDY5IiQHbIH83D4bvXq0X7qQoSBSNP7Dvv3HYuqMhf0DaegrlBuJllFVVq9qPVRnKxt1Il2HgxOBvbhOT+9in1BzA+YJ99UzC85O0Qz06A+CmtHEy4aZ2kj5hHjECgYEAmNS4+A8Fkss8Js1RieK2LniBxMgmYml3pfVLKGnzmng7H2+cwPLhPIzIuwytXywh2bzbsYEfYx3EoEVgMEpPhoarQnYPukrJO4gwE2o5Te6T5mJSZGlQJQj9q4ZB2Dfzet6INsK0oG8XVGXSpQvQh3RUYekCZQkBBFcpqWpbIEsCgYAnM3DQf3FJoSnXaMhrVBIovic5l0xFkEHskAjFTevO86Fsz1C2aSeRKSqGFoOQ0tmJzBEs1R6KqnHInicDTQrKhArgLXX4v3CddjfTRJkFWDbE/CkvKZNOrcf1nhaGCPspRJj2KUkj1Fhl9Cncdn/RsYEONbwQSjIfMPkvxF+8HQ==" public-key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u+qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyehkd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdgcKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbcmwIDAQAB" storage: - use-s3: true - endpoint: "http://localhost:8082/test-hideout" - public-url: "http://localhost:8082/test-hideout" - bucket: "test-hideout" - region: "auto" - access-key: "" - secret-key: "" + type: local spring: + flyway: + enabled: true + clean-disabled: false datasource: driver-class-name: org.h2.Driver - url: "jdbc:h2:mem:test;MODE=POSTGRESQL;DB_CLOSE_DELAY=-1" + url: "jdbc:h2:mem:test;MODE=POSTGRESQL;DB_CLOSE_DELAY=-1;CASE_INSENSITIVE_IDENTIFIERS=true;TRACE_LEVEL_FILE=4;" username: "" password: data: @@ -32,8 +29,8 @@ spring: console: enabled: true - exposed: - generate-ddl: true - excluded-packages: dev.usbharu.hideout.core.infrastructure.kjobexposed +# exposed: +# generate-ddl: true +# excluded-packages: dev.usbharu.hideout.core.infrastructure.kjobexposed server: port: 8080 diff --git a/src/intTest/resources/junit-platform.properties b/src/intTest/resources/junit-platform.properties new file mode 100644 index 00000000..acfa9e5a --- /dev/null +++ b/src/intTest/resources/junit-platform.properties @@ -0,0 +1,2 @@ +junit.jupiter.testclass.order.default=org.junit.jupiter.api.ClassOrderer$Random +junit.jupiter.testmethod.order.default=org.junit.jupiter.api.MethodOrderer$Random diff --git a/src/intTest/resources/logback.xml b/src/intTest/resources/logback.xml index 54cfd39a..a8bb21c4 100644 --- a/src/intTest/resources/logback.xml +++ b/src/intTest/resources/logback.xml @@ -7,4 +7,5 @@ + diff --git a/src/intTest/resources/media/400x400.png b/src/intTest/resources/media/400x400.png new file mode 100644 index 00000000..0d2e71be Binary files /dev/null and b/src/intTest/resources/media/400x400.png differ diff --git a/src/intTest/resources/sql/test-post.sql b/src/intTest/resources/sql/test-post.sql new file mode 100644 index 00000000..01bcb2dd --- /dev/null +++ b/src/intTest/resources/sql/test-post.sql @@ -0,0 +1,3 @@ +insert into posts (id, user_id, overview, text, created_at, visibility, url, repost_id, reply_id, sensitive, ap_id) +VALUES (1, 1, null, 'hello', 1234455, 0, 'https://localhost/users/1/posts/1', null, null, false, + 'https://users/1/posts/1'); diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/exception/ActivityPubProcessException.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/exception/ActivityPubProcessException.kt new file mode 100644 index 00000000..c8cf6a0c --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/exception/ActivityPubProcessException.kt @@ -0,0 +1,21 @@ +package dev.usbharu.hideout.activitypub.domain.exception + +import java.io.Serial + +class ActivityPubProcessException : RuntimeException { + constructor() : super() + constructor(message: String?) : super(message) + constructor(message: String?, cause: Throwable?) : super(message, cause) + constructor(cause: Throwable?) : super(cause) + constructor(message: String?, cause: Throwable?, enableSuppression: Boolean, writableStackTrace: Boolean) : super( + message, + cause, + enableSuppression, + writableStackTrace + ) + + companion object { + @Serial + private const val serialVersionUID: Long = 5370068873167636639L + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/exception/FailedProcessException.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/exception/FailedProcessException.kt new file mode 100644 index 00000000..144759d3 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/exception/FailedProcessException.kt @@ -0,0 +1,21 @@ +package dev.usbharu.hideout.activitypub.domain.exception + +import java.io.Serial + +class FailedProcessException : RuntimeException { + constructor() : super() + constructor(message: String?) : super(message) + constructor(message: String?, cause: Throwable?) : super(message, cause) + constructor(cause: Throwable?) : super(cause) + constructor(message: String?, cause: Throwable?, enableSuppression: Boolean, writableStackTrace: Boolean) : super( + message, + cause, + enableSuppression, + writableStackTrace + ) + + companion object { + @Serial + private const val serialVersionUID: Long = -1305337651143409144L + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/exception/FailedToGetActivityPubResourceException.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/exception/FailedToGetActivityPubResourceException.kt index e050e716..ed967555 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/exception/FailedToGetActivityPubResourceException.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/exception/FailedToGetActivityPubResourceException.kt @@ -1,10 +1,16 @@ package dev.usbharu.hideout.activitypub.domain.exception import dev.usbharu.hideout.core.domain.exception.FailedToGetResourcesException +import java.io.Serial class FailedToGetActivityPubResourceException : FailedToGetResourcesException { constructor() : super() constructor(s: String?) : super(s) constructor(message: String?, cause: Throwable?) : super(message, cause) constructor(cause: Throwable?) : super(cause) + + companion object { + @Serial + private const val serialVersionUID: Long = 6420233106776818052L + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/exception/HttpSignatureUnauthorizedException.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/exception/HttpSignatureUnauthorizedException.kt new file mode 100644 index 00000000..3bba00ef --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/exception/HttpSignatureUnauthorizedException.kt @@ -0,0 +1,21 @@ +package dev.usbharu.hideout.activitypub.domain.exception + +import java.io.Serial + +class HttpSignatureUnauthorizedException : RuntimeException { + constructor() : super() + constructor(message: String?) : super(message) + constructor(message: String?, cause: Throwable?) : super(message, cause) + constructor(cause: Throwable?) : super(cause) + constructor(message: String?, cause: Throwable?, enableSuppression: Boolean, writableStackTrace: Boolean) : super( + message, + cause, + enableSuppression, + writableStackTrace + ) + + companion object { + @Serial + private const val serialVersionUID: Long = -6449793151674654501L + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Accept.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Accept.kt index 5f8af943..2cd730db 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Accept.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Accept.kt @@ -1,40 +1,52 @@ package dev.usbharu.hideout.activitypub.domain.model +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.annotation.JsonDeserialize import dev.usbharu.hideout.activitypub.domain.model.objects.Object import dev.usbharu.hideout.activitypub.domain.model.objects.ObjectDeserializer -open class Accept : Object { +open class Accept @JsonCreator constructor( + type: List = emptyList(), + override val name: String, @JsonDeserialize(using = ObjectDeserializer::class) - var `object`: Object? = null - - protected constructor() - constructor( - type: List = emptyList(), - name: String, - `object`: Object?, - actor: String? - ) : super( - type = add(type, "Accept"), - name = name, - actor = actor - ) { - this.`object` = `object` - } - - override fun toString(): String = "Accept(`object`=$`object`) ${super.toString()}" + @JsonProperty("object") + val apObject: Object, + override val actor: String +) : Object( + type = add(type, "Accept") +), + HasActor, + HasName { override fun equals(other: Any?): Boolean { if (this === other) return true - if (other !is Accept) return false + if (javaClass != other?.javaClass) return false if (!super.equals(other)) return false - return `object` == other.`object` + other as Accept + + if (name != other.name) return false + if (apObject != other.apObject) return false + if (actor != other.actor) return false + + return true } override fun hashCode(): Int { var result = super.hashCode() - result = 31 * result + (`object`?.hashCode() ?: 0) + result = 31 * result + name.hashCode() + result = 31 * result + apObject.hashCode() + result = 31 * result + actor.hashCode() return result } + + override fun toString(): String { + return "Accept(" + + "name='$name', " + + "apObject=$apObject, " + + "actor='$actor'" + + ")" + + " ${super.toString()}" + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Create.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Create.kt index f81439c6..d5b269dd 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Create.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Create.kt @@ -1,48 +1,64 @@ package dev.usbharu.hideout.activitypub.domain.model +import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.annotation.JsonDeserialize import dev.usbharu.hideout.activitypub.domain.model.objects.Object import dev.usbharu.hideout.activitypub.domain.model.objects.ObjectDeserializer -open class Create : Object { +open class Create( + type: List = emptyList(), + override val name: String, @JsonDeserialize(using = ObjectDeserializer::class) - var `object`: Object? = null - var to: List = emptyList() - var cc: List = emptyList() - - protected constructor() : super() - constructor( - type: List = emptyList(), - name: String? = null, - `object`: Object?, - actor: String? = null, - id: String? = null, - to: List = emptyList(), - cc: List = emptyList() - ) : super( - type = add(type, "Create"), - name = name, - actor = actor, - id = id - ) { - this.`object` = `object` - this.to = to - this.cc = cc - } + @JsonProperty("object") + val apObject: Object, + override val actor: String, + override val id: String, + val to: List = emptyList(), + val cc: List = emptyList() +) : Object( + type = add(type, "Create") +), + HasId, + HasName, + HasActor { override fun equals(other: Any?): Boolean { if (this === other) return true - if (other !is Create) return false + if (javaClass != other?.javaClass) return false if (!super.equals(other)) return false - return `object` == other.`object` + other as Create + + if (name != other.name) return false + if (apObject != other.apObject) return false + if (actor != other.actor) return false + if (id != other.id) return false + if (to != other.to) return false + if (cc != other.cc) return false + + return true } override fun hashCode(): Int { var result = super.hashCode() - result = 31 * result + (`object`?.hashCode() ?: 0) + result = 31 * result + name.hashCode() + result = 31 * result + apObject.hashCode() + result = 31 * result + actor.hashCode() + result = 31 * result + id.hashCode() + result = 31 * result + to.hashCode() + result = 31 * result + cc.hashCode() return result } - override fun toString(): String = "Create(`object`=$`object`) ${super.toString()}" + override fun toString(): String { + return "Create(" + + "name='$name', " + + "apObject=$apObject, " + + "actor='$actor', " + + "id='$id', " + + "to=$to, " + + "cc=$cc" + + ")" + + " ${super.toString()}" + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Delete.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Delete.kt index 0305fff2..6f691492 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Delete.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Delete.kt @@ -1,45 +1,55 @@ package dev.usbharu.hideout.activitypub.domain.model +import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.annotation.JsonDeserialize import dev.usbharu.hideout.activitypub.domain.model.objects.Object import dev.usbharu.hideout.activitypub.domain.model.objects.ObjectDeserializer -open class Delete : Object { +open class Delete : Object, HasId, HasActor { @JsonDeserialize(using = ObjectDeserializer::class) - var `object`: Object? = null - var published: String? = null + @JsonProperty("object") + val apObject: Object + val published: String + override val actor: String + override val id: String constructor( type: List = emptyList(), - name: String? = "Delete", actor: String, id: String, `object`: Object, - published: String? - ) : super(add(type, "Delete"), name, actor, id) { - this.`object` = `object` + published: String + ) : super(add(type, "Delete")) { + this.apObject = `object` this.published = published + this.actor = actor + this.id = id } - protected constructor() : super() - override fun equals(other: Any?): Boolean { if (this === other) return true - if (other !is Delete) return false + if (javaClass != other?.javaClass) return false if (!super.equals(other)) return false - if (`object` != other.`object`) return false + other as Delete + + if (apObject != other.apObject) return false if (published != other.published) return false + if (actor != other.actor) return false + if (id != other.id) return false return true } override fun hashCode(): Int { var result = super.hashCode() - result = 31 * result + (`object`?.hashCode() ?: 0) - result = 31 * result + (published?.hashCode() ?: 0) + result = 31 * result + apObject.hashCode() + result = 31 * result + published.hashCode() + result = 31 * result + actor.hashCode() + result = 31 * result + id.hashCode() return result } - override fun toString(): String = "Delete(`object`=$`object`, published=$published) ${super.toString()}" + override fun toString(): String = + "Delete(`object`=$apObject, published=$published, actor='$actor', id='$id') ${super.toString()}" } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Document.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Document.kt index d4f70180..d8b7ff7e 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Document.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Document.kt @@ -2,44 +2,37 @@ package dev.usbharu.hideout.activitypub.domain.model import dev.usbharu.hideout.activitypub.domain.model.objects.Object -open class Document : Object { - - var mediaType: String? = null - var url: String? = null - - protected constructor() : super() - constructor( - type: List = emptyList(), - name: String? = null, - mediaType: String, - url: String - ) : super( - type = add(type, "Document"), - name = name, - actor = null, - id = null - ) { - this.mediaType = mediaType - this.url = url - } +open class Document( + type: List = emptyList(), + override val name: String = "", + val mediaType: String, + val url: String +) : Object( + type = add(type, "Document") +), + HasName { override fun equals(other: Any?): Boolean { if (this === other) return true - if (other !is Document) return false + if (javaClass != other?.javaClass) return false if (!super.equals(other)) return false + other as Document + if (mediaType != other.mediaType) return false if (url != other.url) return false + if (name != other.name) return false return true } override fun hashCode(): Int { var result = super.hashCode() - result = 31 * result + (mediaType?.hashCode() ?: 0) - result = 31 * result + (url?.hashCode() ?: 0) + result = 31 * result + mediaType.hashCode() + result = 31 * result + url.hashCode() + result = 31 * result + name.hashCode() return result } - override fun toString(): String = "Document(mediaType=$mediaType, url=$url) ${super.toString()}" + override fun toString(): String = "Document(mediaType=$mediaType, url=$url, name='$name') ${super.toString()}" } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Emoji.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Emoji.kt index cce3ee87..37ebb879 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Emoji.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Emoji.kt @@ -2,27 +2,17 @@ package dev.usbharu.hideout.activitypub.domain.model import dev.usbharu.hideout.activitypub.domain.model.objects.Object -open class Emoji : Object { - var updated: String? = null - var icon: Image? = null - - protected constructor() : super() - constructor( - type: List, - name: String?, - actor: String?, - id: String?, - updated: String?, - icon: Image? - ) : super( - type = add(type, "Emoji"), - name = name, - actor = actor, - id = id - ) { - this.updated = updated - this.icon = icon - } +open class Emoji( + type: List, + override val name: String, + override val id: String, + val updated: String, + val icon: Image +) : Object( + type = add(type, "Emoji") +), + HasName, + HasId { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -35,8 +25,8 @@ open class Emoji : Object { override fun hashCode(): Int { var result = super.hashCode() - result = 31 * result + (updated?.hashCode() ?: 0) - result = 31 * result + (icon?.hashCode() ?: 0) + result = 31 * result + updated.hashCode() + result = 31 * result + icon.hashCode() return result } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Follow.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Follow.kt index 23648564..c7f292ba 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Follow.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Follow.kt @@ -1,37 +1,36 @@ package dev.usbharu.hideout.activitypub.domain.model +import com.fasterxml.jackson.annotation.JsonProperty import dev.usbharu.hideout.activitypub.domain.model.objects.Object -open class Follow : Object { - var `object`: String? = null - - protected constructor() : super() - constructor( - type: List = emptyList(), - name: String?, - `object`: String?, - actor: String? - ) : super( - type = add(type, "Follow"), - name = name, - actor = actor - ) { - this.`object` = `object` - } +open class Follow( + type: List = emptyList(), + @JsonProperty("object") val apObject: String, + override val actor: String +) : Object( + type = add(type, "Follow") +), + HasActor { override fun equals(other: Any?): Boolean { if (this === other) return true - if (other !is Follow) return false + if (javaClass != other?.javaClass) return false if (!super.equals(other)) return false - return `object` == other.`object` + other as Follow + + if (apObject != other.apObject) return false + if (actor != other.actor) return false + + return true } override fun hashCode(): Int { var result = super.hashCode() - result = 31 * result + (`object`?.hashCode() ?: 0) + result = 31 * result + apObject.hashCode() + result = 31 * result + actor.hashCode() return result } - override fun toString(): String = "Follow(`object`=$`object`) ${super.toString()}" + override fun toString(): String = "Follow(`object`=$apObject, actor='$actor') ${super.toString()}" } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/HasActor.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/HasActor.kt new file mode 100644 index 00000000..c9bc4f91 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/HasActor.kt @@ -0,0 +1,5 @@ +package dev.usbharu.hideout.activitypub.domain.model + +interface HasActor { + val actor: String +} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/HasId.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/HasId.kt new file mode 100644 index 00000000..774032c8 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/HasId.kt @@ -0,0 +1,5 @@ +package dev.usbharu.hideout.activitypub.domain.model + +interface HasId { + val id: String +} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/HasName.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/HasName.kt new file mode 100644 index 00000000..b8e4de76 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/HasName.kt @@ -0,0 +1,5 @@ +package dev.usbharu.hideout.activitypub.domain.model + +interface HasName { + val name: String +} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Image.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Image.kt index f177c8a0..5b63ef5e 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Image.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Image.kt @@ -2,32 +2,33 @@ package dev.usbharu.hideout.activitypub.domain.model import dev.usbharu.hideout.activitypub.domain.model.objects.Object -open class Image : Object { - private var mediaType: String? = null - private var url: String? = null - - protected constructor() : super() - constructor(type: List = emptyList(), name: String, mediaType: String?, url: String?) : super( - add(type, "Image"), - name - ) { - this.mediaType = mediaType - this.url = url - } +open class Image( + type: List = emptyList(), + val mediaType: String, + val url: String +) : Object( + add(type, "Image") +) { override fun equals(other: Any?): Boolean { if (this === other) return true - if (other !is Image) return false + if (javaClass != other?.javaClass) return false if (!super.equals(other)) return false + other as Image + if (mediaType != other.mediaType) return false - return url == other.url + if (url != other.url) return false + + return true } override fun hashCode(): Int { var result = super.hashCode() - result = 31 * result + (mediaType?.hashCode() ?: 0) - result = 31 * result + (url?.hashCode() ?: 0) + result = 31 * result + mediaType.hashCode() + result = 31 * result + url.hashCode() return result } + + override fun toString(): String = "Image(mediaType=$mediaType, url=$url) ${super.toString()}" } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Key.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Key.kt index 5cc33766..e821601f 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Key.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Key.kt @@ -2,41 +2,36 @@ package dev.usbharu.hideout.activitypub.domain.model import dev.usbharu.hideout.activitypub.domain.model.objects.Object -open class Key : Object { - var owner: String? = null - var publicKeyPem: String? = null - - protected constructor() : super() - constructor( - type: List, - name: String, - id: String, - owner: String?, - publicKeyPem: String? - ) : super( - type = add(list = type, type = "Key"), - name = name, - id = id - ) { - this.owner = owner - this.publicKeyPem = publicKeyPem - } +open class Key( + override val id: String, + val owner: String, + val publicKeyPem: String +) : Object( + type = add(list = emptyList(), type = "Key") +), + HasId { override fun equals(other: Any?): Boolean { if (this === other) return true - if (other !is Key) return false + if (javaClass != other?.javaClass) return false if (!super.equals(other)) return false + other as Key + if (owner != other.owner) return false - return publicKeyPem == other.publicKeyPem + if (publicKeyPem != other.publicKeyPem) return false + if (id != other.id) return false + + return true } override fun hashCode(): Int { var result = super.hashCode() - result = 31 * result + (owner?.hashCode() ?: 0) - result = 31 * result + (publicKeyPem?.hashCode() ?: 0) + result = 31 * result + owner.hashCode() + result = 31 * result + publicKeyPem.hashCode() + result = 31 * result + id.hashCode() return result } - override fun toString(): String = "Key(owner=$owner, publicKeyPem=$publicKeyPem) ${super.toString()}" + override fun toString(): String = "Key(owner=$owner, publicKeyPem=$publicKeyPem, id='$id') ${super.toString()}" } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Like.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Like.kt index 17e1037d..ec8e8ec7 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Like.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Like.kt @@ -1,53 +1,57 @@ package dev.usbharu.hideout.activitypub.domain.model +import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.annotation.JsonDeserialize import dev.usbharu.hideout.activitypub.domain.model.objects.Object import dev.usbharu.hideout.activitypub.domain.model.objects.ObjectDeserializer -open class Like : Object { - var `object`: String? = null - var content: String? = null - - @JsonDeserialize(contentUsing = ObjectDeserializer::class) - var tag: List = emptyList() - - protected constructor() : super() - constructor( - type: List = emptyList(), - name: String?, - actor: String?, - id: String?, - `object`: String?, - content: String?, - tag: List = emptyList() - ) : super( - type = add(type, "Like"), - name = name, - actor = actor, - id = id - ) { - this.`object` = `object` - this.content = content - this.tag = tag - } +open class Like( + type: List = emptyList(), + override val actor: String, + override val id: String, + @JsonProperty("object") val apObject: String, + val content: String, + @JsonDeserialize(contentUsing = ObjectDeserializer::class) val tag: List = emptyList() +) : Object( + type = add(type, "Like") +), + HasId, + HasActor { override fun equals(other: Any?): Boolean { if (this === other) return true - if (other !is Like) return false + if (javaClass != other?.javaClass) return false if (!super.equals(other)) return false - if (`object` != other.`object`) return false + other as Like + + if (actor != other.actor) return false + if (id != other.id) return false + if (apObject != other.apObject) return false if (content != other.content) return false - return tag == other.tag + if (tag != other.tag) return false + + return true } override fun hashCode(): Int { var result = super.hashCode() - result = 31 * result + (`object`?.hashCode() ?: 0) - result = 31 * result + (content?.hashCode() ?: 0) + result = 31 * result + actor.hashCode() + result = 31 * result + id.hashCode() + result = 31 * result + apObject.hashCode() + result = 31 * result + content.hashCode() result = 31 * result + tag.hashCode() return result } - override fun toString(): String = "Like(`object`=$`object`, content=$content, tag=$tag) ${super.toString()}" + override fun toString(): String { + return "Like(" + + "actor='$actor', " + + "id='$id', " + + "apObject='$apObject', " + + "content='$content', " + + "tag=$tag" + + ")" + + " ${super.toString()}" + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Note.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Note.kt index ddca1d67..2b16e1c4 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Note.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Note.kt @@ -2,79 +2,70 @@ package dev.usbharu.hideout.activitypub.domain.model import dev.usbharu.hideout.activitypub.domain.model.objects.Object -open class Note : Object { - var attributedTo: String? = null - var attachment: List = emptyList() - var content: String? = null - var published: String? = null - var to: List = emptyList() - var cc: List = emptyList() - var sensitive: Boolean = false - var inReplyTo: String? = null - - protected constructor() : super() - - @Suppress("LongParameterList") - constructor( - type: List = emptyList(), - name: String, - id: String?, - attributedTo: String?, - content: String?, - published: String?, - to: List = emptyList(), - cc: List = emptyList(), - sensitive: Boolean = false, - inReplyTo: String? = null, - attachment: List = emptyList() - ) : super( - type = add(type, "Note"), - name = name, - id = id - ) { - this.attributedTo = attributedTo - this.content = content - this.published = published - this.to = to - this.cc = cc - this.sensitive = sensitive - this.inReplyTo = inReplyTo - this.attachment = attachment - } +open class Note +@Suppress("LongParameterList") +constructor( + type: List = emptyList(), + override val id: String, + val attributedTo: String, + val content: String, + val published: String, + val to: List = emptyList(), + val cc: List = emptyList(), + val sensitive: Boolean = false, + val inReplyTo: String? = null, + val attachment: List = emptyList() +) : Object( + type = add(type, "Note") +), + HasId { override fun equals(other: Any?): Boolean { if (this === other) return true - if (other !is Note) return false + if (javaClass != other?.javaClass) return false if (!super.equals(other)) return false + other as Note + + if (id != other.id) return false if (attributedTo != other.attributedTo) return false - if (attachment != other.attachment) return false if (content != other.content) return false if (published != other.published) return false if (to != other.to) return false if (cc != other.cc) return false if (sensitive != other.sensitive) return false if (inReplyTo != other.inReplyTo) return false + if (attachment != other.attachment) return false return true } override fun hashCode(): Int { var result = super.hashCode() - result = 31 * result + (attributedTo?.hashCode() ?: 0) - result = 31 * result + attachment.hashCode() - result = 31 * result + (content?.hashCode() ?: 0) - result = 31 * result + (published?.hashCode() ?: 0) + result = 31 * result + id.hashCode() + result = 31 * result + attributedTo.hashCode() + result = 31 * result + content.hashCode() + result = 31 * result + published.hashCode() result = 31 * result + to.hashCode() result = 31 * result + cc.hashCode() result = 31 * result + sensitive.hashCode() result = 31 * result + (inReplyTo?.hashCode() ?: 0) + result = 31 * result + attachment.hashCode() return result } override fun toString(): String { - return "Note(attributedTo=$attributedTo, attachment=$attachment, " + - "content=$content, published=$published, to=$to, cc=$cc, sensitive=$sensitive," + - " inReplyTo=$inReplyTo) ${super.toString()}" + return "Note(" + + "id='$id', " + + "attributedTo='$attributedTo', " + + "content='$content', " + + "published='$published', " + + "to=$to, " + + "cc=$cc, " + + "sensitive=$sensitive, " + + "inReplyTo=$inReplyTo, " + + "attachment=$attachment" + + ")" + + " ${super.toString()}" } } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Person.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Person.kt index 7ee0075e..c04ba736 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Person.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Person.kt @@ -2,53 +2,33 @@ package dev.usbharu.hideout.activitypub.domain.model import dev.usbharu.hideout.activitypub.domain.model.objects.Object -open class Person : Object { - var preferredUsername: String? = null - var summary: String? = null - var inbox: String? = null - var outbox: String? = null - var url: String? = null - private var icon: Image? = null - var publicKey: Key? = null - var endpoints: Map = emptyMap() - var following: String? = null - var followers: String? = null - - protected constructor() : super() - - @Suppress("LongParameterList") - constructor( - type: List = emptyList(), - name: String, - id: String?, - preferredUsername: String?, - summary: String?, - inbox: String?, - outbox: String?, - url: String?, - icon: Image?, - publicKey: Key?, - endpoints: Map = emptyMap(), - followers: String?, - following: String? - ) : super(add(type, "Person"), name, id = id) { - this.preferredUsername = preferredUsername - this.summary = summary - this.inbox = inbox - this.outbox = outbox - this.url = url - this.icon = icon - this.publicKey = publicKey - this.endpoints = endpoints - this.followers = followers - this.following = following - } +open class Person +@Suppress("LongParameterList") +constructor( + type: List = emptyList(), + override val name: String, + override val id: String, + var preferredUsername: String?, + var summary: String?, + var inbox: String, + var outbox: String, + var url: String, + private var icon: Image?, + var publicKey: Key, + var endpoints: Map = emptyMap(), + var followers: String?, + var following: String? +) : Object(add(type, "Person")), HasId, HasName { override fun equals(other: Any?): Boolean { if (this === other) return true - if (other !is Person) return false + if (javaClass != other?.javaClass) return false if (!super.equals(other)) return false + other as Person + + if (name != other.name) return false + if (id != other.id) return false if (preferredUsername != other.preferredUsername) return false if (summary != other.summary) return false if (inbox != other.inbox) return false @@ -57,20 +37,26 @@ open class Person : Object { if (icon != other.icon) return false if (publicKey != other.publicKey) return false if (endpoints != other.endpoints) return false + if (followers != other.followers) return false + if (following != other.following) return false return true } override fun hashCode(): Int { var result = super.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + id.hashCode() result = 31 * result + (preferredUsername?.hashCode() ?: 0) result = 31 * result + (summary?.hashCode() ?: 0) - result = 31 * result + (inbox?.hashCode() ?: 0) - result = 31 * result + (outbox?.hashCode() ?: 0) - result = 31 * result + (url?.hashCode() ?: 0) + result = 31 * result + inbox.hashCode() + result = 31 * result + outbox.hashCode() + result = 31 * result + url.hashCode() result = 31 * result + (icon?.hashCode() ?: 0) - result = 31 * result + (publicKey?.hashCode() ?: 0) + result = 31 * result + publicKey.hashCode() result = 31 * result + endpoints.hashCode() + result = 31 * result + (followers?.hashCode() ?: 0) + result = 31 * result + (following?.hashCode() ?: 0) return result } } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Tombstone.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Tombstone.kt index 0017eac4..201eba32 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Tombstone.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Tombstone.kt @@ -2,11 +2,24 @@ package dev.usbharu.hideout.activitypub.domain.model import dev.usbharu.hideout.activitypub.domain.model.objects.Object -open class Tombstone : Object { - constructor( - type: List = emptyList(), - name: String = "Tombstone", - actor: String? = null, - id: String - ) : super(add(type, "Tombstone"), name, actor, id) +open class Tombstone(type: List = emptyList(), override val id: String) : + Object(add(type, "Tombstone")), + HasId { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + if (!super.equals(other)) return false + + other as Tombstone + + return id == other.id + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + id.hashCode() + return result + } + + override fun toString(): String = "Tombstone(id='$id') ${super.toString()}" } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Undo.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Undo.kt index 41cc0aa8..01dbc17c 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Undo.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/Undo.kt @@ -3,42 +3,40 @@ package dev.usbharu.hideout.activitypub.domain.model import com.fasterxml.jackson.databind.annotation.JsonDeserialize import dev.usbharu.hideout.activitypub.domain.model.objects.Object import dev.usbharu.hideout.activitypub.domain.model.objects.ObjectDeserializer -import java.time.Instant - -open class Undo : Object { +open class Undo( + type: List = emptyList(), + override val actor: String, + override val id: String, @JsonDeserialize(using = ObjectDeserializer::class) - var `object`: Object? = null - var published: String? = null - - protected constructor() : super() - constructor( - type: List = emptyList(), - name: String, - actor: String, - id: String?, - `object`: Object, - published: Instant - ) : super(add(type, "Undo"), name, actor, id) { - this.`object` = `object` - this.published = published.toString() - } + @Suppress("VariableNaming") val `object`: Object, + val published: String +) : Object(add(type, "Undo")), HasId, HasActor { override fun equals(other: Any?): Boolean { if (this === other) return true - if (other !is Undo) return false + if (javaClass != other?.javaClass) return false if (!super.equals(other)) return false + other as Undo + if (`object` != other.`object`) return false - return published == other.published + if (published != other.published) return false + if (actor != other.actor) return false + if (id != other.id) return false + + return true } override fun hashCode(): Int { var result = super.hashCode() - result = 31 * result + (`object`?.hashCode() ?: 0) - result = 31 * result + (published?.hashCode() ?: 0) + result = 31 * result + `object`.hashCode() + result = 31 * result + published.hashCode() + result = 31 * result + actor.hashCode() + result = 31 * result + id.hashCode() return result } - override fun toString(): String = "Undo(`object`=$`object`, published=$published) ${super.toString()}" + override fun toString(): String = + "Undo(`object`=$`object`, published=$published, actor='$actor', id='$id') ${super.toString()}" } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/Object.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/Object.kt index 23f26eac..cafdb44d 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/Object.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/Object.kt @@ -12,41 +12,29 @@ open class Object : JsonLd { set(value) { field = value.filter { it.isNotBlank() } } - var name: String? = null - var actor: String? = null - var id: String? = null protected constructor() - constructor(type: List, name: String? = null, actor: String? = null, id: String? = null) : super() { + constructor(type: List) : super() { this.type = type.filter { it.isNotBlank() } - this.name = name - this.actor = actor - this.id = id } override fun equals(other: Any?): Boolean { if (this === other) return true - if (other !is Object) return false + if (javaClass != other?.javaClass) return false if (!super.equals(other)) return false - if (type != other.type) return false - if (name != other.name) return false - if (actor != other.actor) return false - if (id != other.id) return false + other as Object - return true + return type == other.type } override fun hashCode(): Int { var result = super.hashCode() result = 31 * result + type.hashCode() - result = 31 * result + (name?.hashCode() ?: 0) - result = 31 * result + (actor?.hashCode() ?: 0) - result = 31 * result + (id?.hashCode() ?: 0) return result } - override fun toString(): String = "Object(type=$type, name=$name, actor=$actor, id=$id) ${super.toString()}" + override fun toString(): String = "Object(type=$type) ${super.toString()}" companion object { @JvmStatic diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/ObjectDeserializer.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/ObjectDeserializer.kt index 377dfcff..f28070e6 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/ObjectDeserializer.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/ObjectDeserializer.kt @@ -15,9 +15,6 @@ class ObjectDeserializer : JsonDeserializer() { if (treeNode.isValueNode) { return ObjectValue( emptyList(), - null, - null, - null, treeNode.asText() ) } else if (treeNode.isObject) { @@ -33,15 +30,8 @@ class ObjectDeserializer : JsonDeserializer() { } return when (activityType) { - ExtendedActivityVocabulary.Follow -> { - val readValue = p.codec.treeToValue(treeNode, Follow::class.java) - readValue - } - - ExtendedActivityVocabulary.Note -> { - p.codec.treeToValue(treeNode, Note::class.java) - } - + ExtendedActivityVocabulary.Follow -> p.codec.treeToValue(treeNode, Follow::class.java) + ExtendedActivityVocabulary.Note -> p.codec.treeToValue(treeNode, Note::class.java) ExtendedActivityVocabulary.Object -> p.codec.treeToValue(treeNode, Object::class.java) ExtendedActivityVocabulary.Link -> TODO() ExtendedActivityVocabulary.Activity -> TODO() diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/ObjectValue.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/ObjectValue.kt index b97b2541..62ed4344 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/ObjectValue.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/ObjectValue.kt @@ -1,33 +1,27 @@ package dev.usbharu.hideout.activitypub.domain.model.objects +import com.fasterxml.jackson.annotation.JsonCreator + @Suppress("VariableNaming") -open class ObjectValue : Object { - - var `object`: String? = null - - protected constructor() : super() - constructor(type: List, name: String?, actor: String?, id: String?, `object`: String?) : super( - type, - name, - actor, - id - ) { - this.`object` = `object` - } +open class ObjectValue @JsonCreator constructor(type: List, var `object`: String) : Object( + type +) { override fun equals(other: Any?): Boolean { if (this === other) return true - if (other !is ObjectValue) return false + if (javaClass != other?.javaClass) return false if (!super.equals(other)) return false + other as ObjectValue + return `object` == other.`object` } override fun hashCode(): Int { var result = super.hashCode() - result = 31 * result + (`object`?.hashCode() ?: 0) + result = 31 * result + `object`.hashCode() return result } - override fun toString(): String = "ObjectValue(`object`=$`object`) ${super.toString()}" + override fun toString(): String = "ObjectValue(`object`='$`object`') ${super.toString()}" } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/infrastructure/exposedquery/NoteQueryServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/infrastructure/exposedquery/NoteQueryServiceImpl.kt index fd516349..561b8de2 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/infrastructure/exposedquery/NoteQueryServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/infrastructure/exposedquery/NoteQueryServiceImpl.kt @@ -64,7 +64,6 @@ class NoteQueryServiceImpl(private val postRepository: PostRepository, private v this[Users.followers] ) return Note( - name = "Post", id = this[Posts.apId], attributedTo = this[Users.url], content = this[Posts.text], @@ -80,7 +79,7 @@ class NoteQueryServiceImpl(private val postRepository: PostRepository, private v private suspend fun Query.toNote(): Note { return this.groupBy { it[Posts.id] } .map { it.value } - .map { it.first().toNote(it.mapNotNull { it.toMediaOrNull() }) } + .map { it.first().toNote(it.mapNotNull { resultRow -> resultRow.toMediaOrNull() }) } .singleOr { FailedToGetResourcesException("resource does not exist.") } } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/common/ActivityPubStringResponse.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/common/ActivityPubStringResponse.kt deleted file mode 100644 index 60b58404..00000000 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/common/ActivityPubStringResponse.kt +++ /dev/null @@ -1,68 +0,0 @@ -package dev.usbharu.hideout.activitypub.interfaces.api.common - -import dev.usbharu.hideout.activitypub.domain.model.JsonLd -import dev.usbharu.hideout.util.HttpUtil.Activity -import io.ktor.http.* - -sealed class ActivityPubResponse( - val httpStatusCode: HttpStatusCode, - val contentType: ContentType = ContentType.Application.Activity -) { - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is ActivityPubResponse) return false - - if (httpStatusCode != other.httpStatusCode) return false - if (contentType != other.contentType) return false - - return true - } - - override fun hashCode(): Int { - var result = httpStatusCode.hashCode() - result = 31 * result + contentType.hashCode() - return result - } - - override fun toString(): String = "ActivityPubResponse(httpStatusCode=$httpStatusCode, contentType=$contentType)" -} - -class ActivityPubStringResponse( - httpStatusCode: HttpStatusCode = HttpStatusCode.OK, - val message: String, - contentType: ContentType = ContentType.Application.Activity -) : ActivityPubResponse(httpStatusCode, contentType) { - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is ActivityPubStringResponse) return false - - if (message != other.message) return false - - return true - } - - override fun hashCode(): Int = message.hashCode() - - override fun toString(): String = "ActivityPubStringResponse(message='$message') ${super.toString()}" -} - -class ActivityPubObjectResponse( - httpStatusCode: HttpStatusCode = HttpStatusCode.OK, - val message: JsonLd, - contentType: ContentType = ContentType.Application.Activity -) : ActivityPubResponse(httpStatusCode, contentType) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is ActivityPubObjectResponse) return false - - if (message != other.message) return false - - return true - } - - override fun hashCode(): Int = message.hashCode() - - override fun toString(): String = "ActivityPubObjectResponse(message=$message) ${super.toString()}" -} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/inbox/InboxController.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/inbox/InboxController.kt index b2e401cc..7fa3ce18 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/inbox/InboxController.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/inbox/InboxController.kt @@ -15,6 +15,7 @@ interface InboxController { "application/activity+json", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" ], + consumes = ["application/json", "application/*+json"], method = [RequestMethod.POST] ) suspend fun inbox(@RequestBody string: String): ResponseEntity diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/inbox/InboxControllerImpl.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/inbox/InboxControllerImpl.kt index 04f1d6f3..9fb8d101 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/inbox/InboxControllerImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/inbox/InboxControllerImpl.kt @@ -1,16 +1,39 @@ package dev.usbharu.hideout.activitypub.interfaces.api.inbox import dev.usbharu.hideout.activitypub.service.common.APService +import dev.usbharu.httpsignature.common.HttpHeaders +import dev.usbharu.httpsignature.common.HttpMethod +import dev.usbharu.httpsignature.common.HttpRequest import org.slf4j.LoggerFactory +import org.springframework.http.HttpHeaders.WWW_AUTHENTICATE import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RestController +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes +import java.net.URL @RestController class InboxControllerImpl(private val apService: APService) : InboxController { @Suppress("TooGenericExceptionCaught") - override suspend fun inbox(@RequestBody string: String): ResponseEntity { + override suspend fun inbox( + @RequestBody string: String + ): ResponseEntity { + val request = (requireNotNull(RequestContextHolder.getRequestAttributes()) as ServletRequestAttributes).request + + val headersList = request.headerNames?.toList().orEmpty() + LOGGER.trace("Inbox Headers {}", headersList) + + if (headersList.map { it.lowercase() }.contains("signature").not()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .header( + WWW_AUTHENTICATE, + "Signature realm=\"Example\",headers=\"(request-target) date host digest\"" + ) + .build() + } + val parseActivity = try { apService.parseActivity(string) } catch (e: Exception) { @@ -19,7 +42,29 @@ class InboxControllerImpl(private val apService: APService) : InboxController { } LOGGER.info("INBOX Processing Activity Type: {}", parseActivity) try { - apService.processActivity(string, parseActivity) + val url = request.requestURL.toString() + + val headers = + headersList.associateWith { header -> request.getHeaders(header)?.toList().orEmpty() } + + val method = when (val method = request.method.lowercase()) { + "get" -> HttpMethod.GET + "post" -> HttpMethod.POST + else -> { + throw IllegalArgumentException("Unsupported method: $method") + } + } + + apService.processActivity( + string, + parseActivity, + HttpRequest( + URL(url + request.queryString.orEmpty()), + HttpHeaders(headers), + method + ), + headers + ) } catch (e: Exception) { LOGGER.warn("FAILED Process Activity $parseActivity", e) return ResponseEntity(HttpStatus.ACCEPTED) diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/accept/APAcceptService.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/accept/APAcceptService.kt deleted file mode 100644 index 72c3f8c3..00000000 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/accept/APAcceptService.kt +++ /dev/null @@ -1,57 +0,0 @@ -package dev.usbharu.hideout.activitypub.service.activity.accept - -import dev.usbharu.hideout.activitypub.domain.exception.IllegalActivityPubObjectException -import dev.usbharu.hideout.activitypub.domain.model.Accept -import dev.usbharu.hideout.activitypub.domain.model.Follow -import dev.usbharu.hideout.activitypub.interfaces.api.common.ActivityPubResponse -import dev.usbharu.hideout.activitypub.interfaces.api.common.ActivityPubStringResponse -import dev.usbharu.hideout.application.external.Transaction -import dev.usbharu.hideout.core.query.FollowerQueryService -import dev.usbharu.hideout.core.query.UserQueryService -import dev.usbharu.hideout.core.service.user.UserService -import io.ktor.http.* -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Service - -interface APAcceptService { - suspend fun receiveAccept(accept: Accept): ActivityPubResponse -} - -@Service -class APAcceptServiceImpl( - private val userService: UserService, - private val userQueryService: UserQueryService, - private val followerQueryService: FollowerQueryService, - private val transaction: Transaction -) : APAcceptService { - override suspend fun receiveAccept(accept: Accept): ActivityPubResponse { - return transaction.transaction { - LOGGER.debug("START Follow") - LOGGER.trace("{}", accept) - val value = accept.`object` ?: throw IllegalActivityPubObjectException("object is null") - if (value.type.contains("Follow").not()) { - LOGGER.warn("FAILED Activity type is not 'Follow'") - throw IllegalActivityPubObjectException("Invalid type ${value.type}") - } - - val follow = value as Follow - val userUrl = follow.`object` ?: throw IllegalActivityPubObjectException("object is null") - val followerUrl = follow.actor ?: throw IllegalActivityPubObjectException("actor is null") - - val user = userQueryService.findByUrl(userUrl) - val follower = userQueryService.findByUrl(followerUrl) - - if (followerQueryService.alreadyFollow(user.id, follower.id)) { - LOGGER.debug("END User already follow from ${follower.url} to ${user.url}") - return@transaction ActivityPubStringResponse(HttpStatusCode.OK, "accepted") - } - userService.follow(user.id, follower.id) - LOGGER.debug("SUCCESS Follow from ${follower.url} to ${user.url}.") - ActivityPubStringResponse(HttpStatusCode.OK, "accepted") - } - } - - companion object { - private val LOGGER = LoggerFactory.getLogger(APAcceptServiceImpl::class.java) - } -} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/accept/ApAcceptProcessor.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/accept/ApAcceptProcessor.kt new file mode 100644 index 00000000..2ee0f07e --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/accept/ApAcceptProcessor.kt @@ -0,0 +1,52 @@ +package dev.usbharu.hideout.activitypub.service.activity.accept + +import dev.usbharu.hideout.activitypub.domain.exception.IllegalActivityPubObjectException +import dev.usbharu.hideout.activitypub.domain.model.Accept +import dev.usbharu.hideout.activitypub.domain.model.Follow +import dev.usbharu.hideout.activitypub.service.common.AbstractActivityPubProcessor +import dev.usbharu.hideout.activitypub.service.common.ActivityPubProcessContext +import dev.usbharu.hideout.activitypub.service.common.ActivityType +import dev.usbharu.hideout.application.external.Transaction +import dev.usbharu.hideout.core.query.FollowerQueryService +import dev.usbharu.hideout.core.query.UserQueryService +import dev.usbharu.hideout.core.service.user.UserService +import org.springframework.stereotype.Service + +@Service +class ApAcceptProcessor( + transaction: Transaction, + private val userQueryService: UserQueryService, + private val followerQueryService: FollowerQueryService, + private val userService: UserService +) : + AbstractActivityPubProcessor(transaction) { + + override suspend fun internalProcess(activity: ActivityPubProcessContext) { + val value = activity.activity.apObject ?: throw IllegalActivityPubObjectException("object is null") + + if (value.type.contains("Follow").not()) { + logger.warn("FAILED Activity type isn't Follow.") + throw IllegalActivityPubObjectException("Invalid type ${value.type}") + } + + val follow = value as Follow + + val userUrl = follow.apObject + val followerUrl = follow.actor + + val user = userQueryService.findByUrl(userUrl) + val follower = userQueryService.findByUrl(followerUrl) + + if (followerQueryService.alreadyFollow(user.id, follower.id)) { + logger.debug("END User already follow from ${follower.url} to ${user.url}.") + return + } + + userService.follow(user.id, follower.id) + logger.debug("SUCCESS Follow from ${follower.url} to ${user.url}.") + } + + override fun isSupported(activityType: ActivityType): Boolean = activityType == ActivityType.Accept + + override fun type(): Class = Accept::class.java +} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/create/APCreateService.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/create/APCreateService.kt deleted file mode 100644 index e0d179ea..00000000 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/create/APCreateService.kt +++ /dev/null @@ -1,44 +0,0 @@ -package dev.usbharu.hideout.activitypub.service.activity.create - -import dev.usbharu.hideout.activitypub.domain.exception.IllegalActivityPubObjectException -import dev.usbharu.hideout.activitypub.domain.model.Create -import dev.usbharu.hideout.activitypub.domain.model.Note -import dev.usbharu.hideout.activitypub.interfaces.api.common.ActivityPubResponse -import dev.usbharu.hideout.activitypub.interfaces.api.common.ActivityPubStringResponse -import dev.usbharu.hideout.activitypub.service.objects.note.APNoteService -import dev.usbharu.hideout.application.external.Transaction -import io.ktor.http.* -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Service - -interface APCreateService { - suspend fun receiveCreate(create: Create): ActivityPubResponse -} - -@Service -class APCreateServiceImpl( - private val apNoteService: APNoteService, - private val transaction: Transaction -) : APCreateService { - override suspend fun receiveCreate(create: Create): ActivityPubResponse { - LOGGER.debug("START Create new remote note.") - LOGGER.trace("{}", create) - - val value = create.`object` ?: throw IllegalActivityPubObjectException("object is null") - if (value.type.contains("Note").not()) { - LOGGER.warn("FAILED Object type is not 'Note'") - throw IllegalActivityPubObjectException("object is not Note") - } - - return transaction.transaction { - val note = value as Note - apNoteService.fetchNote(note) - LOGGER.debug("SUCCESS Create new remote note. ${note.id} by ${note.attributedTo}") - ActivityPubStringResponse(HttpStatusCode.OK, "Created") - } - } - - companion object { - private val LOGGER = LoggerFactory.getLogger(APCreateServiceImpl::class.java) - } -} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/create/ApSendCreateServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/create/ApSendCreateServiceImpl.kt index 0d3eeac8..226f2a63 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/create/ApSendCreateServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/create/ApSendCreateServiceImpl.kt @@ -33,7 +33,7 @@ class ApSendCreateServiceImpl( val note = noteQueryService.findById(post.id).first val create = Create( name = "Create Note", - `object` = note, + apObject = note, actor = note.attributedTo, id = "${applicationConfig.url}/create/note/${post.id}" ) diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/create/CreateActivityProcessor.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/create/CreateActivityProcessor.kt new file mode 100644 index 00000000..827042ef --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/create/CreateActivityProcessor.kt @@ -0,0 +1,22 @@ +package dev.usbharu.hideout.activitypub.service.activity.create + +import dev.usbharu.hideout.activitypub.domain.model.Create +import dev.usbharu.hideout.activitypub.domain.model.Note +import dev.usbharu.hideout.activitypub.service.common.AbstractActivityPubProcessor +import dev.usbharu.hideout.activitypub.service.common.ActivityPubProcessContext +import dev.usbharu.hideout.activitypub.service.common.ActivityType +import dev.usbharu.hideout.activitypub.service.objects.note.APNoteService +import dev.usbharu.hideout.application.external.Transaction +import org.springframework.stereotype.Service + +@Service +class CreateActivityProcessor(transaction: Transaction, private val apNoteService: APNoteService) : + AbstractActivityPubProcessor(transaction) { + override suspend fun internalProcess(activity: ActivityPubProcessContext) { + apNoteService.fetchNote(activity.activity.apObject as Note) + } + + override fun isSupported(activityType: ActivityType): Boolean = activityType == ActivityType.Create + + override fun type(): Class = Create::class.java +} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/delete/APDeleteProcessor.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/delete/APDeleteProcessor.kt new file mode 100644 index 00000000..efb37938 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/delete/APDeleteProcessor.kt @@ -0,0 +1,42 @@ +package dev.usbharu.hideout.activitypub.service.activity.delete + +import dev.usbharu.hideout.activitypub.domain.exception.IllegalActivityPubObjectException +import dev.usbharu.hideout.activitypub.domain.model.Delete +import dev.usbharu.hideout.activitypub.domain.model.HasId +import dev.usbharu.hideout.activitypub.service.common.AbstractActivityPubProcessor +import dev.usbharu.hideout.activitypub.service.common.ActivityPubProcessContext +import dev.usbharu.hideout.activitypub.service.common.ActivityType +import dev.usbharu.hideout.application.external.Transaction +import dev.usbharu.hideout.core.domain.exception.FailedToGetResourcesException +import dev.usbharu.hideout.core.domain.model.post.PostRepository +import dev.usbharu.hideout.core.query.PostQueryService +import org.springframework.stereotype.Service + +@Service +class APDeleteProcessor( + transaction: Transaction, + private val postQueryService: PostQueryService, + private val postRepository: PostRepository +) : + AbstractActivityPubProcessor(transaction) { + override suspend fun internalProcess(activity: ActivityPubProcessContext) { + val value = activity.activity.apObject + if (value !is HasId) { + throw IllegalActivityPubObjectException("object hasn't id") + } + val deleteId = value.id + + val post = try { + postQueryService.findByApId(deleteId) + } catch (e: FailedToGetResourcesException) { + logger.warn("FAILED delete id: {} is not found.", deleteId, e) + return + } + + postRepository.delete(post.id) + } + + override fun isSupported(activityType: ActivityType): Boolean = activityType == ActivityType.Delete + + override fun type(): Class = Delete::class.java +} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/delete/APReceiveDeleteService.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/delete/APReceiveDeleteService.kt deleted file mode 100644 index 9d047605..00000000 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/delete/APReceiveDeleteService.kt +++ /dev/null @@ -1,8 +0,0 @@ -package dev.usbharu.hideout.activitypub.service.activity.delete - -import dev.usbharu.hideout.activitypub.domain.model.Delete -import dev.usbharu.hideout.activitypub.interfaces.api.common.ActivityPubResponse - -interface APReceiveDeleteService { - suspend fun receiveDelete(delete: Delete): ActivityPubResponse -} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/delete/APReceiveDeleteServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/delete/APReceiveDeleteServiceImpl.kt deleted file mode 100644 index c00aeda6..00000000 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/delete/APReceiveDeleteServiceImpl.kt +++ /dev/null @@ -1,31 +0,0 @@ -package dev.usbharu.hideout.activitypub.service.activity.delete - -import dev.usbharu.hideout.activitypub.domain.exception.IllegalActivityPubObjectException -import dev.usbharu.hideout.activitypub.domain.model.Delete -import dev.usbharu.hideout.activitypub.interfaces.api.common.ActivityPubResponse -import dev.usbharu.hideout.activitypub.interfaces.api.common.ActivityPubStringResponse -import dev.usbharu.hideout.application.external.Transaction -import dev.usbharu.hideout.core.domain.exception.FailedToGetResourcesException -import dev.usbharu.hideout.core.domain.model.post.PostRepository -import dev.usbharu.hideout.core.query.PostQueryService -import io.ktor.http.* -import org.springframework.stereotype.Service - -@Service -class APReceiveDeleteServiceImpl( - private val postQueryService: PostQueryService, - private val postRepository: PostRepository, - private val transaction: Transaction -) : APReceiveDeleteService { - override suspend fun receiveDelete(delete: Delete): ActivityPubResponse = transaction.transaction { - val deleteId = delete.`object`?.id ?: throw IllegalActivityPubObjectException("object.id is null") - - val post = try { - postQueryService.findByApId(deleteId) - } catch (_: FailedToGetResourcesException) { - return@transaction ActivityPubStringResponse(HttpStatusCode.OK, "Resource not found or already deleted") - } - postRepository.delete(post.id) - return@transaction ActivityPubStringResponse(HttpStatusCode.OK, "Resource was deleted.") - } -} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APFollowProcessor.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APFollowProcessor.kt new file mode 100644 index 00000000..537606f4 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APFollowProcessor.kt @@ -0,0 +1,36 @@ +package dev.usbharu.hideout.activitypub.service.activity.follow + +import com.fasterxml.jackson.databind.ObjectMapper +import dev.usbharu.hideout.activitypub.domain.model.Follow +import dev.usbharu.hideout.activitypub.service.common.AbstractActivityPubProcessor +import dev.usbharu.hideout.activitypub.service.common.ActivityPubProcessContext +import dev.usbharu.hideout.activitypub.service.common.ActivityType +import dev.usbharu.hideout.application.external.Transaction +import dev.usbharu.hideout.core.external.job.ReceiveFollowJob +import dev.usbharu.hideout.core.external.job.ReceiveFollowJobParam +import dev.usbharu.hideout.core.service.job.JobQueueParentService +import org.springframework.stereotype.Service + +@Service +class APFollowProcessor( + transaction: Transaction, + private val jobQueueParentService: JobQueueParentService, + private val objectMapper: ObjectMapper +) : + AbstractActivityPubProcessor(transaction) { + override suspend fun internalProcess(activity: ActivityPubProcessContext) { + logger.info("FOLLOW from: {} to {}", activity.activity.actor, activity.activity.apObject) + + // inboxをジョブキューに乗せているので既に不要だが、フォロー承認制アカウントを実装する際に必要なので残す + val jobProps = ReceiveFollowJobParam( + activity.activity.actor, + objectMapper.writeValueAsString(activity.activity), + activity.activity.apObject + ) + jobQueueParentService.scheduleTypeSafe(ReceiveFollowJob, jobProps) + } + + override fun isSupported(activityType: ActivityType): Boolean = activityType == ActivityType.Follow + + override fun type(): Class = Follow::class.java +} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APReceiveFollowJobProcessor.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APReceiveFollowJobProcessor.kt new file mode 100644 index 00000000..0c3957b1 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APReceiveFollowJobProcessor.kt @@ -0,0 +1,61 @@ +package dev.usbharu.hideout.activitypub.service.activity.follow + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import dev.usbharu.hideout.activitypub.domain.model.Accept +import dev.usbharu.hideout.activitypub.domain.model.Follow +import dev.usbharu.hideout.activitypub.service.common.APRequestService +import dev.usbharu.hideout.activitypub.service.objects.user.APUserService +import dev.usbharu.hideout.application.external.Transaction +import dev.usbharu.hideout.core.external.job.ReceiveFollowJob +import dev.usbharu.hideout.core.external.job.ReceiveFollowJobParam +import dev.usbharu.hideout.core.query.UserQueryService +import dev.usbharu.hideout.core.service.job.JobProcessor +import dev.usbharu.hideout.core.service.user.UserService +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +@Service +class APReceiveFollowJobProcessor( + private val transaction: Transaction, + private val userQueryService: UserQueryService, + private val apUserService: APUserService, + private val objectMapper: ObjectMapper, + private val apRequestService: APRequestService, + private val userService: UserService +) : + JobProcessor { + override suspend fun process(param: ReceiveFollowJobParam) = transaction.transaction { + val person = apUserService.fetchPerson(param.actor, param.targetActor) + val follow = objectMapper.readValue(param.follow) + + logger.info("START Follow from: {} to {}", param.targetActor, param.actor) + + val signer = userQueryService.findByUrl(param.targetActor) + + val urlString = person.inbox + + apRequestService.apPost( + url = urlString, + body = Accept( + name = "Follow", + apObject = follow, + actor = param.targetActor + ), + signer = signer + ) + + val targetEntity = userQueryService.findByUrl(param.targetActor) + val followActorEntity = + userQueryService.findByUrl(follow.actor) + + userService.followRequest(targetEntity.id, followActorEntity.id) + logger.info("SUCCESS Follow from: {} to: {}", param.targetActor, param.actor) + } + + override fun job(): ReceiveFollowJob = ReceiveFollowJob + + companion object { + private val logger = LoggerFactory.getLogger(APReceiveFollowJobProcessor::class.java) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APReceiveFollowJobService.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APReceiveFollowJobService.kt deleted file mode 100644 index 2b7a84d4..00000000 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APReceiveFollowJobService.kt +++ /dev/null @@ -1,8 +0,0 @@ -package dev.usbharu.hideout.activitypub.service.activity.follow - -import dev.usbharu.hideout.core.external.job.ReceiveFollowJob -import kjob.core.job.JobProps - -interface APReceiveFollowJobService { - suspend fun receiveFollowJob(props: JobProps) -} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APReceiveFollowJobServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APReceiveFollowJobServiceImpl.kt deleted file mode 100644 index 02a466ab..00000000 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APReceiveFollowJobServiceImpl.kt +++ /dev/null @@ -1,61 +0,0 @@ -package dev.usbharu.hideout.activitypub.service.activity.follow - -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue -import dev.usbharu.hideout.activitypub.domain.model.Accept -import dev.usbharu.hideout.activitypub.domain.model.Follow -import dev.usbharu.hideout.activitypub.service.common.APRequestService -import dev.usbharu.hideout.activitypub.service.objects.user.APUserService -import dev.usbharu.hideout.application.external.Transaction -import dev.usbharu.hideout.core.external.job.ReceiveFollowJob -import dev.usbharu.hideout.core.query.UserQueryService -import dev.usbharu.hideout.core.service.user.UserService -import kjob.core.job.JobProps -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.stereotype.Component - -@Component -class APReceiveFollowJobServiceImpl( - private val apUserService: APUserService, - private val userQueryService: UserQueryService, - private val apRequestService: APRequestService, - private val userService: UserService, - @Qualifier("activitypub") private val objectMapper: ObjectMapper, - private val transaction: Transaction -) : APReceiveFollowJobService { - override suspend fun receiveFollowJob(props: JobProps) { - transaction.transaction { - val actor = props[ReceiveFollowJob.actor] - val targetActor = props[ReceiveFollowJob.targetActor] - val person = apUserService.fetchPerson(actor, targetActor) - val follow = objectMapper.readValue(props[ReceiveFollowJob.follow]) - logger.info("START Follow from: {} to: {}", targetActor, actor) - - val signer = userQueryService.findByUrl(targetActor) - - val urlString = person.inbox ?: throw IllegalArgumentException("inbox is not found") - - apRequestService.apPost( - url = urlString, - body = Accept( - name = "Follow", - `object` = follow, - actor = targetActor - ), - signer = signer - ) - - val targetEntity = userQueryService.findByUrl(targetActor) - val followActorEntity = - userQueryService.findByUrl(follow.actor ?: throw java.lang.IllegalArgumentException("Actor is null")) - - userService.followRequest(targetEntity.id, followActorEntity.id) - logger.info("SUCCESS Follow from: {} to: {}", targetActor, actor) - } - } - - companion object { - private val logger = LoggerFactory.getLogger(APReceiveFollowJobServiceImpl::class.java) - } -} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APReceiveFollowService.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APReceiveFollowService.kt index 95b47ad1..01d85a8b 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APReceiveFollowService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APReceiveFollowService.kt @@ -2,17 +2,14 @@ package dev.usbharu.hideout.activitypub.service.activity.follow import com.fasterxml.jackson.databind.ObjectMapper import dev.usbharu.hideout.activitypub.domain.model.Follow -import dev.usbharu.hideout.activitypub.interfaces.api.common.ActivityPubResponse -import dev.usbharu.hideout.activitypub.interfaces.api.common.ActivityPubStringResponse import dev.usbharu.hideout.core.external.job.ReceiveFollowJob import dev.usbharu.hideout.core.service.job.JobQueueParentService -import io.ktor.http.* import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Qualifier import org.springframework.stereotype.Service interface APReceiveFollowService { - suspend fun receiveFollow(follow: Follow): ActivityPubResponse + suspend fun receiveFollow(follow: Follow) } @Service @@ -20,14 +17,14 @@ class APReceiveFollowServiceImpl( private val jobQueueParentService: JobQueueParentService, @Qualifier("activitypub") private val objectMapper: ObjectMapper ) : APReceiveFollowService { - override suspend fun receiveFollow(follow: Follow): ActivityPubResponse { - logger.info("FOLLOW from: {} to: {}", follow.actor, follow.`object`) + override suspend fun receiveFollow(follow: Follow) { + logger.info("FOLLOW from: {} to: {}", follow.actor, follow.apObject) jobQueueParentService.schedule(ReceiveFollowJob) { props[ReceiveFollowJob.actor] = follow.actor props[ReceiveFollowJob.follow] = objectMapper.writeValueAsString(follow) - props[ReceiveFollowJob.targetActor] = follow.`object` + props[ReceiveFollowJob.targetActor] = follow.apObject } - return ActivityPubStringResponse(HttpStatusCode.OK, "{}", ContentType.Application.Json) + return } companion object { diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APSendFollowService.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APSendFollowService.kt index e71e7c79..825ec198 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APSendFollowService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APSendFollowService.kt @@ -15,8 +15,7 @@ class APSendFollowServiceImpl( ) : APSendFollowService { override suspend fun sendFollow(sendFollowDto: SendFollowDto) { val follow = Follow( - name = "Follow", - `object` = sendFollowDto.followTargetUserId.url, + apObject = sendFollowDto.followTargetUserId.url, actor = sendFollowDto.userId.url ) diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/APLikeProcessor.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/APLikeProcessor.kt new file mode 100644 index 00000000..665c7c94 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/APLikeProcessor.kt @@ -0,0 +1,55 @@ +package dev.usbharu.hideout.activitypub.service.activity.like + +import dev.usbharu.hideout.activitypub.domain.exception.FailedToGetActivityPubResourceException +import dev.usbharu.hideout.activitypub.domain.model.Like +import dev.usbharu.hideout.activitypub.service.common.AbstractActivityPubProcessor +import dev.usbharu.hideout.activitypub.service.common.ActivityPubProcessContext +import dev.usbharu.hideout.activitypub.service.common.ActivityType +import dev.usbharu.hideout.activitypub.service.objects.note.APNoteService +import dev.usbharu.hideout.activitypub.service.objects.user.APUserService +import dev.usbharu.hideout.application.external.Transaction +import dev.usbharu.hideout.core.query.PostQueryService +import dev.usbharu.hideout.core.service.reaction.ReactionService +import org.springframework.stereotype.Service + +@Service +class APLikeProcessor( + transaction: Transaction, + private val apUserService: APUserService, + private val apNoteService: APNoteService, + private val postQueryService: PostQueryService, + private val reactionService: ReactionService +) : + AbstractActivityPubProcessor(transaction) { + override suspend fun internalProcess(activity: ActivityPubProcessContext) { + val actor = activity.activity.actor + val content = activity.activity.content + + val target = activity.activity.apObject + + val personWithEntity = apUserService.fetchPersonWithEntity(actor) + + try { + apNoteService.fetchNoteAsync(target).await() + } catch (e: FailedToGetActivityPubResourceException) { + logger.debug("FAILED failed to get {}", target) + logger.trace("", e) + return + } + + val post = postQueryService.findByUrl(target) + + reactionService.receiveReaction( + content, + actor.substringAfter("://").substringBefore("/"), + personWithEntity.second.id, + post.id + ) + + logger.debug("SUCCESS Add Like($content) from ${personWithEntity.second.url} to ${post.url}") + } + + override fun isSupported(activityType: ActivityType): Boolean = activityType == ActivityType.Like + + override fun type(): Class = Like::class.java +} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/APLikeService.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/APLikeService.kt deleted file mode 100644 index c37af164..00000000 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/APLikeService.kt +++ /dev/null @@ -1,66 +0,0 @@ -package dev.usbharu.hideout.activitypub.service.activity.like - -import dev.usbharu.hideout.activitypub.domain.exception.FailedToGetActivityPubResourceException -import dev.usbharu.hideout.activitypub.domain.exception.IllegalActivityPubObjectException -import dev.usbharu.hideout.activitypub.domain.model.Like -import dev.usbharu.hideout.activitypub.interfaces.api.common.ActivityPubResponse -import dev.usbharu.hideout.activitypub.interfaces.api.common.ActivityPubStringResponse -import dev.usbharu.hideout.activitypub.service.objects.note.APNoteService -import dev.usbharu.hideout.activitypub.service.objects.user.APUserService -import dev.usbharu.hideout.application.external.Transaction -import dev.usbharu.hideout.core.query.PostQueryService -import dev.usbharu.hideout.core.service.reaction.ReactionService -import io.ktor.http.* -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Service - -interface APLikeService { - suspend fun receiveLike(like: Like): ActivityPubResponse -} - -@Service -class APLikeServiceImpl( - private val reactionService: ReactionService, - private val apUserService: APUserService, - private val apNoteService: APNoteService, - private val postQueryService: PostQueryService, - private val transaction: Transaction -) : APLikeService { - override suspend fun receiveLike(like: Like): ActivityPubResponse { - LOGGER.debug("START Add Like") - LOGGER.trace("{}", like) - - val actor = like.actor ?: throw IllegalActivityPubObjectException("actor is null") - val content = like.content ?: throw IllegalActivityPubObjectException("content is null") - like.`object` ?: throw IllegalActivityPubObjectException("object is null") - transaction.transaction { - LOGGER.trace("FETCH Liked Person $actor") - val person = apUserService.fetchPersonWithEntity(actor) - LOGGER.trace("{}", person.second) - - LOGGER.trace("FETCH Liked Note ${like.`object`}") - try { - apNoteService.fetchNoteAsync(like.`object` ?: return@transaction).await() - } catch (e: FailedToGetActivityPubResourceException) { - LOGGER.debug("FAILED Failed to Get ${like.`object`}") - LOGGER.trace("", e) - return@transaction - } - val post = postQueryService.findByUrl(like.`object` ?: return@transaction) - LOGGER.trace("{}", post) - - reactionService.receiveReaction( - content, - actor.substringAfter("://").substringBefore("/"), - person.second.id, - post.id - ) - LOGGER.debug("SUCCESS Add Like($content) from ${person.second.url} to ${post.url}") - } - return ActivityPubStringResponse(HttpStatusCode.OK, "") - } - - companion object { - private val LOGGER = LoggerFactory.getLogger(APLikeServiceImpl::class.java) - } -} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/ApReactionJobProcessor.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/ApReactionJobProcessor.kt new file mode 100644 index 00000000..1487eb56 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/ApReactionJobProcessor.kt @@ -0,0 +1,36 @@ +package dev.usbharu.hideout.activitypub.service.activity.like + +import dev.usbharu.hideout.activitypub.domain.model.Like +import dev.usbharu.hideout.activitypub.service.common.APRequestService +import dev.usbharu.hideout.application.config.ApplicationConfig +import dev.usbharu.hideout.application.external.Transaction +import dev.usbharu.hideout.core.external.job.DeliverReactionJob +import dev.usbharu.hideout.core.external.job.DeliverReactionJobParam +import dev.usbharu.hideout.core.query.UserQueryService +import dev.usbharu.hideout.core.service.job.JobProcessor +import org.springframework.stereotype.Service + +@Service +class ApReactionJobProcessor( + private val userQueryService: UserQueryService, + private val apRequestService: APRequestService, + private val applicationConfig: ApplicationConfig, + private val transaction: Transaction +) : JobProcessor { + override suspend fun process(param: DeliverReactionJobParam): Unit = transaction.transaction { + val signer = userQueryService.findByUrl(param.actor) + + apRequestService.apPost( + param.inbox, + Like( + actor = param.actor, + apObject = param.postUrl, + id = "${applicationConfig.url}/liek/note/${param.id}", + content = param.reaction + ), + signer + ) + } + + override fun job(): DeliverReactionJob = DeliverReactionJob +} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/ApReactionJobService.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/ApReactionJobService.kt deleted file mode 100644 index ca43443f..00000000 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/ApReactionJobService.kt +++ /dev/null @@ -1,10 +0,0 @@ -package dev.usbharu.hideout.activitypub.service.activity.like - -import dev.usbharu.hideout.core.external.job.DeliverReactionJob -import dev.usbharu.hideout.core.external.job.DeliverRemoveReactionJob -import kjob.core.job.JobProps - -interface ApReactionJobService { - suspend fun reactionJob(props: JobProps) - suspend fun removeReactionJob(props: JobProps) -} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/ApReactionJobServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/ApReactionJobServiceImpl.kt deleted file mode 100644 index 5a8e088c..00000000 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/ApReactionJobServiceImpl.kt +++ /dev/null @@ -1,66 +0,0 @@ -package dev.usbharu.hideout.activitypub.service.activity.like - -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue -import dev.usbharu.hideout.activitypub.domain.model.Like -import dev.usbharu.hideout.activitypub.domain.model.Undo -import dev.usbharu.hideout.activitypub.service.common.APRequestService -import dev.usbharu.hideout.application.config.ApplicationConfig -import dev.usbharu.hideout.core.external.job.DeliverReactionJob -import dev.usbharu.hideout.core.external.job.DeliverRemoveReactionJob -import dev.usbharu.hideout.core.query.UserQueryService -import kjob.core.job.JobProps -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.stereotype.Service -import java.time.Instant - -@Service -class ApReactionJobServiceImpl( - private val userQueryService: UserQueryService, - private val apRequestService: APRequestService, - private val applicationConfig: ApplicationConfig, - @Qualifier("activitypub") private val objectMapper: ObjectMapper -) : ApReactionJobService { - override suspend fun reactionJob(props: JobProps) { - val inbox = props[DeliverReactionJob.inbox] - val actor = props[DeliverReactionJob.actor] - val postUrl = props[DeliverReactionJob.postUrl] - val id = props[DeliverReactionJob.id] - val content = props[DeliverReactionJob.reaction] - - val signer = userQueryService.findByUrl(actor) - - apRequestService.apPost( - inbox, - Like( - name = "Like", - actor = actor, - `object` = postUrl, - id = "${applicationConfig.url}/like/note/$id", - content = content - ), - signer - ) - } - - override suspend fun removeReactionJob(props: JobProps) { - val inbox = props[DeliverRemoveReactionJob.inbox] - val actor = props[DeliverRemoveReactionJob.actor] - val like = objectMapper.readValue(props[DeliverRemoveReactionJob.like]) - val id = props[DeliverRemoveReactionJob.id] - - val signer = userQueryService.findByUrl(actor) - - apRequestService.apPost( - inbox, - Undo( - name = "Undo Reaction", - actor = actor, - `object` = like, - id = "${applicationConfig.url}/undo/note/$id", - published = Instant.now() - ), - signer - ) - } -} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/ApRemoveReactionJobProcessor.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/ApRemoveReactionJobProcessor.kt new file mode 100644 index 00000000..285670b5 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/ApRemoveReactionJobProcessor.kt @@ -0,0 +1,43 @@ +package dev.usbharu.hideout.activitypub.service.activity.like + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import dev.usbharu.hideout.activitypub.domain.model.Like +import dev.usbharu.hideout.activitypub.domain.model.Undo +import dev.usbharu.hideout.activitypub.service.common.APRequestService +import dev.usbharu.hideout.application.config.ApplicationConfig +import dev.usbharu.hideout.application.external.Transaction +import dev.usbharu.hideout.core.external.job.DeliverRemoveReactionJob +import dev.usbharu.hideout.core.external.job.DeliverRemoveReactionJobParam +import dev.usbharu.hideout.core.query.UserQueryService +import dev.usbharu.hideout.core.service.job.JobProcessor +import org.springframework.stereotype.Service +import java.time.Instant + +@Service +class ApRemoveReactionJobProcessor( + private val userQueryService: UserQueryService, + private val transaction: Transaction, + private val objectMapper: ObjectMapper, + private val apRequestService: APRequestService, + private val applicationConfig: ApplicationConfig +) : JobProcessor { + override suspend fun process(param: DeliverRemoveReactionJobParam): Unit = transaction.transaction { + val like = objectMapper.readValue(param.like) + + val signer = userQueryService.findByUrl(param.actor) + + apRequestService.apPost( + param.inbox, + Undo( + actor = param.actor, + `object` = like, + id = "${applicationConfig.url}/undo/like/${param.id}", + published = Instant.now().toString() + ), + signer + ) + } + + override fun job(): DeliverRemoveReactionJob = DeliverRemoveReactionJob +} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/undo/APUndoProcessor.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/undo/APUndoProcessor.kt new file mode 100644 index 00000000..2c8067a4 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/undo/APUndoProcessor.kt @@ -0,0 +1,55 @@ +package dev.usbharu.hideout.activitypub.service.activity.undo + +import dev.usbharu.hideout.activitypub.domain.model.Follow +import dev.usbharu.hideout.activitypub.domain.model.Undo +import dev.usbharu.hideout.activitypub.service.common.AbstractActivityPubProcessor +import dev.usbharu.hideout.activitypub.service.common.ActivityPubProcessContext +import dev.usbharu.hideout.activitypub.service.common.ActivityType +import dev.usbharu.hideout.activitypub.service.objects.user.APUserService +import dev.usbharu.hideout.application.external.Transaction +import dev.usbharu.hideout.core.query.UserQueryService +import dev.usbharu.hideout.core.service.user.UserService +import org.springframework.stereotype.Service + +@Service +class APUndoProcessor( + transaction: Transaction, + private val apUserService: APUserService, + private val userQueryService: UserQueryService, + private val userService: UserService +) : + AbstractActivityPubProcessor(transaction) { + override suspend fun internalProcess(activity: ActivityPubProcessContext) { + val undo = activity.activity + if (undo.actor == null) { + return + } + + val type = + undo.`object`.type.orEmpty() + .firstOrNull { it == "Block" || it == "Follow" || it == "Like" || it == "Announce" || it == "Accept" } + ?: return + + when (type) { + "Follow" -> { + val follow = undo.`object` as Follow + + if (follow.apObject == null) { + return + } + apUserService.fetchPerson(undo.actor, follow.apObject) + val follower = userQueryService.findByUrl(undo.actor) + val target = userQueryService.findByUrl(follow.apObject) + userService.unfollow(target.id, follower.id) + return + } + + else -> {} + } + TODO() + } + + override fun isSupported(activityType: ActivityType): Boolean = activityType == ActivityType.Undo + + override fun type(): Class = Undo::class.java +} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/undo/APUndoService.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/undo/APUndoService.kt deleted file mode 100644 index 372ff33c..00000000 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/activity/undo/APUndoService.kt +++ /dev/null @@ -1,56 +0,0 @@ -package dev.usbharu.hideout.activitypub.service.activity.undo - -import dev.usbharu.hideout.activitypub.domain.model.Follow -import dev.usbharu.hideout.activitypub.domain.model.Undo -import dev.usbharu.hideout.activitypub.interfaces.api.common.ActivityPubResponse -import dev.usbharu.hideout.activitypub.interfaces.api.common.ActivityPubStringResponse -import dev.usbharu.hideout.activitypub.service.objects.user.APUserService -import dev.usbharu.hideout.application.external.Transaction -import dev.usbharu.hideout.core.query.UserQueryService -import dev.usbharu.hideout.core.service.user.UserService -import io.ktor.http.* -import org.springframework.stereotype.Service - -interface APUndoService { - suspend fun receiveUndo(undo: Undo): ActivityPubResponse -} - -@Service -@Suppress("UnsafeCallOnNullableType") -class APUndoServiceImpl( - private val userService: UserService, - private val apUserService: APUserService, - private val userQueryService: UserQueryService, - private val transaction: Transaction -) : APUndoService { - override suspend fun receiveUndo(undo: Undo): ActivityPubResponse { - if (undo.actor == null) { - return ActivityPubStringResponse(HttpStatusCode.BadRequest, "actor is null") - } - - val type = - undo.`object`?.type.orEmpty() - .firstOrNull { it == "Block" || it == "Follow" || it == "Like" || it == "Announce" || it == "Accept" } - ?: return ActivityPubStringResponse(HttpStatusCode.BadRequest, "unknown type ${undo.`object`?.type}") - - when (type) { - "Follow" -> { - val follow = undo.`object` as Follow - - if (follow.`object` == null) { - return ActivityPubStringResponse(HttpStatusCode.BadRequest, "object.object is null") - } - transaction.transaction { - apUserService.fetchPerson(undo.actor!!, follow.`object`) - val follower = userQueryService.findByUrl(undo.actor!!) - val target = userQueryService.findByUrl(follow.`object`!!) - userService.unfollow(target.id, follower.id) - } - return ActivityPubStringResponse(HttpStatusCode.OK, "Accept") - } - - else -> {} - } - TODO() - } -} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/APRequestServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/APRequestServiceImpl.kt index 6e87d402..511d3e67 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/APRequestServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/APRequestServiceImpl.kt @@ -37,17 +37,31 @@ class APRequestServiceImpl( logger.debug("START ActivityPub Request GET url: {}, signer: {}", url, signer?.url) val date = dateTimeFormatter.format(ZonedDateTime.now(ZoneId.of("GMT"))) val u = URL(url) - if (signer?.privateKey == null) { - val bodyAsText = httpClient.get(url) { - header("Accept", ContentType.Application.Activity) - header("Date", date) - }.bodyAsText() - logBody(bodyAsText, url) - return objectMapper.readValue(bodyAsText, responseClass) + val httpResponse = if (signer?.privateKey == null) { + apGetNotSign(url, date) + } else { + apGetSign(date, u, signer, url) } + val bodyAsText = httpResponse.bodyAsText() + val readValue = objectMapper.readValue(bodyAsText, responseClass) + logger.debug( + "SUCCESS ActivityPub Request GET status: {} url: {}", + httpResponse.status, + httpResponse.request.url + ) + logBody(bodyAsText, url) + return readValue + } + + private suspend fun apGetSign( + date: String, + u: URL, + signer: User, + url: String + ): HttpResponse { val headers = headers { - append("Accept", ContentType.Application.Activity) + append("Accept", Activity) append("Date", date) append("Host", u.host) } @@ -60,7 +74,7 @@ class APRequestServiceImpl( ), privateKey = PrivateKey( keyId = "${signer.url}#pubkey", - privateKey = RsaUtil.decodeRsaPrivateKeyPem(signer.privateKey), + privateKey = RsaUtil.decodeRsaPrivateKeyPem(signer.privateKey!!), ), signHeaders = listOf("(request-target)", "date", "host", "accept") ) @@ -73,17 +87,14 @@ class APRequestServiceImpl( remove("Host") } } - contentType(ContentType.Application.Activity) + contentType(Activity) } - val bodyAsText = httpResponse.bodyAsText() - val readValue = objectMapper.readValue(bodyAsText, responseClass) - logger.debug( - "SUCCESS ActivityPub Request GET status: {} url: {}", - httpResponse.status, - httpResponse.request.url - ) - logBody(bodyAsText, url) - return readValue + return httpResponse + } + + private suspend fun apGetNotSign(url: String, date: String?) = httpClient.get(url) { + header("Accept", Activity) + header("Date", date) } override suspend fun apPost( @@ -96,18 +107,9 @@ class APRequestServiceImpl( return objectMapper.readValue(bodyAsText, responseClass) } - @Suppress("LongMethod") override suspend fun apPost(url: String, body: T?, signer: User?): String { logger.debug("START ActivityPub Request POST url: {}, signer: {}", url, signer?.url) - val requestBody = if (body != null) { - val mutableListOf = mutableListOf() - mutableListOf.add("https://www.w3.org/ns/activitystreams") - mutableListOf.addAll(body.context) - body.context = mutableListOf - objectMapper.writeValueAsString(body) - } else { - null - } + val requestBody = addContextIfNotNull(body) logger.trace( """ @@ -129,22 +131,46 @@ class APRequestServiceImpl( val date = dateTimeFormatter.format(ZonedDateTime.now(ZoneId.of("GMT"))) val u = URL(url) - if (signer?.privateKey == null) { - val bodyAsText = httpClient.post(url) { - accept(ContentType.Application.Activity) - header("Date", date) - header("Digest", "sha-256=$digest") - if (requestBody != null) { - setBody(requestBody) - contentType(ContentType.Application.Activity) - } - }.bodyAsText() - logBody(bodyAsText, url) - return bodyAsText + val httpResponse = if (signer?.privateKey == null) { + apPostNotSign(url, date, digest, requestBody) + } else { + apPostSign(date, u, digest, signer, requestBody) } + val bodyAsText = httpResponse.bodyAsText() + logger.debug( + "SUCCESS ActivityPub Request POST status: {} url: {}", + httpResponse.status, + httpResponse.request.url + ) + logBody(bodyAsText, url) + return bodyAsText + } + + private suspend fun apPostNotSign( + url: String, + date: String?, + digest: String, + requestBody: String? + ) = httpClient.post(url) { + accept(Activity) + header("Date", date) + header("Digest", "sha-256=$digest") + if (requestBody != null) { + setBody(requestBody) + contentType(Activity) + } + } + + private suspend fun apPostSign( + date: String, + u: URL, + digest: String, + signer: User, + requestBody: String? + ): HttpResponse { val headers = headers { - append("Accept", ContentType.Application.Activity) + append("Accept", Activity) append("Date", date) append("Host", u.host) append("Digest", "sha-256=$digest") @@ -158,30 +184,31 @@ class APRequestServiceImpl( ), privateKey = PrivateKey( keyId = signer.keyId, - privateKey = RsaUtil.decodeRsaPrivateKeyPem(signer.privateKey) + privateKey = RsaUtil.decodeRsaPrivateKeyPem(signer.privateKey!!) ), signHeaders = listOf("(request-target)", "date", "host", "digest") ) - val httpResponse = httpClient.post(url) { + val httpResponse = httpClient.post(u) { headers { - headers { - appendAll(headers) - append("Signature", sign.signatureHeader) - remove("Host") - } + appendAll(headers) + append("Signature", sign.signatureHeader) + remove("Host") } setBody(requestBody) - contentType(ContentType.Application.Activity) + contentType(Activity) } - val bodyAsText = httpResponse.bodyAsText() - logger.debug( - "SUCCESS ActivityPub Request POST status: {} url: {}", - httpResponse.status, - httpResponse.request.url - ) - logBody(bodyAsText, url) - return bodyAsText + return httpResponse + } + + private fun addContextIfNotNull(body: T?) = if (body != null) { + val mutableListOf = mutableListOf() + mutableListOf.add("https://www.w3.org/ns/activitystreams") + mutableListOf.addAll(body.context) + body.context = mutableListOf + objectMapper.writeValueAsString(body) + } else { + null } private fun logBody(bodyAsText: String, url: String) { diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/APResourceResolveServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/APResourceResolveServiceImpl.kt index 81b7aec3..85099d84 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/APResourceResolveServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/APResourceResolveServiceImpl.kt @@ -39,9 +39,8 @@ class APResourceResolveServiceImpl( return (cacheManager.getOrWait(key) as APResolveResponse).objects } - private suspend fun runResolve(url: String, singer: User?, clazz: Class): ResolveResponse { - return APResolveResponse(apRequestService.apGet(url, singer, clazz)) - } + private suspend fun runResolve(url: String, singer: User?, clazz: Class): ResolveResponse = + APResolveResponse(apRequestService.apGet(url, singer, clazz)) private fun genCacheKey(url: String, singerId: Long?): String { if (singerId != null) { diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/APService.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/APService.kt index c7df1df2..4f5c2902 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/APService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/APService.kt @@ -2,16 +2,10 @@ package dev.usbharu.hideout.activitypub.service.common import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue import dev.usbharu.hideout.activitypub.domain.exception.JsonParseException -import dev.usbharu.hideout.activitypub.domain.model.Follow -import dev.usbharu.hideout.activitypub.interfaces.api.common.ActivityPubResponse -import dev.usbharu.hideout.activitypub.service.activity.accept.APAcceptService -import dev.usbharu.hideout.activitypub.service.activity.create.APCreateService -import dev.usbharu.hideout.activitypub.service.activity.delete.APReceiveDeleteService -import dev.usbharu.hideout.activitypub.service.activity.follow.APReceiveFollowService -import dev.usbharu.hideout.activitypub.service.activity.like.APLikeService -import dev.usbharu.hideout.activitypub.service.activity.undo.APUndoService +import dev.usbharu.hideout.core.external.job.InboxJob +import dev.usbharu.hideout.core.service.job.JobQueueParentService +import dev.usbharu.httpsignature.common.HttpRequest import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Qualifier @@ -20,7 +14,12 @@ import org.springframework.stereotype.Service interface APService { fun parseActivity(json: String): ActivityType - suspend fun processActivity(json: String, type: ActivityType): ActivityPubResponse? + suspend fun processActivity( + json: String, + type: ActivityType, + httpRequest: HttpRequest, + map: Map> + ) } enum class ActivityType { @@ -176,13 +175,8 @@ enum class ExtendedVocabulary { @Service class APServiceImpl( - private val apReceiveFollowService: APReceiveFollowService, - private val apUndoService: APUndoService, - private val apAcceptService: APAcceptService, - private val apCreateService: APCreateService, - private val apLikeService: APLikeService, - private val apReceiveDeleteService: APReceiveDeleteService, - @Qualifier("activitypub") private val objectMapper: ObjectMapper + @Qualifier("activitypub") private val objectMapper: ObjectMapper, + private val jobQueueParentService: JobQueueParentService ) : APService { val logger: Logger = LoggerFactory.getLogger(APServiceImpl::class.java) @@ -224,23 +218,21 @@ class APServiceImpl( } } - @Suppress("CyclomaticComplexMethod", "NotImplementedDeclaration") - override suspend fun processActivity(json: String, type: ActivityType): ActivityPubResponse { + override suspend fun processActivity( + json: String, + type: ActivityType, + httpRequest: HttpRequest, + map: Map> + ) { logger.debug("process activity: {}", type) - return when (type) { - ActivityType.Accept -> apAcceptService.receiveAccept(objectMapper.readValue(json)) - ActivityType.Follow -> - apReceiveFollowService - .receiveFollow(objectMapper.readValue(json, Follow::class.java)) - - ActivityType.Create -> apCreateService.receiveCreate(objectMapper.readValue(json)) - ActivityType.Like -> apLikeService.receiveLike(objectMapper.readValue(json)) - ActivityType.Undo -> apUndoService.receiveUndo(objectMapper.readValue(json)) - ActivityType.Delete -> apReceiveDeleteService.receiveDelete(objectMapper.readValue(json)) - - else -> { - throw IllegalArgumentException("$type is not supported.") - } + jobQueueParentService.schedule(InboxJob) { + props[it.json] = json + props[it.type] = type.name + val writeValueAsString = objectMapper.writeValueAsString(httpRequest) + println(writeValueAsString) + props[it.httpRequest] = writeValueAsString + props[it.headers] = objectMapper.writeValueAsString(map) } + return } } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/AbstractActivityPubProcessor.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/AbstractActivityPubProcessor.kt new file mode 100644 index 00000000..0e04262e --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/AbstractActivityPubProcessor.kt @@ -0,0 +1,36 @@ +package dev.usbharu.hideout.activitypub.service.common + +import dev.usbharu.hideout.activitypub.domain.exception.ActivityPubProcessException +import dev.usbharu.hideout.activitypub.domain.exception.FailedProcessException +import dev.usbharu.hideout.activitypub.domain.exception.HttpSignatureUnauthorizedException +import dev.usbharu.hideout.activitypub.domain.model.objects.Object +import dev.usbharu.hideout.application.external.Transaction +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +@Service +abstract class AbstractActivityPubProcessor( + private val transaction: Transaction, + private val allowUnauthorized: Boolean = false +) : ActivityPubProcessor { + protected val logger: Logger = LoggerFactory.getLogger(this::class.java) + + override suspend fun process(activity: ActivityPubProcessContext) { + if (activity.isAuthorized.not() && allowUnauthorized.not()) { + throw HttpSignatureUnauthorizedException() + } + logger.info("START ActivityPub process") + try { + transaction.transaction { + internalProcess(activity) + } + } catch (e: ActivityPubProcessException) { + logger.warn("FAILED ActivityPub process", e) + throw FailedProcessException("Failed process", e) + } + logger.info("SUCCESS ActivityPub process") + } + + abstract suspend fun internalProcess(activity: ActivityPubProcessContext) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/ActivityPubProcessContext.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/ActivityPubProcessContext.kt new file mode 100644 index 00000000..60a17bb4 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/ActivityPubProcessContext.kt @@ -0,0 +1,14 @@ +package dev.usbharu.hideout.activitypub.service.common + +import com.fasterxml.jackson.databind.JsonNode +import dev.usbharu.hideout.activitypub.domain.model.objects.Object +import dev.usbharu.httpsignature.common.HttpRequest +import dev.usbharu.httpsignature.verify.Signature + +data class ActivityPubProcessContext( + val activity: T, + val jsonNode: JsonNode, + val httpRequest: HttpRequest, + val signature: Signature?, + val isAuthorized: Boolean +) diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/ActivityPubProcessor.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/ActivityPubProcessor.kt new file mode 100644 index 00000000..4bc16f25 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/ActivityPubProcessor.kt @@ -0,0 +1,11 @@ +package dev.usbharu.hideout.activitypub.service.common + +import dev.usbharu.hideout.activitypub.domain.model.objects.Object + +interface ActivityPubProcessor { + suspend fun process(activity: ActivityPubProcessContext) + + fun isSupported(activityType: ActivityType): Boolean + + fun type(): Class +} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/ApJobService.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/ApJobService.kt deleted file mode 100644 index fe909b0d..00000000 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/ApJobService.kt +++ /dev/null @@ -1,8 +0,0 @@ -package dev.usbharu.hideout.activitypub.service.common - -import dev.usbharu.hideout.core.external.job.HideoutJob -import kjob.core.dsl.JobContextWithProps - -interface ApJobService { - suspend fun processActivity(job: JobContextWithProps, hideoutJob: HideoutJob) -} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/ApJobServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/ApJobServiceImpl.kt deleted file mode 100644 index 7057ccc5..00000000 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/common/ApJobServiceImpl.kt +++ /dev/null @@ -1,46 +0,0 @@ -package dev.usbharu.hideout.activitypub.service.common - -import dev.usbharu.hideout.activitypub.service.activity.follow.APReceiveFollowJobService -import dev.usbharu.hideout.activitypub.service.activity.like.ApReactionJobService -import dev.usbharu.hideout.activitypub.service.objects.note.ApNoteJobService -import dev.usbharu.hideout.core.external.job.* -import kjob.core.dsl.JobContextWithProps -import kjob.core.job.JobProps -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Service - -@Service -class ApJobServiceImpl( - private val apReceiveFollowJobService: APReceiveFollowJobService, - private val apNoteJobService: ApNoteJobService, - private val apReactionJobService: ApReactionJobService -) : ApJobService { - @Suppress("REDUNDANT_ELSE_IN_WHEN") - override suspend fun processActivity(job: JobContextWithProps, hideoutJob: HideoutJob) { - logger.debug("processActivity: ${hideoutJob.name}") - - @Suppress("ElseCaseInsteadOfExhaustiveWhen") - // Springで作成されるプロキシの都合上パターンマッチングが壊れるので必須 - when (hideoutJob) { - is ReceiveFollowJob -> { - apReceiveFollowJobService.receiveFollowJob( - job.props as JobProps - ) - } - - is DeliverPostJob -> apNoteJobService.createNoteJob(job.props as JobProps) - is DeliverReactionJob -> apReactionJobService.reactionJob(job.props as JobProps) - is DeliverRemoveReactionJob -> apReactionJobService.removeReactionJob( - job.props as JobProps - ) - - else -> { - throw IllegalStateException("WTF") - } - } - } - - companion object { - private val logger = LoggerFactory.getLogger(ApJobServiceImpl::class.java) - } -} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/inbox/InboxJobProcessor.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/inbox/InboxJobProcessor.kt new file mode 100644 index 00000000..65220c41 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/inbox/InboxJobProcessor.kt @@ -0,0 +1,127 @@ +package dev.usbharu.hideout.activitypub.service.inbox + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import dev.usbharu.hideout.activitypub.domain.model.objects.Object +import dev.usbharu.hideout.activitypub.service.common.ActivityPubProcessContext +import dev.usbharu.hideout.activitypub.service.common.ActivityPubProcessor +import dev.usbharu.hideout.activitypub.service.objects.user.APUserService +import dev.usbharu.hideout.application.external.Transaction +import dev.usbharu.hideout.core.domain.exception.FailedToGetResourcesException +import dev.usbharu.hideout.core.external.job.InboxJob +import dev.usbharu.hideout.core.external.job.InboxJobParam +import dev.usbharu.hideout.core.query.UserQueryService +import dev.usbharu.hideout.core.service.job.JobProcessor +import dev.usbharu.hideout.util.RsaUtil +import dev.usbharu.httpsignature.common.HttpHeaders +import dev.usbharu.httpsignature.common.HttpMethod +import dev.usbharu.httpsignature.common.HttpRequest +import dev.usbharu.httpsignature.common.PublicKey +import dev.usbharu.httpsignature.verify.HttpSignatureVerifier +import dev.usbharu.httpsignature.verify.Signature +import dev.usbharu.httpsignature.verify.SignatureHeaderParser +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +@Service +class InboxJobProcessor( + private val activityPubProcessorList: List>, + private val objectMapper: ObjectMapper, + private val signatureHeaderParser: SignatureHeaderParser, + private val signatureVerifier: HttpSignatureVerifier, + private val userQueryService: UserQueryService, + private val apUserService: APUserService, + private val transaction: Transaction +) : JobProcessor { + + private suspend fun verifyHttpSignature( + httpRequest: HttpRequest, + signature: Signature, + transaction: Transaction + ): Boolean { + val requiredHeaders = when (httpRequest.method) { + HttpMethod.GET -> getRequiredHeaders + HttpMethod.POST -> postRequiredHeaders + } + if (signature.headers.containsAll(requiredHeaders).not()) { + logger.warn("FAILED Invalid signature. require: {}", requiredHeaders) + return false + } + + val user = transaction.transaction { + try { + userQueryService.findByKeyId(signature.keyId) + } catch (_: FailedToGetResourcesException) { + apUserService.fetchPersonWithEntity(signature.keyId).second + } + } + + @Suppress("TooGenericExceptionCaught") + val verify = try { + signatureVerifier.verify( + httpRequest, + PublicKey(RsaUtil.decodeRsaPublicKeyPem(user.publicKey), signature.keyId) + ) + } catch (e: Exception) { + logger.warn("FAILED Verify Http Signature", e) + return false + } + + return verify.success + } + + @Suppress("TooGenericExceptionCaught") + private fun parseSignatureHeader(httpHeaders: HttpHeaders): Signature? { + return try { + println("Signature Header =" + httpHeaders.get("Signature").single()) + signatureHeaderParser.parse(httpHeaders) + } catch (e: RuntimeException) { + logger.trace("FAILED parse signature header", e) + null + } + } + + override suspend fun process(param: InboxJobParam) { + val jsonNode = objectMapper.readTree(param.json) + + logger.info("START Process inbox. type: {}", param.type) + logger.trace("type: {}\njson: \n{}", param.type, jsonNode.toPrettyString()) + + val map = objectMapper.readValue>>(param.headers) + + val httpRequest = objectMapper.readValue(param.httpRequest).copy(headers = HttpHeaders(map)) + + logger.trace("Request: {}\nheaders: {}", httpRequest, map) + + val signature = parseSignatureHeader(httpRequest.headers) + + logger.debug("Has signature? {}", signature != null) + + val verify = signature?.let { verifyHttpSignature(httpRequest, it, transaction) } ?: false + + transaction.transaction { + logger.debug("Is verifying success? {}", verify) + + val activityPubProcessor = + activityPubProcessorList.firstOrNull { it.isSupported(param.type) } as ActivityPubProcessor? + + if (activityPubProcessor == null) { + logger.warn("ActivityType {} is not support.", param.type) + throw IllegalStateException("ActivityPubProcessor not found.") + } + + val value = objectMapper.treeToValue(jsonNode, activityPubProcessor.type()) + activityPubProcessor.process(ActivityPubProcessContext(value, jsonNode, httpRequest, signature, verify)) + + logger.info("SUCCESS Process inbox. type: {}", param.type) + } + } + + override fun job(): InboxJob = InboxJob + + companion object { + private val logger = LoggerFactory.getLogger(InboxJobProcessor::class.java) + private val postRequiredHeaders = listOf("(request-target)", "date", "host", "digest") + private val getRequiredHeaders = listOf("(request-target)", "date", "host") + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteService.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteService.kt index f7300314..0e5b6e14 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteService.kt @@ -1,7 +1,6 @@ package dev.usbharu.hideout.activitypub.service.objects.note import dev.usbharu.hideout.activitypub.domain.exception.FailedToGetActivityPubResourceException -import dev.usbharu.hideout.activitypub.domain.exception.IllegalActivityPubObjectException import dev.usbharu.hideout.activitypub.domain.model.Note import dev.usbharu.hideout.activitypub.query.NoteQueryService import dev.usbharu.hideout.activitypub.service.common.APResourceResolveService @@ -91,7 +90,7 @@ class APNoteServiceImpl( requireNotNull(note.id) { "id is null" } return try { - noteQueryService.findByApid(note.id!!).first + noteQueryService.findByApid(note.id).first } catch (_: FailedToGetResourcesException) { saveNote(note, targetActor, url) } @@ -99,7 +98,7 @@ class APNoteServiceImpl( private suspend fun saveNote(note: Note, targetActor: String?, url: String): Note { val person = apUserService.fetchPersonWithEntity( - note.attributedTo ?: throw IllegalActivityPubObjectException("note.attributedTo is null"), + note.attributedTo, targetActor ) @@ -128,9 +127,9 @@ class APNoteServiceImpl( .map { mediaService.uploadRemoteMedia( RemoteMedia( - (it.name ?: it.url)!!, - it.url!!, - it.mediaType ?: "application/octet-stream", + it.name, + it.url, + it.mediaType, description = it.name ) ) @@ -142,13 +141,13 @@ class APNoteServiceImpl( postBuilder.of( id = postRepository.generateId(), userId = person.second.id, - text = note.content.orEmpty(), + text = note.content, createdAt = Instant.parse(note.published).toEpochMilli(), visibility = visibility, - url = note.id ?: url, + url = note.id, replyId = reply?.id, sensitive = note.sensitive, - apId = note.id ?: url, + apId = note.id, mediaIds = mediaList ) ) @@ -156,7 +155,7 @@ class APNoteServiceImpl( } override suspend fun fetchNote(note: Note, targetActor: String?): Note = - saveIfMissing(note, targetActor, note.id ?: throw IllegalArgumentException("note.id is null")) + saveIfMissing(note, targetActor, note.id) companion object { const val public: String = "https://www.w3.org/ns/activitystreams#Public" diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/ApNoteJobProcessor.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/ApNoteJobProcessor.kt new file mode 100644 index 00000000..181f869a --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/ApNoteJobProcessor.kt @@ -0,0 +1,42 @@ +package dev.usbharu.hideout.activitypub.service.objects.note + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import dev.usbharu.hideout.activitypub.domain.model.Create +import dev.usbharu.hideout.activitypub.service.common.APRequestService +import dev.usbharu.hideout.application.external.Transaction +import dev.usbharu.hideout.core.external.job.DeliverPostJob +import dev.usbharu.hideout.core.external.job.DeliverPostJobParam +import dev.usbharu.hideout.core.query.UserQueryService +import dev.usbharu.hideout.core.service.job.JobProcessor +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +@Service +class ApNoteJobProcessor( + private val transaction: Transaction, + private val objectMapper: ObjectMapper, + private val userQueryService: UserQueryService, + private val apRequestService: APRequestService +) : JobProcessor { + override suspend fun process(param: DeliverPostJobParam) { + val create = objectMapper.readValue(param.create) + transaction.transaction { + val signer = userQueryService.findByUrl(param.actor) + + logger.debug("CreateNoteJob: actor: {} create: {} inbox: {}", param.actor, create, param.inbox) + + apRequestService.apPost( + param.inbox, + create, + signer + ) + } + } + + override fun job(): DeliverPostJob = DeliverPostJob + + companion object { + private val logger = LoggerFactory.getLogger(ApNoteJobProcessor::class.java) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/ApNoteJobService.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/ApNoteJobService.kt deleted file mode 100644 index ad7ea01e..00000000 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/ApNoteJobService.kt +++ /dev/null @@ -1,8 +0,0 @@ -package dev.usbharu.hideout.activitypub.service.objects.note - -import dev.usbharu.hideout.core.external.job.DeliverPostJob -import kjob.core.job.JobProps - -interface ApNoteJobService { - suspend fun createNoteJob(props: JobProps) -} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/ApNoteJobServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/ApNoteJobServiceImpl.kt deleted file mode 100644 index 1e3dc801..00000000 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/ApNoteJobServiceImpl.kt +++ /dev/null @@ -1,41 +0,0 @@ -package dev.usbharu.hideout.activitypub.service.objects.note - -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue -import dev.usbharu.hideout.activitypub.domain.model.Create -import dev.usbharu.hideout.activitypub.service.common.APRequestService -import dev.usbharu.hideout.application.external.Transaction -import dev.usbharu.hideout.core.external.job.DeliverPostJob -import dev.usbharu.hideout.core.query.UserQueryService -import kjob.core.job.JobProps -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.stereotype.Component - -@Component -class ApNoteJobServiceImpl( - private val userQueryService: UserQueryService, - private val apRequestService: APRequestService, - @Qualifier("activitypub") private val objectMapper: ObjectMapper, - private val transaction: Transaction -) : ApNoteJobService { - override suspend fun createNoteJob(props: JobProps) { - val actor = props[DeliverPostJob.actor] - val create = objectMapper.readValue(props[DeliverPostJob.create]) - transaction.transaction { - val signer = userQueryService.findByUrl(actor) - - val inbox = props[DeliverPostJob.inbox] - logger.debug("createNoteJob: actor={}, create={}, inbox={}", actor, create, inbox) - apRequestService.apPost( - inbox, - create, - signer - ) - } - } - - companion object { - private val logger = LoggerFactory.getLogger(ApNoteJobServiceImpl::class.java) - } -} diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/NoteApApiServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/NoteApApiServiceImpl.kt index 547befbf..079cc073 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/NoteApApiServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/NoteApApiServiceImpl.kt @@ -4,6 +4,7 @@ import dev.usbharu.hideout.activitypub.domain.model.Note import dev.usbharu.hideout.activitypub.query.NoteQueryService import dev.usbharu.hideout.application.external.Transaction import dev.usbharu.hideout.core.domain.exception.FailedToGetResourcesException +import dev.usbharu.hideout.core.domain.model.post.Post import dev.usbharu.hideout.core.domain.model.post.Visibility import dev.usbharu.hideout.core.query.FollowerQueryService import org.slf4j.LoggerFactory @@ -28,20 +29,27 @@ class NoteApApiServiceImpl( } Visibility.FOLLOWERS -> { - if (userId == null) { - return@transaction null - } - - if (followerQueryService.alreadyFollow(findById.second.userId, userId).not()) { - return@transaction null - } - return@transaction findById.first + return@transaction getFollowersNote(userId, findById) } Visibility.DIRECT -> return@transaction null } } + private suspend fun getFollowersNote( + userId: Long?, + findById: Pair + ): Note? { + if (userId == null) { + return null + } + + if (followerQueryService.alreadyFollow(findById.second.userId, userId)) { + return findById.first + } + return null + } + companion object { private val logger = LoggerFactory.getLogger(NoteApApiServiceImpl::class.java) } diff --git a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/user/APUserService.kt b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/user/APUserService.kt index 9c0777e9..fff97e01 100644 --- a/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/user/APUserService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/activitypub/service/objects/user/APUserService.kt @@ -14,6 +14,7 @@ import dev.usbharu.hideout.core.query.UserQueryService import dev.usbharu.hideout.core.service.user.RemoteUserCreateDto import dev.usbharu.hideout.core.service.user.UserService import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional interface APUserService { suspend fun getPersonByName(name: String): Person @@ -57,13 +58,10 @@ class APUserServiceImpl( url = userUrl, icon = Image( type = emptyList(), - name = "$userUrl/icon.png", mediaType = "image/png", url = "$userUrl/icon.png" ), publicKey = Key( - type = emptyList(), - name = "Public Key", id = userEntity.keyId, owner = userUrl, publicKeyPem = userEntity.publicKey @@ -77,52 +75,33 @@ class APUserServiceImpl( override suspend fun fetchPerson(url: String, targetActor: String?): Person = fetchPersonWithEntity(url, targetActor).first + @Transactional override suspend fun fetchPersonWithEntity(url: String, targetActor: String?): Pair { return try { val userEntity = userQueryService.findByUrl(url) - return Person( - type = emptyList(), - name = userEntity.name, - id = url, - preferredUsername = userEntity.name, - summary = userEntity.description, - inbox = "$url/inbox", - outbox = "$url/outbox", - url = url, - icon = Image( - type = emptyList(), - name = "$url/icon.png", - mediaType = "image/png", - url = "$url/icon.png" - ), - publicKey = Key( - type = emptyList(), - name = "Public Key", - id = userEntity.keyId, - owner = url, - publicKeyPem = userEntity.publicKey - ), - endpoints = mapOf("sharedInbox" to "${applicationConfig.url}/inbox"), - followers = userEntity.followers, - following = userEntity.following - ) to userEntity + val id = userEntity.url + return entityToPerson(userEntity, id) to userEntity } catch (ignore: FailedToGetResourcesException) { val person = apResourceResolveService.resolve(url, null as Long?) + val id = person.id + try { + val userEntity = userQueryService.findByUrl(id) + return entityToPerson(userEntity, id) to userEntity + } catch (_: FailedToGetResourcesException) { + } person to userService.createRemoteUser( RemoteUserCreateDto( name = person.preferredUsername ?: throw IllegalActivityPubObjectException("preferredUsername is null"), - domain = url.substringAfter("://").substringBefore("/"), - screenName = (person.name ?: person.preferredUsername) - ?: throw IllegalActivityPubObjectException("preferredUsername is null"), + domain = id.substringAfter("://").substringBefore("/"), + screenName = person.name, description = person.summary.orEmpty(), - inbox = person.inbox ?: throw IllegalActivityPubObjectException("inbox is null"), - outbox = person.outbox ?: throw IllegalActivityPubObjectException("outbox is null"), - url = url, - publicKey = person.publicKey?.publicKeyPem - ?: throw IllegalActivityPubObjectException("publicKey is null"), - keyId = person.publicKey?.id ?: throw IllegalActivityPubObjectException("publicKey keyId is null"), + inbox = person.inbox, + outbox = person.outbox, + url = id, + publicKey = person.publicKey.publicKeyPem, + keyId = person.publicKey.id, following = person.following, followers = person.followers, sharedInbox = person.endpoints["sharedInbox"] @@ -130,4 +109,31 @@ class APUserServiceImpl( ) } } + + private fun entityToPerson( + userEntity: User, + id: String + ) = Person( + type = emptyList(), + name = userEntity.name, + id = id, + preferredUsername = userEntity.name, + summary = userEntity.description, + inbox = "$id/inbox", + outbox = "$id/outbox", + url = id, + icon = Image( + type = emptyList(), + mediaType = "image/png", + url = "$id/icon.png" + ), + publicKey = Key( + id = userEntity.keyId, + owner = id, + publicKeyPem = userEntity.publicKey + ), + endpoints = mapOf("sharedInbox" to "${applicationConfig.url}/inbox"), + followers = userEntity.followers, + following = userEntity.following + ) } diff --git a/src/main/kotlin/dev/usbharu/hideout/application/config/ActivityPubConfig.kt b/src/main/kotlin/dev/usbharu/hideout/application/config/ActivityPubConfig.kt index d6bdf301..c99d07b2 100644 --- a/src/main/kotlin/dev/usbharu/hideout/application/config/ActivityPubConfig.kt +++ b/src/main/kotlin/dev/usbharu/hideout/application/config/ActivityPubConfig.kt @@ -7,6 +7,8 @@ import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import dev.usbharu.hideout.core.infrastructure.httpsignature.HttpRequestMixIn +import dev.usbharu.httpsignature.common.HttpRequest import dev.usbharu.httpsignature.sign.HttpSignatureSigner import dev.usbharu.httpsignature.sign.RsaSha256HttpSignatureSigner import org.springframework.beans.factory.annotation.Qualifier @@ -24,11 +26,13 @@ class ActivityPubConfig { val objectMapper = jacksonObjectMapper() .enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY) .setSerializationInclusion(JsonInclude.Include.NON_EMPTY) - .setDefaultSetterInfo(JsonSetter.Value.forContentNulls(Nulls.AS_EMPTY)) + .setDefaultSetterInfo(JsonSetter.Value.forContentNulls(Nulls.SKIP)) + .setDefaultSetterInfo(JsonSetter.Value.forValueNulls(Nulls.SKIP)) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(JsonParser.Feature.ALLOW_COMMENTS, true) .configure(JsonParser.Feature.ALLOW_SINGLE_QUOTES, true) .configure(JsonParser.Feature.ALLOW_TRAILING_COMMA, true) + .addMixIn(HttpRequest::class.java, HttpRequestMixIn::class.java) return objectMapper } diff --git a/src/main/kotlin/dev/usbharu/hideout/application/config/AwsConfig.kt b/src/main/kotlin/dev/usbharu/hideout/application/config/AwsConfig.kt index 67820efd..ad40a0bd 100644 --- a/src/main/kotlin/dev/usbharu/hideout/application/config/AwsConfig.kt +++ b/src/main/kotlin/dev/usbharu/hideout/application/config/AwsConfig.kt @@ -1,5 +1,6 @@ package dev.usbharu.hideout.application.config +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import software.amazon.awssdk.auth.credentials.AwsBasicCredentials @@ -10,7 +11,8 @@ import java.net.URI @Configuration class AwsConfig { @Bean - fun s3Client(awsConfig: StorageConfig): S3Client { + @ConditionalOnProperty("hideout.storage.type", havingValue = "s3") + fun s3Client(awsConfig: S3StorageConfig): S3Client { return S3Client.builder() .endpointOverride(URI.create(awsConfig.endpoint)) .region(Region.of(awsConfig.region)) diff --git a/src/main/kotlin/dev/usbharu/hideout/application/config/HttpSignatureConfig.kt b/src/main/kotlin/dev/usbharu/hideout/application/config/HttpSignatureConfig.kt new file mode 100644 index 00000000..ee3bc409 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/application/config/HttpSignatureConfig.kt @@ -0,0 +1,20 @@ +package dev.usbharu.hideout.application.config + +import dev.usbharu.httpsignature.sign.RsaSha256HttpSignatureSigner +import dev.usbharu.httpsignature.verify.DefaultSignatureHeaderParser +import dev.usbharu.httpsignature.verify.RsaSha256HttpSignatureVerifier +import dev.usbharu.httpsignature.verify.SignatureHeaderParser +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class HttpSignatureConfig { + @Bean + fun defaultSignatureHeaderParser(): DefaultSignatureHeaderParser = DefaultSignatureHeaderParser() + + @Bean + fun rsaSha256HttpSignatureVerifier( + signatureHeaderParser: SignatureHeaderParser, + signatureSigner: RsaSha256HttpSignatureSigner + ): RsaSha256HttpSignatureVerifier = RsaSha256HttpSignatureVerifier(signatureHeaderParser, signatureSigner) +} diff --git a/src/main/kotlin/dev/usbharu/hideout/application/config/JobQueueRunner.kt b/src/main/kotlin/dev/usbharu/hideout/application/config/JobQueueRunner.kt index 4f7f1aaf..1667b7ec 100644 --- a/src/main/kotlin/dev/usbharu/hideout/application/config/JobQueueRunner.kt +++ b/src/main/kotlin/dev/usbharu/hideout/application/config/JobQueueRunner.kt @@ -1,6 +1,5 @@ package dev.usbharu.hideout.application.config -import dev.usbharu.hideout.activitypub.service.common.ApJobService import dev.usbharu.hideout.core.external.job.HideoutJob import dev.usbharu.hideout.core.service.job.JobQueueParentService import dev.usbharu.hideout.core.service.job.JobQueueWorkerService @@ -11,7 +10,10 @@ import org.springframework.boot.ApplicationRunner import org.springframework.stereotype.Component @Component -class JobQueueRunner(private val jobQueueParentService: JobQueueParentService, private val jobs: List) : +class JobQueueRunner( + private val jobQueueParentService: JobQueueParentService, + private val jobs: List> +) : ApplicationRunner { override fun run(args: ApplicationArguments?) { LOGGER.info("Init job queue. ${jobs.size}") @@ -26,24 +28,10 @@ class JobQueueRunner(private val jobQueueParentService: JobQueueParentService, p @Component class JobQueueWorkerRunner( private val jobQueueWorkerService: JobQueueWorkerService, - private val jobs: List, - private val apJobService: ApJobService ) : ApplicationRunner { override fun run(args: ApplicationArguments?) { LOGGER.info("Init job queue worker.") - jobQueueWorkerService.init( - jobs.map { - it to { - execute { - LOGGER.debug("excute job ${it.name}") - apJobService.processActivity( - job = this, - hideoutJob = it - ) - } - } - } - ) + jobQueueWorkerService.init>(emptyList()) } companion object { diff --git a/src/main/kotlin/dev/usbharu/hideout/application/config/SecurityConfig.kt b/src/main/kotlin/dev/usbharu/hideout/application/config/SecurityConfig.kt index c34eeac2..dead45f0 100644 --- a/src/main/kotlin/dev/usbharu/hideout/application/config/SecurityConfig.kt +++ b/src/main/kotlin/dev/usbharu/hideout/application/config/SecurityConfig.kt @@ -1,13 +1,11 @@ package dev.usbharu.hideout.application.config import com.fasterxml.jackson.annotation.JsonInclude -import com.fasterxml.jackson.databind.ObjectMapper import com.nimbusds.jose.jwk.JWKSet import com.nimbusds.jose.jwk.RSAKey import com.nimbusds.jose.jwk.source.ImmutableJWKSet import com.nimbusds.jose.jwk.source.JWKSource import com.nimbusds.jose.proc.SecurityContext -import dev.usbharu.hideout.activitypub.service.objects.user.APUserService import dev.usbharu.hideout.application.external.Transaction import dev.usbharu.hideout.core.infrastructure.springframework.httpsignature.HttpSignatureFilter import dev.usbharu.hideout.core.infrastructure.springframework.httpsignature.HttpSignatureUserDetailsService @@ -16,32 +14,32 @@ import dev.usbharu.hideout.core.infrastructure.springframework.oauth2.UserDetail import dev.usbharu.hideout.core.infrastructure.springframework.oauth2.UserDetailsServiceImpl import dev.usbharu.hideout.core.query.UserQueryService import dev.usbharu.hideout.util.RsaUtil +import dev.usbharu.hideout.util.hasAnyScope import dev.usbharu.httpsignature.sign.RsaSha256HttpSignatureSigner import dev.usbharu.httpsignature.verify.DefaultSignatureHeaderParser import dev.usbharu.httpsignature.verify.RsaSha256HttpSignatureVerifier import jakarta.annotation.PostConstruct import org.springframework.beans.factory.annotation.Autowired -import org.springframework.beans.factory.annotation.Qualifier import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer -import org.springframework.boot.autoconfigure.security.servlet.PathRequest import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Primary import org.springframework.core.annotation.Order -import org.springframework.http.HttpMethod +import org.springframework.http.HttpMethod.GET +import org.springframework.http.HttpMethod.POST import org.springframework.http.HttpStatus import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter import org.springframework.security.authentication.AccountStatusUserDetailsChecker import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.authentication.dao.DaoAuthenticationProvider -import org.springframework.security.config.Customizer import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.invoke import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.core.Authentication import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder @@ -58,15 +56,13 @@ import org.springframework.security.web.authentication.AuthenticationEntryPointF import org.springframework.security.web.authentication.HttpStatusEntryPoint import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationProvider -import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher +import org.springframework.security.web.savedrequest.RequestCacheAwareFilter import org.springframework.security.web.util.matcher.AnyRequestMatcher -import org.springframework.web.servlet.handler.HandlerMappingIntrospector import java.security.KeyPairGenerator import java.security.interfaces.RSAPrivateKey import java.security.interfaces.RSAPublicKey import java.util.* - @EnableWebSecurity(debug = false) @Configuration @Suppress("FunctionMaxLength", "TooManyFunctions") @@ -83,51 +79,37 @@ class SecurityConfig { @Order(1) fun httpSignatureFilterChain( http: HttpSecurity, - httpSignatureFilter: HttpSignatureFilter, - introspector: HandlerMappingIntrospector + httpSignatureFilter: HttpSignatureFilter ): SecurityFilterChain { - val builder = MvcRequestMatcher.Builder(introspector) - http - .securityMatcher("/inbox", "/outbox", "/users/*/inbox", "/users/*/outbox", "/users/*/posts/*") - .addFilter(httpSignatureFilter) - .addFilterBefore( - ExceptionTranslationFilter(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)), - HttpSignatureFilter::class.java + http { + securityMatcher("/users/*/posts/*") + addFilterAt(httpSignatureFilter) + addFilterBefore( + ExceptionTranslationFilter(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) ) - .authorizeHttpRequests { - it.requestMatchers( - builder.pattern("/inbox"), - builder.pattern("/outbox"), - builder.pattern("/users/*/inbox"), - builder.pattern("/users/*/outbox") - ).authenticated() - it.anyRequest().permitAll() + authorizeHttpRequests { + authorize(anyRequest, permitAll) } - .csrf { - it.disable() - } - .exceptionHandling { - it.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) - it.defaultAuthenticationEntryPointFor( + exceptionHandling { + authenticationEntryPoint = HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED) + defaultAuthenticationEntryPointFor( HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED), AnyRequestMatcher.INSTANCE ) } - .sessionManagement { - it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + sessionManagement { + sessionCreationPolicy = SessionCreationPolicy.STATELESS } + } return http.build() } @Bean fun getHttpSignatureFilter( authenticationManager: AuthenticationManager, - @Qualifier("activitypub") objectMapper: ObjectMapper, - apUserService: APUserService, - transaction: Transaction ): HttpSignatureFilter { val httpSignatureFilter = - HttpSignatureFilter(DefaultSignatureHeaderParser(), objectMapper, apUserService, transaction) + HttpSignatureFilter(DefaultSignatureHeaderParser()) httpSignatureFilter.setAuthenticationManager(authenticationManager) httpSignatureFilter.setContinueFilterChainOnUnsuccessfulAuthentication(false) val authenticationEntryPointFailureHandler = @@ -150,18 +132,20 @@ class SecurityConfig { @Order(1) fun httpSignatureAuthenticationProvider(transaction: Transaction): PreAuthenticatedAuthenticationProvider { val provider = PreAuthenticatedAuthenticationProvider() + val signatureHeaderParser = DefaultSignatureHeaderParser() provider.setPreAuthenticatedUserDetailsService( HttpSignatureUserDetailsService( userQueryService, HttpSignatureVerifierComposite( mapOf( "rsa-sha256" to RsaSha256HttpSignatureVerifier( - DefaultSignatureHeaderParser(), RsaSha256HttpSignatureSigner() + signatureHeaderParser, RsaSha256HttpSignatureSigner() ) ), - DefaultSignatureHeaderParser() + signatureHeaderParser ), - transaction + transaction, + signatureHeaderParser ) ) provider.setUserDetailsChecker(AccountStatusUserDetailsChecker()) @@ -170,62 +154,64 @@ class SecurityConfig { @Bean @Order(2) - fun oauth2SecurityFilterChain(http: HttpSecurity, introspector: HandlerMappingIntrospector): SecurityFilterChain { - val builder = MvcRequestMatcher.Builder(introspector) - + fun oauth2SecurityFilterChain(http: HttpSecurity): SecurityFilterChain { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http) - http.exceptionHandling { - it.authenticationEntryPoint( - LoginUrlAuthenticationEntryPoint("/login") - ) - }.oauth2ResourceServer { - it.jwt(Customizer.withDefaults()) + http { + exceptionHandling { + authenticationEntryPoint = LoginUrlAuthenticationEntryPoint("/login") + } + oauth2ResourceServer { + jwt { + } + } } return http.build() } @Bean @Order(4) - fun defaultSecurityFilterChain(http: HttpSecurity, introspector: HandlerMappingIntrospector): SecurityFilterChain { - val builder = MvcRequestMatcher.Builder(introspector) + fun defaultSecurityFilterChain(http: HttpSecurity): SecurityFilterChain { + http { + authorizeHttpRequests { + authorize("/error", permitAll) + authorize("/login", permitAll) + authorize(GET, "/.well-known/**", permitAll) + authorize(GET, "/nodeinfo/2.0", permitAll) - http.authorizeHttpRequests { - it.requestMatchers(PathRequest.toH2Console()).permitAll() - it.requestMatchers( - builder.pattern("/inbox"), - builder.pattern("/users/*/inbox"), - builder.pattern("/api/v1/apps"), - builder.pattern("/api/v1/instance/**"), - builder.pattern("/.well-known/**"), - builder.pattern("/error"), - builder.pattern("/nodeinfo/2.0") - ).permitAll() - it.requestMatchers( - builder.pattern("/auth/**") - ).anonymous() - it.requestMatchers(builder.pattern("/change-password")).authenticated() - it.requestMatchers(builder.pattern("/api/v1/accounts/verify_credentials")) - .hasAnyAuthority("SCOPE_read", "SCOPE_read:accounts") - it.anyRequest().permitAll() - } - http.oauth2ResourceServer { - it.jwt(Customizer.withDefaults()) - } - .passwordManagement { } - .formLogin { + authorize(POST, "/inbox", permitAll) + authorize(POST, "/users/*/inbox", permitAll) + authorize(POST, "/api/v1/apps", permitAll) + authorize(GET, "/api/v1/instance/**", permitAll) + authorize(POST, "/api/v1/accounts", permitAll) + + authorize("/auth/sign_up", hasRole("ANONYMOUS")) + + authorize(GET, "/api/v1/accounts/verify_credentials", hasAnyScope("read", "read:accounts")) + + authorize(POST, "/api/v1/media", hasAnyScope("write", "write:media")) + authorize(POST, "/api/v1/statuses", hasAnyScope("write", "write:statuses")) + + authorize(anyRequest, authenticated) } - .csrf { - it.ignoringRequestMatchers(builder.pattern("/users/*/inbox")) - it.ignoringRequestMatchers(builder.pattern(HttpMethod.POST, "/api/v1/apps")) - it.ignoringRequestMatchers(builder.pattern("/inbox")) - it.ignoringRequestMatchers(PathRequest.toH2Console()) + + oauth2ResourceServer { + jwt { } } - .headers { - it.frameOptions { - it.sameOrigin() + + formLogin { + } + + csrf { + ignoringRequestMatchers("/users/*/inbox", "/inbox", "/api/v1/apps") + } + + headers { + frameOptions { + sameOrigin = true } } + } return http.build() } @@ -297,7 +283,6 @@ data class JwkConfig( val privateKey: String ) - @Configuration class PostSecurityConfig( val auth: AuthenticationManagerBuilder, diff --git a/src/main/kotlin/dev/usbharu/hideout/application/config/SpringConfig.kt b/src/main/kotlin/dev/usbharu/hideout/application/config/SpringConfig.kt index 89275999..f3e088ab 100644 --- a/src/main/kotlin/dev/usbharu/hideout/application/config/SpringConfig.kt +++ b/src/main/kotlin/dev/usbharu/hideout/application/config/SpringConfig.kt @@ -1,6 +1,7 @@ package dev.usbharu.hideout.application.config import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -13,9 +14,6 @@ class SpringConfig { @Autowired lateinit var config: ApplicationConfig - @Autowired - lateinit var storageConfig: StorageConfig - @Bean fun requestLoggingFilter(): CommonsRequestLoggingFilter { val loggingFilter = CommonsRequestLoggingFilter() @@ -33,9 +31,9 @@ data class ApplicationConfig( val url: URL ) -@ConfigurationProperties("hideout.storage") -data class StorageConfig( - val useS3: Boolean, +@ConfigurationProperties("hideout.storage.s3") +@ConditionalOnProperty("hideout.storage.type", havingValue = "s3") +data class S3StorageConfig( val endpoint: String, val publicUrl: String, val bucket: String, @@ -44,6 +42,20 @@ data class StorageConfig( val secretKey: String ) + +/** + * メディアの保存にローカルファイルシステムを使用する際のコンフィグ + * + * @property path フォゾンする場所へのパス。 /から始めると絶対パスとなります。 + * @property publicUrl 公開用URL 省略可能 指定するとHideoutがファイルを配信しなくなります。 + */ +@ConfigurationProperties("hideout.storage.local") +@ConditionalOnProperty("hideout.storage.type", havingValue = "local", matchIfMissing = true) +data class LocalStorageConfig( + val path: String = "files", + val publicUrl: String? +) + @ConfigurationProperties("hideout.character-limit") data class CharacterLimit( val general: General = General(), diff --git a/src/main/kotlin/dev/usbharu/hideout/application/infrastructure/exposed/ExposedTransaction.kt b/src/main/kotlin/dev/usbharu/hideout/application/infrastructure/exposed/ExposedTransaction.kt index 3b5d83b7..74be00ff 100644 --- a/src/main/kotlin/dev/usbharu/hideout/application/infrastructure/exposed/ExposedTransaction.kt +++ b/src/main/kotlin/dev/usbharu/hideout/application/infrastructure/exposed/ExposedTransaction.kt @@ -1,7 +1,6 @@ package dev.usbharu.hideout.application.infrastructure.exposed import dev.usbharu.hideout.application.external.Transaction -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.slf4j.MDCContext import org.jetbrains.exposed.sql.StdOutSqlLogger import org.jetbrains.exposed.sql.addLogger @@ -12,11 +11,9 @@ import java.sql.Connection @Service class ExposedTransaction : Transaction { override suspend fun transaction(block: suspend () -> T): T { - return org.jetbrains.exposed.sql.transactions.transaction(transactionIsolation = Connection.TRANSACTION_SERIALIZABLE) { + return newSuspendedTransaction(MDCContext(), transactionIsolation = Connection.TRANSACTION_SERIALIZABLE) { addLogger(StdOutSqlLogger) - runBlocking { - block() - } + block() } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaConvertException.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaConvertException.kt index d2d9d7c9..cc564619 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaConvertException.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaConvertException.kt @@ -1,5 +1,7 @@ package dev.usbharu.hideout.core.domain.exception.media +import java.io.Serial + open class MediaConvertException : MediaException { constructor() : super() constructor(message: String?) : super(message) @@ -11,4 +13,9 @@ open class MediaConvertException : MediaException { enableSuppression, writableStackTrace ) + + companion object { + @Serial + private const val serialVersionUID: Long = -6349105549968160551L + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaException.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaException.kt index 538e3c72..221c92e5 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaException.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaException.kt @@ -1,5 +1,7 @@ package dev.usbharu.hideout.core.domain.exception.media +import java.io.Serial + abstract class MediaException : RuntimeException { constructor() : super() constructor(message: String?) : super(message) @@ -11,4 +13,9 @@ abstract class MediaException : RuntimeException { enableSuppression, writableStackTrace ) + + companion object { + @Serial + private const val serialVersionUID: Long = 5988922562494187852L + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaFileSizeException.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaFileSizeException.kt index f75b74c2..f5153641 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaFileSizeException.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaFileSizeException.kt @@ -1,5 +1,7 @@ package dev.usbharu.hideout.core.domain.exception.media +import java.io.Serial + open class MediaFileSizeException : MediaException { constructor() : super() constructor(message: String?) : super(message) @@ -11,4 +13,9 @@ open class MediaFileSizeException : MediaException { enableSuppression, writableStackTrace ) + + companion object { + @Serial + private const val serialVersionUID: Long = 8672626879026555064L + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaFileSizeIsZeroException.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaFileSizeIsZeroException.kt index 74261698..1837ce06 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaFileSizeIsZeroException.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaFileSizeIsZeroException.kt @@ -1,5 +1,7 @@ package dev.usbharu.hideout.core.domain.exception.media +import java.io.Serial + class MediaFileSizeIsZeroException : MediaFileSizeException { constructor() : super() constructor(message: String?) : super(message) @@ -11,4 +13,9 @@ class MediaFileSizeIsZeroException : MediaFileSizeException { enableSuppression, writableStackTrace ) + + companion object { + @Serial + private const val serialVersionUID: Long = -2398394583775317875L + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaProcessException.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaProcessException.kt index 4292d0e8..6751fec1 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaProcessException.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/MediaProcessException.kt @@ -1,5 +1,7 @@ package dev.usbharu.hideout.core.domain.exception.media +import java.io.Serial + class MediaProcessException : MediaException { constructor() : super() constructor(message: String?) : super(message) @@ -11,4 +13,9 @@ class MediaProcessException : MediaException { enableSuppression, writableStackTrace ) + + companion object { + @Serial + private const val serialVersionUID: Long = -5195233013542703735L + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/UnsupportedMediaException.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/UnsupportedMediaException.kt index 7eea4e38..6c62f904 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/UnsupportedMediaException.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/exception/media/UnsupportedMediaException.kt @@ -1,5 +1,7 @@ package dev.usbharu.hideout.core.domain.exception.media +import java.io.Serial + class UnsupportedMediaException : MediaException { constructor() : super() constructor(message: String?) : super(message) @@ -11,4 +13,9 @@ class UnsupportedMediaException : MediaException { enableSuppression, writableStackTrace ) + + companion object { + @Serial + private const val serialVersionUID: Long = -116741513216017134L + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/instance/Nodeinfo.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/instance/Nodeinfo.kt index 427c76a3..f7fc3160 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/instance/Nodeinfo.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/instance/Nodeinfo.kt @@ -1,15 +1,11 @@ package dev.usbharu.hideout.core.domain.model.instance -class Nodeinfo { +class Nodeinfo private constructor() { var links: List = emptyList() - - private constructor() } -class Links { +class Links private constructor() { var rel: String? = null var href: String? = null - - private constructor() } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/instance/Nodeinfo2_0.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/instance/Nodeinfo2_0.kt index fcd99c73..97478228 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/instance/Nodeinfo2_0.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/instance/Nodeinfo2_0.kt @@ -1,23 +1,19 @@ +@file:Suppress("Filename") + package dev.usbharu.hideout.core.domain.model.instance @Suppress("ClassNaming") class Nodeinfo2_0 { var metadata: Metadata? = null var software: Software? = null - - constructor() } class Metadata { var nodeName: String? = null var nodeDescription: String? = null - - constructor() } class Software { var name: String? = null var version: String? = null - - constructor() } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/media/Media.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/media/Media.kt index be6b3839..4faae800 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/media/Media.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/media/Media.kt @@ -2,6 +2,7 @@ package dev.usbharu.hideout.core.domain.model.media import dev.usbharu.hideout.core.service.media.FileType import dev.usbharu.hideout.core.service.media.MimeType +import dev.usbharu.hideout.domain.mastodon.model.generated.MediaAttachment data class Media( val id: Long, @@ -14,3 +15,19 @@ data class Media( val blurHash: String?, val description: String? = null ) + +fun Media.toMediaAttachments(): MediaAttachment = MediaAttachment( + id = id.toString(), + type = when (type) { + FileType.Image -> MediaAttachment.Type.image + FileType.Video -> MediaAttachment.Type.video + FileType.Audio -> MediaAttachment.Type.audio + FileType.Unknown -> MediaAttachment.Type.unknown + }, + url = url, + previewUrl = thumbnailUrl, + remoteUrl = remoteUrl, + description = description, + blurhash = blurHash, + textUrl = url +) diff --git a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/user/UserRepository.kt b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/user/UserRepository.kt index 25db9d37..bbc0d346 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/domain/model/user/UserRepository.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/domain/model/user/UserRepository.kt @@ -2,7 +2,6 @@ package dev.usbharu.hideout.core.domain.model.user import org.springframework.stereotype.Repository -@Suppress("TooManyFunctions") @Repository interface UserRepository { suspend fun save(user: User): User diff --git a/src/main/kotlin/dev/usbharu/hideout/core/external/job/HideoutJob.kt b/src/main/kotlin/dev/usbharu/hideout/core/external/job/HideoutJob.kt index 62f989d0..d201fc9a 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/external/job/HideoutJob.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/external/job/HideoutJob.kt @@ -1,38 +1,161 @@ package dev.usbharu.hideout.core.external.job +import dev.usbharu.hideout.activitypub.service.common.ActivityType import kjob.core.Job import kjob.core.Prop +import kjob.core.dsl.ScheduleContext +import kjob.core.job.JobProps import org.springframework.stereotype.Component -sealed class HideoutJob(name: String = "") : Job(name) +abstract class HideoutJob>(name: String = "") : Job(name) { + abstract fun convert(value: @UnsafeVariance T): ScheduleContext<@UnsafeVariance R>.(@UnsafeVariance R) -> Unit + fun convertUnsafe(props: JobProps<*>): T = convert(props as JobProps) + abstract fun convert(props: JobProps<@UnsafeVariance R>): T +} + +data class ReceiveFollowJobParam( + val actor: String, + val follow: String, + val targetActor: String +) @Component -object ReceiveFollowJob : HideoutJob("ReceiveFollowJob") { +object ReceiveFollowJob : HideoutJob("ReceiveFollowJob") { val actor: Prop = string("actor") val follow: Prop = string("follow") val targetActor: Prop = string("targetActor") + + override fun convert(value: ReceiveFollowJobParam): ScheduleContext.(ReceiveFollowJob) -> Unit = { + props[follow] = value.follow + props[actor] = value.actor + props[targetActor] = value.targetActor + } + + override fun convert(props: JobProps): ReceiveFollowJobParam = ReceiveFollowJobParam( + actor = props[actor], + follow = props[follow], + targetActor = props[targetActor] + ) } +data class DeliverPostJobParam( + val create: String, + val inbox: String, + val actor: String +) + @Component -object DeliverPostJob : HideoutJob("DeliverPostJob") { +object DeliverPostJob : HideoutJob("DeliverPostJob") { val create = string("create") val inbox = string("inbox") val actor = string("actor") + override fun convert(value: DeliverPostJobParam): ScheduleContext.(DeliverPostJob) -> Unit = { + props[create] = value.create + props[inbox] = value.inbox + props[actor] = value.actor + } + + override fun convert(props: JobProps): DeliverPostJobParam = DeliverPostJobParam( + create = props[create], + inbox = props[inbox], + actor = props[actor] + ) } +data class DeliverReactionJobParam( + val reaction: String, + val postUrl: String, + val actor: String, + val inbox: String, + val id: String +) + @Component -object DeliverReactionJob : HideoutJob("DeliverReactionJob") { +object DeliverReactionJob : HideoutJob("DeliverReactionJob") { val reaction: Prop = string("reaction") val postUrl: Prop = string("postUrl") val actor: Prop = string("actor") val inbox: Prop = string("inbox") val id: Prop = string("id") + override fun convert( + value: DeliverReactionJobParam + ): ScheduleContext.(DeliverReactionJob) -> Unit = + { + props[reaction] = value.reaction + props[postUrl] = value.postUrl + props[actor] = value.actor + props[inbox] = value.inbox + props[id] = value.id + } + + override fun convert(props: JobProps): DeliverReactionJobParam = DeliverReactionJobParam( + props[reaction], + props[postUrl], + props[actor], + props[inbox], + props[id] + ) } +data class DeliverRemoveReactionJobParam( + val id: String, + val inbox: String, + val actor: String, + val like: String +) + @Component -object DeliverRemoveReactionJob : HideoutJob("DeliverRemoveReactionJob") { +object DeliverRemoveReactionJob : + HideoutJob("DeliverRemoveReactionJob") { val id: Prop = string("id") val inbox: Prop = string("inbox") val actor: Prop = string("actor") val like: Prop = string("like") + + override fun convert( + value: DeliverRemoveReactionJobParam + ): ScheduleContext.(DeliverRemoveReactionJob) -> Unit = + { + props[id] = value.id + props[inbox] = value.inbox + props[actor] = value.actor + props[like] = value.like + } + + override fun convert(props: JobProps): DeliverRemoveReactionJobParam = + DeliverRemoveReactionJobParam( + id = props[id], + inbox = props[inbox], + actor = props[actor], + like = props[like] + ) +} + +data class InboxJobParam( + val json: String, + val type: ActivityType, + val httpRequest: String, + val headers: String +) + +@Component +object InboxJob : HideoutJob("InboxJob") { + val json = string("json") + val type = string("type") + val httpRequest = string("http_request") + val headers = string("headers") + + override fun convert(value: InboxJobParam): ScheduleContext.(InboxJob) -> Unit = { + props[json] = value.json + props[type] = value.type.name + props[httpRequest] = value.httpRequest + props[headers] = value.headers + } + + override fun convert(props: JobProps): InboxJobParam = InboxJobParam( + props[json], + ActivityType.valueOf(props[type]), + props[httpRequest], + props[headers] + ) } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposed/PostQueryMapper.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposed/PostQueryMapper.kt index 8e87edfa..a21fcf4d 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposed/PostQueryMapper.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposed/PostQueryMapper.kt @@ -15,7 +15,7 @@ class PostQueryMapper(private val postResultRowMapper: ResultRowMapper) : .map { it.value } .map { it.first().let(postResultRowMapper::map) - .copy(mediaIds = it.mapNotNull { it.getOrNull(PostsMedia.mediaId) }) + .copy(mediaIds = it.mapNotNull { resultRow -> resultRow.getOrNull(PostsMedia.mediaId) }) } } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedquery/InstanceQueryServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedquery/InstanceQueryServiceImpl.kt index 587f57a1..c7d276ef 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedquery/InstanceQueryServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedquery/InstanceQueryServiceImpl.kt @@ -12,5 +12,5 @@ import dev.usbharu.hideout.core.domain.model.instance.Instance as InstanceEntity @Repository class InstanceQueryServiceImpl : InstanceQueryService { override suspend fun findByUrl(url: String): InstanceEntity = Instance.select { Instance.url eq url } - .singleOr { FailedToGetResourcesException("url is doesn't exist") }.toInstance() + .singleOr { FailedToGetResourcesException("$url is doesn't exist", it) }.toInstance() } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedquery/MediaQueryServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedquery/MediaQueryServiceImpl.kt index 473c5f66..7b5a5d8e 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedquery/MediaQueryServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedquery/MediaQueryServiceImpl.kt @@ -1,17 +1,20 @@ package dev.usbharu.hideout.core.infrastructure.exposedquery -import dev.usbharu.hideout.core.domain.model.media.Media +import dev.usbharu.hideout.core.domain.exception.FailedToGetResourcesException +import dev.usbharu.hideout.core.infrastructure.exposedrepository.Media import dev.usbharu.hideout.core.infrastructure.exposedrepository.PostsMedia import dev.usbharu.hideout.core.infrastructure.exposedrepository.toMedia import dev.usbharu.hideout.core.query.MediaQueryService +import dev.usbharu.hideout.util.singleOr import org.jetbrains.exposed.sql.innerJoin import org.jetbrains.exposed.sql.select import org.springframework.stereotype.Repository +import dev.usbharu.hideout.core.domain.model.media.Media as MediaEntity @Repository class MediaQueryServiceImpl : MediaQueryService { - override suspend fun findByPostId(postId: Long): List { - return dev.usbharu.hideout.core.infrastructure.exposedrepository.Media.innerJoin( + override suspend fun findByPostId(postId: Long): List { + return Media.innerJoin( PostsMedia, onColumn = { id }, otherColumn = { mediaId } @@ -19,4 +22,10 @@ class MediaQueryServiceImpl : MediaQueryService { .select { PostsMedia.postId eq postId } .map { it.toMedia() } } + + override suspend fun findByRemoteUrl(remoteUrl: String): MediaEntity { + return Media.select { Media.remoteUrl eq remoteUrl } + .singleOr { FailedToGetResourcesException("remoteUrl: $remoteUrl is duplicate or not exist.", it) } + .toMedia() + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/InstanceRepositoryImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/InstanceRepositoryImpl.kt index edd79195..883a5b48 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/InstanceRepositoryImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/InstanceRepositoryImpl.kt @@ -54,7 +54,7 @@ class InstanceRepositoryImpl(private val idGenerateService: IdGenerateService) : } override suspend fun delete(instance: InstanceEntity) { - Instance.deleteWhere { Instance.id eq instance.id } + Instance.deleteWhere { id eq instance.id } } } @@ -79,9 +79,9 @@ object Instance : Table("instance") { val id = long("id") val name = varchar("name", 1000) val description = varchar("description", 5000) - val url = varchar("url", 255) + val url = varchar("url", 255).uniqueIndex() val iconUrl = varchar("icon_url", 255) - val sharedInbox = varchar("shared_inbox", 255).nullable() + val sharedInbox = varchar("shared_inbox", 255).nullable().uniqueIndex() val software = varchar("software", 255) val version = varchar("version", 255) val isBlocked = bool("is_blocked") diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/MediaRepositoryImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/MediaRepositoryImpl.kt index 0206feb9..97b7c527 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/MediaRepositoryImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/MediaRepositoryImpl.kt @@ -99,9 +99,9 @@ fun ResultRow.toMediaOrNull(): EntityMedia? { object Media : Table("media") { val id = long("id") val name = varchar("name", 255) - val url = varchar("url", 255) - val remoteUrl = varchar("remote_url", 255).nullable() - val thumbnailUrl = varchar("thumbnail_url", 255).nullable() + val url = varchar("url", 255).uniqueIndex() + val remoteUrl = varchar("remote_url", 255).uniqueIndex().nullable() + val thumbnailUrl = varchar("thumbnail_url", 255).uniqueIndex().nullable() val type = integer("type") val blurhash = varchar("blurhash", 255).nullable() val mimeType = varchar("mime_type", 255) diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/UserRepositoryImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/UserRepositoryImpl.kt index 5cd94ccf..ecd910a6 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/UserRepositoryImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/exposedrepository/UserRepositoryImpl.kt @@ -17,7 +17,7 @@ class UserRepositoryImpl( UserRepository { override suspend fun save(user: User): User { - val singleOrNull = Users.select { Users.id eq user.id or (Users.url eq user.url) }.empty() + val singleOrNull = Users.select { Users.id eq user.id }.empty() if (singleOrNull) { Users.insert { it[id] = user.id diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/httpsignature/HttpRequestMixIn.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/httpsignature/HttpRequestMixIn.kt new file mode 100644 index 00000000..4f998d91 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/httpsignature/HttpRequestMixIn.kt @@ -0,0 +1,28 @@ +package dev.usbharu.hideout.core.infrastructure.httpsignature + +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import dev.usbharu.httpsignature.common.HttpHeaders +import dev.usbharu.httpsignature.common.HttpMethod +import dev.usbharu.httpsignature.common.HttpRequest +import java.net.URL + +@JsonDeserialize(using = HttpRequestDeserializer::class) +@JsonSubTypes +abstract class HttpRequestMixIn + +class HttpRequestDeserializer : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext?): HttpRequest { + val readTree: JsonNode = p.codec.readTree(p) + + return HttpRequest( + URL(readTree["url"].textValue()), + HttpHeaders(emptyMap()), + HttpMethod.valueOf(readTree["method"].textValue()) + ) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/kjobexposed/KJobJobQueueParentService.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/kjobexposed/KJobJobQueueParentService.kt index 0e02b011..f61949e2 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/kjobexposed/KJobJobQueueParentService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/kjobexposed/KJobJobQueueParentService.kt @@ -1,5 +1,6 @@ package dev.usbharu.hideout.core.infrastructure.kjobexposed +import dev.usbharu.hideout.core.external.job.HideoutJob import dev.usbharu.hideout.core.service.job.JobQueueParentService import kjob.core.Job import kjob.core.KJob @@ -12,7 +13,7 @@ import org.springframework.stereotype.Service @Service @ConditionalOnProperty(name = ["hideout.use-mongodb"], havingValue = "false", matchIfMissing = true) -class KJobJobQueueParentService() : JobQueueParentService { +class KJobJobQueueParentService : JobQueueParentService { private val logger = LoggerFactory.getLogger(this::class.java) @@ -29,4 +30,12 @@ class KJobJobQueueParentService() : JobQueueParentService { logger.debug("schedule job={}", job.name) kjob.schedule(job, block) } + + override suspend fun > scheduleTypeSafe(job: J, jobProps: T) { + logger.debug("SCHEDULE Job: {}", job.name) + logger.trace("Job props: {}", jobProps) + val convert: ScheduleContext.(J) -> Unit = job.convert(jobProps) + kjob.schedule(job, convert) + logger.debug("SUCCESS Schedule Job: {}", job.name) + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/kjobexposed/KJobJobQueueWorkerService.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/kjobexposed/KJobJobQueueWorkerService.kt index 98c3a488..a03272c4 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/kjobexposed/KJobJobQueueWorkerService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/kjobexposed/KJobJobQueueWorkerService.kt @@ -1,18 +1,19 @@ package dev.usbharu.hideout.core.infrastructure.kjobexposed +import dev.usbharu.hideout.core.external.job.HideoutJob +import dev.usbharu.hideout.core.service.job.JobProcessor import dev.usbharu.hideout.core.service.job.JobQueueWorkerService +import kjob.core.dsl.JobContextWithProps import kjob.core.dsl.JobRegisterContext import kjob.core.dsl.KJobFunctions import kjob.core.kjob import org.jetbrains.exposed.sql.transactions.TransactionManager import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.stereotype.Service -import dev.usbharu.hideout.core.external.job.HideoutJob as HJ -import kjob.core.dsl.JobContextWithProps as JCWP @Service @ConditionalOnProperty(name = ["hideout.use-mongodb"], havingValue = "false", matchIfMissing = true) -class KJobJobQueueWorkerService() : JobQueueWorkerService { +class KJobJobQueueWorkerService(private val jobQueueProcessorList: List>) : JobQueueWorkerService { val kjob by lazy { kjob(ExposedKJob) { @@ -23,11 +24,21 @@ class KJobJobQueueWorkerService() : JobQueueWorkerService { }.start() } - override fun init( - defines: List>.(HJ) -> KJobFunctions>>> + override fun > init( + defines: + List>.(R) -> KJobFunctions>>> ) { defines.forEach { job -> kjob.register(job.first, job.second) } + + for (jobProcessor in jobQueueProcessorList) { + kjob.register(jobProcessor.job()) { + execute { + val param = it.convertUnsafe(props) + jobProcessor.process(param) + } + } + } } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/kjobmongodb/KJobMongoJobQueueWorkerService.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/kjobmongodb/KJobMongoJobQueueWorkerService.kt index 5d4ed22c..fd57f210 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/kjobmongodb/KJobMongoJobQueueWorkerService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/kjobmongodb/KJobMongoJobQueueWorkerService.kt @@ -1,19 +1,23 @@ package dev.usbharu.hideout.core.infrastructure.kjobmongodb import com.mongodb.reactivestreams.client.MongoClient +import dev.usbharu.hideout.core.external.job.HideoutJob +import dev.usbharu.hideout.core.service.job.JobProcessor import dev.usbharu.hideout.core.service.job.JobQueueWorkerService +import kjob.core.dsl.JobContextWithProps import kjob.core.dsl.JobRegisterContext import kjob.core.dsl.KJobFunctions import kjob.core.kjob import kjob.mongo.Mongo import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.stereotype.Service -import dev.usbharu.hideout.core.external.job.HideoutJob as HJ -import kjob.core.dsl.JobContextWithProps as JCWP @Service @ConditionalOnProperty(name = ["hideout.use-mongodb"], havingValue = "true", matchIfMissing = false) -class KJobMongoJobQueueWorkerService(private val mongoClient: MongoClient) : JobQueueWorkerService, AutoCloseable { +class KJobMongoJobQueueWorkerService( + private val mongoClient: MongoClient, + private val jobQueueProcessorList: List> +) : JobQueueWorkerService, AutoCloseable { val kjob by lazy { kjob(Mongo) { client = mongoClient @@ -23,12 +27,21 @@ class KJobMongoJobQueueWorkerService(private val mongoClient: MongoClient) : Job }.start() } - override fun init( - defines: List>.(HJ) -> KJobFunctions>>> + override fun > init( + defines: + List>.(R) -> KJobFunctions>>> ) { defines.forEach { job -> kjob.register(job.first, job.second) } + for (jobProcessor in jobQueueProcessorList) { + kjob.register(jobProcessor.job()) { + execute { + val param = it.convertUnsafe(props) + jobProcessor.process(param) + } + } + } } override fun close() { diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/kjobmongodb/KjobMongoJobQueueParentService.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/kjobmongodb/KjobMongoJobQueueParentService.kt index 0875325d..37f600dc 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/kjobmongodb/KjobMongoJobQueueParentService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/kjobmongodb/KjobMongoJobQueueParentService.kt @@ -1,11 +1,13 @@ package dev.usbharu.hideout.core.infrastructure.kjobmongodb import com.mongodb.reactivestreams.client.MongoClient +import dev.usbharu.hideout.core.external.job.HideoutJob import dev.usbharu.hideout.core.service.job.JobQueueParentService import kjob.core.Job import kjob.core.dsl.ScheduleContext import kjob.core.kjob import kjob.mongo.Mongo +import org.slf4j.LoggerFactory import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.stereotype.Service @@ -23,11 +25,25 @@ class KjobMongoJobQueueParentService(private val mongoClient: MongoClient) : Job override fun init(jobDefines: List) = Unit + @Deprecated("use type safe → scheduleTypeSafe") override suspend fun schedule(job: J, block: ScheduleContext.(J) -> Unit) { + logger.debug("SCHEDULE Job: {}", job.name) kjob.schedule(job, block) } + override suspend fun > scheduleTypeSafe(job: J, jobProps: T) { + logger.debug("SCHEDULE Job: {}", job.name) + logger.trace("Job props: {}", jobProps) + val convert = job.convert(jobProps) + kjob.schedule(job, convert) + logger.debug("SUCCESS Job: {}", job.name) + } + override fun close() { kjob.shutdown() } + + companion object { + private val logger = LoggerFactory.getLogger(KjobMongoJobQueueParentService::class.java) + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/mongorepository/MongoTimelineRepositoryWrapper.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/mongorepository/MongoTimelineRepositoryWrapper.kt index 1fdbea8e..dfaebfce 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/mongorepository/MongoTimelineRepositoryWrapper.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/mongorepository/MongoTimelineRepositoryWrapper.kt @@ -9,7 +9,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.stereotype.Repository @Repository -@Suppress("InjectDispatcher") @ConditionalOnProperty("hideout.use-mongodb", havingValue = "true", matchIfMissing = false) class MongoTimelineRepositoryWrapper( private val mongoTimelineRepository: MongoTimelineRepository, diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/httpsignature/HttpSignatureFilter.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/httpsignature/HttpSignatureFilter.kt index e3566886..8b3c1b11 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/httpsignature/HttpSignatureFilter.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/httpsignature/HttpSignatureFilter.kt @@ -1,34 +1,20 @@ package dev.usbharu.hideout.core.infrastructure.springframework.httpsignature -import com.fasterxml.jackson.databind.ObjectMapper -import dev.usbharu.hideout.activitypub.service.objects.user.APUserService -import dev.usbharu.hideout.application.external.Transaction import dev.usbharu.httpsignature.common.HttpHeaders import dev.usbharu.httpsignature.common.HttpMethod import dev.usbharu.httpsignature.common.HttpRequest import dev.usbharu.httpsignature.verify.SignatureHeaderParser import jakarta.servlet.http.HttpServletRequest -import kotlinx.coroutines.runBlocking -import org.springframework.beans.factory.annotation.Qualifier import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter import java.net.URL -class HttpSignatureFilter( - private val httpSignatureHeaderParser: SignatureHeaderParser, - @Qualifier("activitypub") private val objectMapper: ObjectMapper, - private val apUserService: APUserService, - private val transaction: Transaction, -) : +class HttpSignatureFilter(private val httpSignatureHeaderParser: SignatureHeaderParser) : AbstractPreAuthenticatedProcessingFilter() { - - - override fun getPreAuthenticatedPrincipal(request: HttpServletRequest): Any? { - - - val headersList = request.headerNames?.toList().orEmpty() + override fun getPreAuthenticatedPrincipal(request: HttpServletRequest?): Any? { + val headersList = request?.headerNames?.toList().orEmpty() val headers = - headersList.associateWith { header -> request.getHeaders(header)?.toList().orEmpty() } + headersList.associateWith { header -> request?.getHeaders(header)?.toList().orEmpty() } val signature = try { httpSignatureHeaderParser.parse(HttpHeaders(headers)) @@ -37,12 +23,6 @@ class HttpSignatureFilter( } catch (_: RuntimeException) { return "" } - runBlocking { - transaction.transaction { - - apUserService.fetchPerson(signature.keyId) - } - } return signature.keyId } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/httpsignature/HttpSignatureUserDetailsService.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/httpsignature/HttpSignatureUserDetailsService.kt index 83b0c326..9ef79982 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/httpsignature/HttpSignatureUserDetailsService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/httpsignature/HttpSignatureUserDetailsService.kt @@ -5,10 +5,12 @@ import dev.usbharu.hideout.core.domain.exception.FailedToGetResourcesException import dev.usbharu.hideout.core.domain.exception.HttpSignatureVerifyException import dev.usbharu.hideout.core.query.UserQueryService import dev.usbharu.hideout.util.RsaUtil +import dev.usbharu.httpsignature.common.HttpMethod import dev.usbharu.httpsignature.common.HttpRequest import dev.usbharu.httpsignature.common.PublicKey import dev.usbharu.httpsignature.verify.FailedVerification import dev.usbharu.httpsignature.verify.HttpSignatureVerifier +import dev.usbharu.httpsignature.verify.SignatureHeaderParser import kotlinx.coroutines.runBlocking import org.slf4j.LoggerFactory import org.springframework.security.authentication.BadCredentialsException @@ -20,16 +22,15 @@ import org.springframework.security.web.authentication.preauth.PreAuthenticatedA class HttpSignatureUserDetailsService( private val userQueryService: UserQueryService, private val httpSignatureVerifier: HttpSignatureVerifier, - private val transaction: Transaction + private val transaction: Transaction, + private val httpSignatureHeaderParser: SignatureHeaderParser ) : AuthenticationUserDetailsService { override fun loadUserDetails(token: PreAuthenticatedAuthenticationToken): UserDetails = runBlocking { - if (token.principal !is String) { - throw IllegalStateException("Token is not String") - } - if (token.credentials !is HttpRequest) { - throw IllegalStateException("Credentials is not HttpRequest") - } + check(token.principal is String) { "Token is not String" } + val credentials = token.credentials + + check(credentials is HttpRequest) { "Credentials is not HttpRequest" } val keyId = token.principal as String val findByKeyId = transaction.transaction { @@ -41,10 +42,25 @@ class HttpSignatureUserDetailsService( } } + val signature = httpSignatureHeaderParser.parse(credentials.headers) + + val requiredHeaders = when (credentials.method) { + HttpMethod.GET -> getRequiredHeaders + HttpMethod.POST -> postRequiredHeaders + } + if (signature.headers.containsAll(requiredHeaders).not()) { + logger.warn( + "FAILED Verify HTTP Signature. required headers: {} but actual: {}", + requiredHeaders, + signature.headers + ) + throw BadCredentialsException("HTTP Signature. required headers: $requiredHeaders") + } + @Suppress("TooGenericExceptionCaught") val verify = try { httpSignatureVerifier.verify( - token.credentials as HttpRequest, + credentials, PublicKey(RsaUtil.decodeRsaPublicKeyPem(findByKeyId.publicKey), keyId) ) } catch (e: RuntimeException) { @@ -68,5 +84,7 @@ class HttpSignatureUserDetailsService( companion object { private val logger = LoggerFactory.getLogger(HttpSignatureUserDetailsService::class.java) + private val postRequiredHeaders = listOf("(request-target)", "date", "host", "digest") + private val getRequiredHeaders = listOf("(request-target)", "date", "host") } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/oauth2/ExposedOAuth2AuthorizationService.kt b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/oauth2/ExposedOAuth2AuthorizationService.kt index 458f805c..34e72833 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/oauth2/ExposedOAuth2AuthorizationService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/infrastructure/springframework/oauth2/ExposedOAuth2AuthorizationService.kt @@ -62,7 +62,7 @@ class ExposedOAuth2AuthorizationService( it[accessTokenMetadata] = accessToken?.metadata?.let { it1 -> mapToJson(it1) } it[accessTokenType] = accessToken?.token?.tokenType?.value it[accessTokenScopes] = - accessToken?.run { token.scopes.joinToString(",").takeIf { it.isNotEmpty() } } + accessToken?.run { token.scopes.joinToString(",").takeIf { s -> s.isNotEmpty() } } it[refreshTokenValue] = refreshToken?.token?.tokenValue it[refreshTokenIssuedAt] = refreshToken?.token?.issuedAt it[refreshTokenExpiresAt] = refreshToken?.token?.expiresAt @@ -197,7 +197,7 @@ class ExposedOAuth2AuthorizationService( } } - @Suppress("LongMethod", "CyclomaticComplexMethod") + @Suppress("LongMethod", "CyclomaticComplexMethod", "CastToNullableType", "UNCHECKED_CAST") fun ResultRow.toAuthorization(): OAuth2Authorization { val registeredClientId = this[Authorization.registeredClientId] @@ -272,7 +272,8 @@ class ExposedOAuth2AuthorizationService( oidcIdTokenValue, oidcTokenIssuedAt, oidcTokenExpiresAt, - oidcTokenMetadata.getValue(OAuth2Authorization.Token.CLAIMS_METADATA_NAME) as MutableMap? + oidcTokenMetadata.getValue(OAuth2Authorization.Token.CLAIMS_METADATA_NAME) + as MutableMap? ) builder.token(oidcIdToken) { it.putAll(oidcTokenMetadata) } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/query/MediaQueryService.kt b/src/main/kotlin/dev/usbharu/hideout/core/query/MediaQueryService.kt index fc7fb675..876c2f1e 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/query/MediaQueryService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/query/MediaQueryService.kt @@ -4,4 +4,5 @@ import dev.usbharu.hideout.core.domain.model.media.Media interface MediaQueryService { suspend fun findByPostId(postId: Long): List + suspend fun findByRemoteUrl(remoteUrl: String): Media } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/instance/InstanceService.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/instance/InstanceService.kt index 4b0e2640..d06e53ad 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/instance/InstanceService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/instance/InstanceService.kt @@ -31,7 +31,7 @@ class InstanceServiceImpl( val resolveInstanceUrl = u.protocol + "://" + u.host try { - return instanceQueryService.findByUrl(url) + return instanceQueryService.findByUrl(resolveInstanceUrl) } catch (e: FailedToGetResourcesException) { logger.info("Instance not found. try fetch instance info. url: {}", resolveInstanceUrl) logger.debug("Failed to get resources. url: {}", resolveInstanceUrl, e) @@ -53,7 +53,7 @@ class InstanceServiceImpl( name = nodeinfo20.metadata?.nodeName, description = nodeinfo20.metadata?.nodeDescription, url = resolveInstanceUrl, - iconUrl = resolveInstanceUrl + "/favicon.ico", + iconUrl = "$resolveInstanceUrl/favicon.ico", sharedInbox = sharedInbox, software = nodeinfo20.software?.name, version = nodeinfo20.software?.version @@ -72,7 +72,7 @@ class InstanceServiceImpl( name = nodeinfo20.metadata?.nodeName, description = nodeinfo20.metadata?.nodeDescription, url = resolveInstanceUrl, - iconUrl = resolveInstanceUrl + "/favicon.ico", + iconUrl = "$resolveInstanceUrl/favicon.ico", sharedInbox = sharedInbox, software = nodeinfo20.software?.name, version = nodeinfo20.software?.version @@ -81,7 +81,7 @@ class InstanceServiceImpl( } else -> { - TODO() + throw IllegalStateException("Unknown nodeinfo versions: $key url: $value") } } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/job/JobProcessor.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/job/JobProcessor.kt new file mode 100644 index 00000000..7d38449f --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/job/JobProcessor.kt @@ -0,0 +1,8 @@ +package dev.usbharu.hideout.core.service.job + +import dev.usbharu.hideout.core.external.job.HideoutJob + +interface JobProcessor> { + suspend fun process(param: @UnsafeVariance T) + fun job(): R +} diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/job/JobQueueParentService.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/job/JobQueueParentService.kt index 38f8111f..acec3f4a 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/job/JobQueueParentService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/job/JobQueueParentService.kt @@ -1,5 +1,6 @@ package dev.usbharu.hideout.core.service.job +import dev.usbharu.hideout.core.external.job.HideoutJob import kjob.core.Job import kjob.core.dsl.ScheduleContext import org.springframework.stereotype.Service @@ -8,5 +9,8 @@ import org.springframework.stereotype.Service interface JobQueueParentService { fun init(jobDefines: List) + + @Deprecated("use type safe → scheduleTypeSafe") suspend fun schedule(job: J, block: ScheduleContext.(J) -> Unit = {}) + suspend fun > scheduleTypeSafe(job: J, jobProps: T) } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/job/JobQueueWorkerService.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/job/JobQueueWorkerService.kt index 982dbeb0..9496470e 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/job/JobQueueWorkerService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/job/JobQueueWorkerService.kt @@ -8,7 +8,7 @@ import kjob.core.dsl.JobRegisterContext as JRC @Service interface JobQueueWorkerService { - fun init( - defines: List>.(HJ) -> KJobFunctions>>> + fun > init( + defines: List>.(R) -> KJobFunctions>>> ) } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/media/LocalFileSystemMediaDataStore.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/media/LocalFileSystemMediaDataStore.kt new file mode 100644 index 00000000..a7300081 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/media/LocalFileSystemMediaDataStore.kt @@ -0,0 +1,109 @@ +package dev.usbharu.hideout.core.service.media + +import dev.usbharu.hideout.application.config.ApplicationConfig +import dev.usbharu.hideout.application.config.LocalStorageConfig +import org.slf4j.LoggerFactory +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.stereotype.Service +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import kotlin.io.path.copyTo +import kotlin.io.path.createDirectories +import kotlin.io.path.deleteIfExists +import kotlin.io.path.outputStream + +@Service +@ConditionalOnProperty("hideout.storage.type", havingValue = "local", matchIfMissing = true) +/** + * ローカルファイルシステムにメディアを保存します + * + * @constructor + * ApplicationConfigとLocalStorageConfigをもとに作成 + * + * @param applicationConfig ApplicationConfig + * @param localStorageConfig LocalStorageConfig + */ +class LocalFileSystemMediaDataStore( + applicationConfig: ApplicationConfig, localStorageConfig: LocalStorageConfig +) : MediaDataStore { + + private val savePath: Path = Path.of(localStorageConfig.path).toAbsolutePath() + + private val publicUrl = localStorageConfig.publicUrl ?: "${applicationConfig.url}/files/" + + init { + savePath.createDirectories() + } + + override suspend fun save(dataMediaSave: MediaSave): SavedMedia { + val fileSavePath = buildSavePath(savePath, dataMediaSave.name) + val thumbnailSavePath = buildSavePath(savePath, "thumbnail-" + dataMediaSave.name) + + + dataMediaSave.thumbnailInputStream?.inputStream()?.use { + it.buffered().use { bufferedInputStream -> + thumbnailSavePath.outputStream(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE) + .use { outputStream -> + outputStream.buffered().use { + bufferedInputStream.transferTo(it) + } + } + } + } + + + dataMediaSave.fileInputStream.inputStream().use { + it.buffered().use { bufferedInputStream -> + fileSavePath.outputStream(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE) + .use { outputStream -> outputStream.buffered().use { bufferedInputStream.transferTo(it) } } + } + } + + return SuccessSavedMedia( + dataMediaSave.name, publicUrl + dataMediaSave.name, publicUrl + "thumbnail-" + dataMediaSave.name + ) + } + + override suspend fun save(dataSaveRequest: MediaSaveRequest): SavedMedia { + logger.info("START Media upload. {}", dataSaveRequest.name) + val fileSavePath = buildSavePath(savePath, dataSaveRequest.name) + val thumbnailSavePath = buildSavePath(savePath, "thumbnail-" + dataSaveRequest.name) + + val fileSavePathString = fileSavePath.toAbsolutePath().toString() + logger.info("MEDIA save. path: {}", fileSavePathString) + + try { + dataSaveRequest.filePath.copyTo(fileSavePath) + dataSaveRequest.thumbnailPath?.copyTo(thumbnailSavePath) + } catch (e: Exception) { + logger.warn("FAILED to Save the media.", e) + return FaildSavedMedia("FAILED to Save the media.", "Failed copy to path: $fileSavePathString", e) + } + + logger.info("SUCCESS Media upload. {}", dataSaveRequest.name) + return SuccessSavedMedia( + dataSaveRequest.name, publicUrl + dataSaveRequest.name, publicUrl + "thumbnail-" + dataSaveRequest.name + ) + } + + /** + * メディアを削除します。サムネイルも削除されます。 + * + * @param id 削除するメディアのid [SuccessSavedMedia.name]を指定します。 + */ + override suspend fun delete(id: String) { + logger.info("START Media delete. id: {}", id) + try { + buildSavePath(savePath, id).deleteIfExists() + buildSavePath(savePath, "thumbnail-$id").deleteIfExists() + } catch (e: Exception) { + logger.warn("FAILED Media delete. id: {}", id, e) + } + } + + private fun buildSavePath(savePath: Path, name: String): Path = savePath.resolve(name) + + companion object { + private val logger = LoggerFactory.getLogger(LocalFileSystemMediaDataStore::class.java) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/media/MediaDataStore.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/media/MediaDataStore.kt index 03ee3e9f..478ddc50 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/media/MediaDataStore.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/media/MediaDataStore.kt @@ -1,7 +1,31 @@ package dev.usbharu.hideout.core.service.media +/** + * メディアを保存するインタフェース + * + */ interface MediaDataStore { + /** + * InputStreamを使用してメディアを保存します + * + * @param dataMediaSave FileとThumbnailのinputStream + * @return 保存されたメディア + */ suspend fun save(dataMediaSave: MediaSave): SavedMedia + + /** + * 一時ファイルのパスを使用してメディアを保存します + * + * @param dataSaveRequest FileとThumbnailのパス + * @return 保存されたメディア + */ suspend fun save(dataSaveRequest: MediaSaveRequest): SavedMedia + + /** + * メディアを削除します + * 実装はサムネイル、メタデータなども削除するべきです。 + * + * @param id 削除するメディアのid 通常は[SuccessSavedMedia.name]を指定します。 + */ suspend fun delete(id: String) } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/media/MediaSave.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/media/MediaSave.kt index 3529ace9..9fbae73a 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/media/MediaSave.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/media/MediaSave.kt @@ -5,4 +5,29 @@ data class MediaSave( val prefix: String, val fileInputStream: ByteArray, val thumbnailInputStream: ByteArray? -) +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MediaSave + + if (name != other.name) return false + if (prefix != other.prefix) return false + if (!fileInputStream.contentEquals(other.fileInputStream)) return false + if (thumbnailInputStream != null) { + if (other.thumbnailInputStream == null) return false + if (!thumbnailInputStream.contentEquals(other.thumbnailInputStream)) return false + } else if (other.thumbnailInputStream != null) return false + + return true + } + + override fun hashCode(): Int { + var result = name.hashCode() + result = 31 * result + prefix.hashCode() + result = 31 * result + fileInputStream.contentHashCode() + result = 31 * result + (thumbnailInputStream?.contentHashCode() ?: 0) + return result + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/media/MediaServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/media/MediaServiceImpl.kt index ff8f1d93..80d26318 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/media/MediaServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/media/MediaServiceImpl.kt @@ -1,9 +1,11 @@ package dev.usbharu.hideout.core.service.media +import dev.usbharu.hideout.core.domain.exception.FailedToGetResourcesException import dev.usbharu.hideout.core.domain.exception.media.MediaSaveException import dev.usbharu.hideout.core.domain.exception.media.UnsupportedMediaException import dev.usbharu.hideout.core.domain.model.media.Media import dev.usbharu.hideout.core.domain.model.media.MediaRepository +import dev.usbharu.hideout.core.query.MediaQueryService import dev.usbharu.hideout.core.service.media.converter.MediaProcessService import dev.usbharu.hideout.mastodon.interfaces.api.media.MediaRequest import dev.usbharu.hideout.util.withDelete @@ -15,14 +17,15 @@ import dev.usbharu.hideout.core.domain.model.media.Media as EntityMedia @Service @Suppress("TooGenericExceptionCaught") -open class MediaServiceImpl( +class MediaServiceImpl( private val mediaDataStore: MediaDataStore, private val fileTypeDeterminationService: FileTypeDeterminationService, private val mediaBlurhashService: MediaBlurhashService, private val mediaRepository: MediaRepository, private val mediaProcessServices: List, private val remoteMediaDownloadService: RemoteMediaDownloadService, - private val renameService: MediaFileRenameService + private val renameService: MediaFileRenameService, + private val mediaQueryService: MediaQueryService ) : MediaService { @Suppress("LongMethod", "NestedBlockDepth") override suspend fun uploadLocalMedia(mediaRequest: MediaRequest): EntityMedia { @@ -99,6 +102,13 @@ open class MediaServiceImpl( override suspend fun uploadRemoteMedia(remoteMedia: RemoteMedia): Media { logger.info("MEDIA Remote media. filename:${remoteMedia.name} url:${remoteMedia.url}") + try { + val findByRemoteUrl = mediaQueryService.findByRemoteUrl(remoteMedia.url) + logger.warn("DUPLICATED Remote media is duplicated. url: {}", remoteMedia.url) + return findByRemoteUrl + } catch (_: FailedToGetResourcesException) { + } + remoteMediaDownloadService.download(remoteMedia.url).withDelete().use { val mimeType = fileTypeDeterminationService.fileType(it.path, remoteMedia.name) diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/media/ProcessedFile.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/media/ProcessedFile.kt index c138ed43..b3589cee 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/media/ProcessedFile.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/media/ProcessedFile.kt @@ -3,4 +3,22 @@ package dev.usbharu.hideout.core.service.media data class ProcessedFile( val byteArray: ByteArray, val extension: String -) +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ProcessedFile + + if (!byteArray.contentEquals(other.byteArray)) return false + if (extension != other.extension) return false + + return true + } + + override fun hashCode(): Int { + var result = byteArray.contentHashCode() + result = 31 * result + extension.hashCode() + return result + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/media/RemoteMediaDownloadServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/media/RemoteMediaDownloadServiceImpl.kt index 6bc9040c..bd1014f8 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/media/RemoteMediaDownloadServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/media/RemoteMediaDownloadServiceImpl.kt @@ -1,10 +1,6 @@ package dev.usbharu.hideout.core.service.media -import io.ktor.client.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.utils.io.jvm.javaio.* +import dev.usbharu.hideout.core.service.resource.KtorResourceResolveService import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.nio.file.Files @@ -12,16 +8,16 @@ import java.nio.file.Path import kotlin.io.path.outputStream @Service -class RemoteMediaDownloadServiceImpl(private val httpClient: HttpClient) : RemoteMediaDownloadService { +class RemoteMediaDownloadServiceImpl(private val resourceResolveService: KtorResourceResolveService) : + RemoteMediaDownloadService { override suspend fun download(url: String): Path { logger.info("START Download remote file. url: {}", url) - val httpResponse = httpClient.get(url) - httpResponse.contentLength() + val httpResponse = resourceResolveService.resolve(url).body() val createTempFile = Files.createTempFile("hideout-remote-download", ".tmp") logger.debug("Save to {} url: {} ", createTempFile, url) - httpResponse.bodyAsChannel().toInputStream().use { inputStream -> + httpResponse.use { inputStream -> createTempFile.outputStream().use { inputStream.transferTo(it) } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/media/S3MediaDataStore.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/media/S3MediaDataStore.kt index 4377fdc0..8b0f2235 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/media/S3MediaDataStore.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/media/S3MediaDataStore.kt @@ -1,11 +1,12 @@ package dev.usbharu.hideout.core.service.media -import dev.usbharu.hideout.application.config.StorageConfig +import dev.usbharu.hideout.application.config.S3StorageConfig import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import org.slf4j.LoggerFactory +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.stereotype.Service import software.amazon.awssdk.core.sync.RequestBody import software.amazon.awssdk.services.s3.S3Client @@ -14,16 +15,17 @@ import software.amazon.awssdk.services.s3.model.GetUrlRequest import software.amazon.awssdk.services.s3.model.PutObjectRequest @Service -class S3MediaDataStore(private val s3Client: S3Client, private val storageConfig: StorageConfig) : MediaDataStore { +@ConditionalOnProperty("hideout.storage.type", havingValue = "s3") +class S3MediaDataStore(private val s3Client: S3Client, private val s3StorageConfig: S3StorageConfig) : MediaDataStore { override suspend fun save(dataMediaSave: MediaSave): SavedMedia { val fileUploadRequest = PutObjectRequest.builder() - .bucket(storageConfig.bucket) + .bucket(s3StorageConfig.bucket) .key(dataMediaSave.name) .build() val thumbnailKey = "thumbnail-${dataMediaSave.name}" val thumbnailUploadRequest = PutObjectRequest.builder() - .bucket(storageConfig.bucket) + .bucket(s3StorageConfig.bucket) .key(thumbnailKey) .build() @@ -36,7 +38,7 @@ class S3MediaDataStore(private val s3Client: S3Client, private val storageConfig RequestBody.fromBytes(dataMediaSave.thumbnailInputStream) ) s3Client.utilities() - .getUrl(GetUrlRequest.builder().bucket(storageConfig.bucket).key(thumbnailKey).build()) + .getUrl(GetUrlRequest.builder().bucket(s3StorageConfig.bucket).key(thumbnailKey).build()) } else { null } @@ -44,14 +46,14 @@ class S3MediaDataStore(private val s3Client: S3Client, private val storageConfig async { s3Client.putObject(fileUploadRequest, RequestBody.fromBytes(dataMediaSave.fileInputStream)) s3Client.utilities() - .getUrl(GetUrlRequest.builder().bucket(storageConfig.bucket).key(dataMediaSave.name).build()) + .getUrl(GetUrlRequest.builder().bucket(s3StorageConfig.bucket).key(dataMediaSave.name).build()) } ) } return SuccessSavedMedia( name = dataMediaSave.name, - url = "${storageConfig.publicUrl}/${storageConfig.bucket}/${dataMediaSave.name}", - thumbnailUrl = "${storageConfig.publicUrl}/${storageConfig.bucket}/$thumbnailKey" + url = "${s3StorageConfig.publicUrl}/${s3StorageConfig.bucket}/${dataMediaSave.name}", + thumbnailUrl = "${s3StorageConfig.publicUrl}/${s3StorageConfig.bucket}/$thumbnailKey" ) } @@ -59,19 +61,19 @@ class S3MediaDataStore(private val s3Client: S3Client, private val storageConfig logger.info("MEDIA upload. {}", dataSaveRequest.name) val fileUploadRequest = PutObjectRequest.builder() - .bucket(storageConfig.bucket) + .bucket(s3StorageConfig.bucket) .key(dataSaveRequest.name) .build() - logger.info("MEDIA upload. bucket: {} key: {}", storageConfig.bucket, dataSaveRequest.name) + logger.info("MEDIA upload. bucket: {} key: {}", s3StorageConfig.bucket, dataSaveRequest.name) val thumbnailKey = "thumbnail-${dataSaveRequest.name}" val thumbnailUploadRequest = PutObjectRequest.builder() - .bucket(storageConfig.bucket) + .bucket(s3StorageConfig.bucket) .key(thumbnailKey) .build() - logger.info("MEDIA upload. bucket: {} key: {}", storageConfig.bucket, thumbnailKey) + logger.info("MEDIA upload. bucket: {} key: {}", s3StorageConfig.bucket, thumbnailKey) withContext(Dispatchers.IO) { awaitAll( @@ -92,8 +94,8 @@ class S3MediaDataStore(private val s3Client: S3Client, private val storageConfig } val successSavedMedia = SuccessSavedMedia( name = dataSaveRequest.name, - url = "${storageConfig.publicUrl}/${storageConfig.bucket}/${dataSaveRequest.name}", - thumbnailUrl = "${storageConfig.publicUrl}/${storageConfig.bucket}/$thumbnailKey" + url = "${s3StorageConfig.publicUrl}/${s3StorageConfig.bucket}/${dataSaveRequest.name}", + thumbnailUrl = "${s3StorageConfig.publicUrl}/${s3StorageConfig.bucket}/$thumbnailKey" ) logger.info("SUCCESS Media upload. {}", dataSaveRequest.name) @@ -108,9 +110,9 @@ class S3MediaDataStore(private val s3Client: S3Client, private val storageConfig } override suspend fun delete(id: String) { - val fileDeleteRequest = DeleteObjectRequest.builder().bucket(storageConfig.bucket).key(id).build() + val fileDeleteRequest = DeleteObjectRequest.builder().bucket(s3StorageConfig.bucket).key(id).build() val thumbnailDeleteRequest = - DeleteObjectRequest.builder().bucket(storageConfig.bucket).key("thumbnail-$id").build() + DeleteObjectRequest.builder().bucket(s3StorageConfig.bucket).key("thumbnail-$id").build() s3Client.deleteObject(fileDeleteRequest) s3Client.deleteObject(thumbnailDeleteRequest) } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/media/converter/image/ImageMediaProcessService.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/media/converter/image/ImageMediaProcessService.kt index 2893fa4c..c713a63e 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/media/converter/image/ImageMediaProcessService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/media/converter/image/ImageMediaProcessService.kt @@ -13,6 +13,8 @@ import net.coobird.thumbnailator.Thumbnails import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Qualifier import org.springframework.stereotype.Service +import java.awt.Color +import java.awt.image.BufferedImage import java.nio.file.Files import java.nio.file.Path import java.util.* @@ -57,7 +59,14 @@ class ImageMediaProcessService(private val imageMediaProcessorConfiguration: Ima filePath: Path, thumbnails: Path? ): ProcessedMediaPath = withContext(Dispatchers.IO + MDCContext()) { - val bufferedImage = ImageIO.read(filePath.inputStream()) + val read = ImageIO.read(filePath.inputStream()) + + val bufferedImage = BufferedImage(read.width, read.height, BufferedImage.TYPE_INT_RGB) + + val graphics = bufferedImage.createGraphics() + + graphics.drawImage(read, 0, 0, Color.BLACK, null) + val tempFileName = UUID.randomUUID().toString() val tempFile = Files.createTempFile(tempFileName, "tmp") @@ -67,18 +76,20 @@ class ImageMediaProcessService(private val imageMediaProcessorConfiguration: Ima tempThumbnailFile.outputStream().use { val write = ImageIO.write( if (thumbnails != null) { - Thumbnails.of(thumbnails.toFile()).size(width, height).asBufferedImage() + Thumbnails.of(thumbnails.toFile()) + .size(width, height) + .imageType(BufferedImage.TYPE_INT_RGB) + .asBufferedImage() } else { - Thumbnails.of(bufferedImage).size(width, height).asBufferedImage() + Thumbnails.of(bufferedImage) + .size(width, height) + .imageType(BufferedImage.TYPE_INT_RGB) + .asBufferedImage() }, convertType, it ) - if (write) { - tempThumbnailFile - } else { - null - } + tempThumbnailFile.takeIf { write } } } else { null diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/media/converter/movie/MovieMediaProcessService.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/media/converter/movie/MovieMediaProcessService.kt index 9b39e854..711cf29b 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/media/converter/movie/MovieMediaProcessService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/media/converter/movie/MovieMediaProcessService.kt @@ -34,6 +34,7 @@ class MovieMediaProcessService : MediaProcessService { TODO("Not yet implemented") } + @Suppress("LongMethod", "NestedBlockDepth", "CognitiveComplexMethod") override suspend fun process( mimeType: MimeType, fileName: String, diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/post/PostServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/post/PostServiceImpl.kt index 027b3274..2e2c6c0c 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/post/PostServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/post/PostServiceImpl.kt @@ -65,7 +65,9 @@ class PostServiceImpl( createdAt = Instant.now().toEpochMilli(), visibility = post.visibility, url = "${user.url}/posts/$id", - mediaIds = post.mediaIds + mediaIds = post.mediaIds, + replyId = post.repolyId, + repostId = post.repostId, ) return internalCreate(createPost, isLocal) } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionServiceImpl.kt index 056e5249..1ab87448 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/reaction/ReactionServiceImpl.kt @@ -6,6 +6,7 @@ import dev.usbharu.hideout.core.domain.model.reaction.Reaction import dev.usbharu.hideout.core.domain.model.reaction.ReactionRepository import dev.usbharu.hideout.core.query.ReactionQueryService import org.jetbrains.exposed.exceptions.ExposedSQLException +import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @@ -50,6 +51,6 @@ class ReactionServiceImpl( } companion object { - val LOGGER = LoggerFactory.getLogger(ReactionServiceImpl::class.java) + val LOGGER: Logger = LoggerFactory.getLogger(ReactionServiceImpl::class.java) } } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/resource/InMemoryCacheManager.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/resource/InMemoryCacheManager.kt index b48fadd7..6efbc980 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/resource/InMemoryCacheManager.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/resource/InMemoryCacheManager.kt @@ -18,7 +18,7 @@ class InMemoryCacheManager : CacheManager { keyMutex.withLock { cacheKey.filter { Instant.ofEpochMilli(it.value).plusSeconds(300) <= Instant.now() } - val cached = cacheKey.get(key) + val cached = cacheKey[key] if (cached == null) { needRunBlock = true cacheKey[key] = Instant.now().toEpochMilli() @@ -29,7 +29,13 @@ class InMemoryCacheManager : CacheManager { } } if (needRunBlock) { - val processed = block() + @Suppress("TooGenericExceptionCaught") + val processed = try { + block() + } catch (e: Exception) { + cacheKey.remove(key) + throw e + } if (cacheKey.containsKey(key)) { valueStore[key] = processed diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/timeline/ExposedGenerateTimelineService.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/timeline/ExposedGenerateTimelineService.kt index 5fc098b2..03d2f49a 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/timeline/ExposedGenerateTimelineService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/timeline/ExposedGenerateTimelineService.kt @@ -45,7 +45,7 @@ class ExposedGenerateTimelineService(private val statusQueryService: StatusQuery it[Timelines.postId], it[Timelines.replyId], it[Timelines.repostId], - it[Timelines.mediaIds].split(",").mapNotNull { it.toLongOrNull() } + it[Timelines.mediaIds].split(",").mapNotNull { s -> s.toLongOrNull() } ) } diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/user/UserService.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/user/UserService.kt index fdfb5bcf..fc34c36c 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/user/UserService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/user/UserService.kt @@ -3,7 +3,6 @@ package dev.usbharu.hideout.core.service.user import dev.usbharu.hideout.core.domain.model.user.User import org.springframework.stereotype.Service -@Suppress("TooManyFunctions") @Service interface UserService { diff --git a/src/main/kotlin/dev/usbharu/hideout/core/service/user/UserServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/core/service/user/UserServiceImpl.kt index 6ee4a53c..d2a2e45e 100644 --- a/src/main/kotlin/dev/usbharu/hideout/core/service/user/UserServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/core/service/user/UserServiceImpl.kt @@ -12,6 +12,7 @@ import dev.usbharu.hideout.core.service.instance.InstanceService import org.jetbrains.exposed.exceptions.ExposedSQLException import org.slf4j.LoggerFactory import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional import java.time.Instant @Service @@ -52,13 +53,15 @@ class UserServiceImpl( createdAt = Instant.now(), following = "$userUrl/following", followers = "$userUrl/followers", - keyId = "$userUrl#pubkey", - instance = null + keyId = "$userUrl#pubkey" ) return userRepository.save(userEntity) } + @Transactional override suspend fun createRemoteUser(user: RemoteUserCreateDto): User { + logger.info("START Create New remote user. name: {} url: {}", user.name, user.url) + @Suppress("TooGenericExceptionCaught") val instance = try { instanceService.fetchInstance(user.url, user.sharedInbox) } catch (e: Exception) { @@ -84,8 +87,11 @@ class UserServiceImpl( instance = instance?.id ) return try { - userRepository.save(userEntity) + val save = userRepository.save(userEntity) + logger.warn("SUCCESS Create New remote user. id: {} name: {} url: {}", userEntity.id, user.name, user.url) + save } catch (_: ExposedSQLException) { + logger.warn("FAILED User already exists. name: {} url: {}", user.name, user.url) userQueryService.findByUrl(user.url) } } diff --git a/src/main/kotlin/dev/usbharu/hideout/generate/JsonOrFormModelMethodProcessor.kt b/src/main/kotlin/dev/usbharu/hideout/generate/JsonOrFormModelMethodProcessor.kt index a4222880..f784a4d2 100644 --- a/src/main/kotlin/dev/usbharu/hideout/generate/JsonOrFormModelMethodProcessor.kt +++ b/src/main/kotlin/dev/usbharu/hideout/generate/JsonOrFormModelMethodProcessor.kt @@ -1,5 +1,6 @@ package dev.usbharu.hideout.generate +import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.core.MethodParameter import org.springframework.web.bind.support.WebDataBinderFactory @@ -49,6 +50,6 @@ class JsonOrFormModelMethodProcessor( } companion object { - val logger = LoggerFactory.getLogger(JsonOrFormModelMethodProcessor::class.java) + val logger: Logger = LoggerFactory.getLogger(JsonOrFormModelMethodProcessor::class.java) } } diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/exposedquery/StatusQueryServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/exposedquery/StatusQueryServiceImpl.kt index c300375a..8010405d 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/exposedquery/StatusQueryServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/exposedquery/StatusQueryServiceImpl.kt @@ -1,22 +1,20 @@ package dev.usbharu.hideout.mastodon.infrastructure.exposedquery +import dev.usbharu.hideout.core.domain.model.media.toMediaAttachments import dev.usbharu.hideout.core.infrastructure.exposedrepository.* -import dev.usbharu.hideout.core.service.media.FileType import dev.usbharu.hideout.domain.mastodon.model.generated.Account -import dev.usbharu.hideout.domain.mastodon.model.generated.MediaAttachment import dev.usbharu.hideout.domain.mastodon.model.generated.Status import dev.usbharu.hideout.mastodon.interfaces.api.status.StatusQuery import dev.usbharu.hideout.mastodon.query.StatusQueryService import org.jetbrains.exposed.sql.ResultRow -import org.jetbrains.exposed.sql.innerJoin import org.jetbrains.exposed.sql.select import org.springframework.stereotype.Repository import java.time.Instant +@Suppress("IncompleteDestructuring") @Repository class StatusQueryServiceImpl : StatusQueryService { - @Suppress("LongMethod") - override suspend fun findByPostIds(ids: List): List = findByPostIdsWithMediaAttachments(ids) + override suspend fun findByPostIds(ids: List): List = findByPostIdsWithMedia(ids) override suspend fun findByPostIdsWithMediaIds(statusQueries: List): List { val postIdSet = mutableSetOf() @@ -29,23 +27,7 @@ class StatusQueryServiceImpl : StatusQueryService { .associate { it[Posts.id] to toStatus(it) } val mediaMap = Media.select { Media.id inList mediaIdSet } .associate { - it[Media.id] to it.toMedia().let { - MediaAttachment( - id = it.id.toString(), - type = when (it.type) { - FileType.Image -> MediaAttachment.Type.image - FileType.Video -> MediaAttachment.Type.video - FileType.Audio -> MediaAttachment.Type.audio - FileType.Unknown -> MediaAttachment.Type.unknown - }, - url = it.url, - previewUrl = it.thumbnailUrl, - remoteUrl = it.remoteUrl, - description = "", - blurhash = it.blurHash, - textUrl = it.url - ) - } + it[Media.id] to it.toMedia().toMediaAttachments() } return statusQueries.mapNotNull { statusQuery -> @@ -58,18 +40,6 @@ class StatusQueryServiceImpl : StatusQueryService { } } - @Suppress("unused") - private suspend fun internalFindByPostIds(ids: List): List { - val pairs = Posts - .innerJoin(Users, onColumn = { Posts.userId }, otherColumn = { Users.id }) - .select { Posts.id inList ids } - .map { - toStatus(it) to it[Posts.repostId] - } - - return resolveReplyAndRepost(pairs) - } - private fun resolveReplyAndRepost(pairs: List>): List { val statuses = pairs.map { it.first } return pairs @@ -89,8 +59,7 @@ class StatusQueryServiceImpl : StatusQueryService { } } - @Suppress("FunctionMaxLength") - private suspend fun findByPostIdsWithMediaAttachments(ids: List): List { + private suspend fun findByPostIdsWithMedia(ids: List): List { val pairs = Posts .leftJoin(PostsMedia) .leftJoin(Users) @@ -100,24 +69,8 @@ class StatusQueryServiceImpl : StatusQueryService { .map { it.value } .map { toStatus(it.first()).copy( - mediaAttachments = it.mapNotNull { - it.toMediaOrNull()?.let { - MediaAttachment( - id = it.id.toString(), - type = when (it.type) { - FileType.Image -> MediaAttachment.Type.image - FileType.Video -> MediaAttachment.Type.video - FileType.Audio -> MediaAttachment.Type.audio - FileType.Unknown -> MediaAttachment.Type.unknown - }, - url = it.url, - previewUrl = it.thumbnailUrl, - remoteUrl = it.remoteUrl, - description = "", - blurhash = it.blurHash, - textUrl = it.url - ) - } + mediaAttachments = it.mapNotNull { resultRow -> + resultRow.toMediaOrNull()?.toMediaAttachments() } ) to it.first()[Posts.repostId] } diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/status/StatusesRequest.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/status/StatusesRequest.kt index 715a6819..98803f6b 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/status/StatusesRequest.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/status/StatusesRequest.kt @@ -1,7 +1,10 @@ package dev.usbharu.hideout.mastodon.interfaces.api.status import com.fasterxml.jackson.annotation.JsonProperty +import dev.usbharu.hideout.core.domain.model.post.Visibility +import dev.usbharu.hideout.domain.mastodon.model.generated.Status import dev.usbharu.hideout.domain.mastodon.model.generated.StatusesRequestPoll +import dev.usbharu.hideout.mastodon.interfaces.api.status.StatusesRequest.Visibility.* @Suppress("VariableNaming", "EnumEntryName") class StatusesRequest { @@ -67,7 +70,7 @@ class StatusesRequest { " scheduledAt=$scheduled_at)" } - @Suppress("EnumNaming") + @Suppress("EnumNaming", "EnumEntryNameCase") enum class Visibility { `public`, unlisted, @@ -75,3 +78,23 @@ class StatusesRequest { direct } } + +fun StatusesRequest.Visibility?.toPostVisibility(): Visibility { + return when (this) { + public -> Visibility.PUBLIC + unlisted -> Visibility.UNLISTED + private -> Visibility.FOLLOWERS + direct -> Visibility.DIRECT + null -> Visibility.PUBLIC + } +} + +fun StatusesRequest.Visibility?.toStatusVisibility(): Status.Visibility { + return when (this) { + public -> Status.Visibility.public + unlisted -> Status.Visibility.unlisted + private -> Status.Visibility.private + direct -> Status.Visibility.direct + null -> Status.Visibility.public + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/service/media/MediaApiServiceImpl.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/service/media/MediaApiServiceImpl.kt index 824a6f59..c87b6469 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/service/media/MediaApiServiceImpl.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/service/media/MediaApiServiceImpl.kt @@ -25,7 +25,6 @@ class MediaApiServiceImpl(private val mediaService: MediaService, private val tr type = type, url = uploadLocalMedia.url, previewUrl = uploadLocalMedia.thumbnailUrl, - remoteUrl = null, description = mediaRequest.description, blurhash = uploadLocalMedia.blurHash, textUrl = uploadLocalMedia.url diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/service/status/StatusesApiService.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/service/status/StatusesApiService.kt index 1cc448e0..198681ce 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/service/status/StatusesApiService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/service/status/StatusesApiService.kt @@ -3,16 +3,17 @@ package dev.usbharu.hideout.mastodon.service.status import dev.usbharu.hideout.application.external.Transaction import dev.usbharu.hideout.core.domain.exception.FailedToGetResourcesException import dev.usbharu.hideout.core.domain.model.media.MediaRepository -import dev.usbharu.hideout.core.domain.model.post.Visibility +import dev.usbharu.hideout.core.domain.model.media.toMediaAttachments import dev.usbharu.hideout.core.query.PostQueryService import dev.usbharu.hideout.core.query.UserQueryService -import dev.usbharu.hideout.core.service.media.FileType import dev.usbharu.hideout.core.service.post.PostCreateDto import dev.usbharu.hideout.core.service.post.PostService -import dev.usbharu.hideout.domain.mastodon.model.generated.MediaAttachment import dev.usbharu.hideout.domain.mastodon.model.generated.Status import dev.usbharu.hideout.mastodon.interfaces.api.status.StatusesRequest +import dev.usbharu.hideout.mastodon.interfaces.api.status.toPostVisibility +import dev.usbharu.hideout.mastodon.interfaces.api.status.toStatusVisibility import dev.usbharu.hideout.mastodon.service.account.AccountService +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.time.Instant @@ -34,39 +35,24 @@ class StatsesApiServiceImpl( private val transaction: Transaction ) : StatusesApiService { - @Suppress("LongMethod", "CyclomaticComplexMethod") override suspend fun postStatus( statusesRequest: StatusesRequest, userId: Long ): Status = transaction.transaction { - val visibility = when (statusesRequest.visibility) { - StatusesRequest.Visibility.public -> Visibility.PUBLIC - StatusesRequest.Visibility.unlisted -> Visibility.UNLISTED - StatusesRequest.Visibility.private -> Visibility.FOLLOWERS - StatusesRequest.Visibility.direct -> Visibility.DIRECT - null -> Visibility.PUBLIC - } + logger.debug("START create post by mastodon api. {}", statusesRequest) val post = postService.createLocal( PostCreateDto( text = statusesRequest.status.orEmpty(), overview = statusesRequest.spoiler_text, - visibility = visibility, - repolyId = statusesRequest.in_reply_to_id?.toLongOrNull(), + visibility = statusesRequest.visibility.toPostVisibility(), + repolyId = statusesRequest.in_reply_to_id?.toLong(), userId = userId, mediaIds = statusesRequest.media_ids.map { it.toLong() } ) ) val account = accountService.findById(userId) - val postVisibility = when (statusesRequest.visibility) { - StatusesRequest.Visibility.public -> Status.Visibility.public - StatusesRequest.Visibility.unlisted -> Status.Visibility.unlisted - StatusesRequest.Visibility.private -> Status.Visibility.private - StatusesRequest.Visibility.direct -> Status.Visibility.direct - null -> Status.Visibility.public - } - val replyUser = if (post.replyId != null) { try { userQueryService.findById(postQueryService.findById(post.replyId).userId).id @@ -81,21 +67,7 @@ class StatsesApiServiceImpl( val mediaAttachment = post.mediaIds.map { mediaId -> mediaRepository.findById(mediaId) }.map { - MediaAttachment( - id = it.id.toString(), - type = when (it.type) { - FileType.Image -> MediaAttachment.Type.image - FileType.Video -> MediaAttachment.Type.video - FileType.Audio -> MediaAttachment.Type.audio - FileType.Unknown -> MediaAttachment.Type.unknown - }, - url = it.url, - previewUrl = it.thumbnailUrl, - remoteUrl = it.remoteUrl, - description = "", - blurhash = it.blurHash, - textUrl = it.url - ) + it.toMediaAttachments() } Status( @@ -104,7 +76,7 @@ class StatsesApiServiceImpl( createdAt = Instant.ofEpochMilli(post.createdAt).toString(), account = account, content = post.text, - visibility = postVisibility, + visibility = statusesRequest.visibility.toStatusVisibility(), sensitive = post.sensitive, spoilerText = post.overview.orEmpty(), mediaAttachments = mediaAttachment, @@ -122,4 +94,8 @@ class StatsesApiServiceImpl( editedAt = null, ) } + + companion object { + private val logger = LoggerFactory.getLogger(StatusesApiService::class.java) + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/util/AcctUtil.kt b/src/main/kotlin/dev/usbharu/hideout/util/AcctUtil.kt index d8e7b246..a0f1a09b 100644 --- a/src/main/kotlin/dev/usbharu/hideout/util/AcctUtil.kt +++ b/src/main/kotlin/dev/usbharu/hideout/util/AcctUtil.kt @@ -35,7 +35,7 @@ object AcctUtil { } else -> { - throw IllegalArgumentException("Invalid acct.(Too many @)") + throw IllegalArgumentException("Invalid acct. (Too many @)") } } } diff --git a/src/main/kotlin/dev/usbharu/hideout/util/HttpUtil.kt b/src/main/kotlin/dev/usbharu/hideout/util/HttpUtil.kt index 882a211f..66d321fc 100644 --- a/src/main/kotlin/dev/usbharu/hideout/util/HttpUtil.kt +++ b/src/main/kotlin/dev/usbharu/hideout/util/HttpUtil.kt @@ -3,10 +3,10 @@ package dev.usbharu.hideout.util import io.ktor.http.* object HttpUtil { - val ContentType.Application.Activity: ContentType + val Activity: ContentType get() = ContentType("application", "activity+json") - val ContentType.Application.JsonLd: ContentType + val JsonLd: ContentType get() { return ContentType( contentType = "application", diff --git a/src/main/kotlin/dev/usbharu/hideout/util/SpringSecurityKotlinDslExtension.kt b/src/main/kotlin/dev/usbharu/hideout/util/SpringSecurityKotlinDslExtension.kt new file mode 100644 index 00000000..52a2f486 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/util/SpringSecurityKotlinDslExtension.kt @@ -0,0 +1,12 @@ +package dev.usbharu.hideout.util + +import org.springframework.security.authorization.AuthorizationManager +import org.springframework.security.config.annotation.web.AuthorizeHttpRequestsDsl +import org.springframework.security.web.access.intercept.RequestAuthorizationContext + +fun AuthorizeHttpRequestsDsl.hasScope(scope: String): AuthorizationManager = + hasAuthority("SCOPE_$scope") + +@Suppress("SpreadOperator") +fun AuthorizeHttpRequestsDsl.hasAnyScope(vararg scopes: String): AuthorizationManager = + hasAnyAuthority(*scopes.map { "SCOPE_$it" }.toTypedArray()) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3762c6c1..daff34db 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,6 @@ hideout: url: "https://test-hideout.usbharu.dev" - use-mongodb: true + use-mongodb: false security: jwt: generate: true @@ -18,18 +18,16 @@ spring: WRITE_DATES_AS_TIMESTAMPS: false default-property-inclusion: always datasource: - hikari: - transaction-isolation: "TRANSACTION_SERIALIZABLE" driver-class-name: org.postgresql.Driver url: "jdbc:postgresql:hideout2" username: "postgres" password: "" - data: - mongodb: - auto-index-creation: true - host: localhost - port: 27017 - database: hideout + # data: + # mongodb: + # auto-index-creation: true + # host: localhost + # port: 27017 + # database: hideout # username: hideoutuser # password: hideoutpass servlet: diff --git a/src/main/resources/db/migration/V1__Init_DB.sql b/src/main/resources/db/migration/V1__Init_DB.sql index 15a61994..e0188588 100644 --- a/src/main/resources/db/migration/V1__Init_DB.sql +++ b/src/main/resources/db/migration/V1__Init_DB.sql @@ -3,9 +3,9 @@ create table if not exists instance id bigint primary key, "name" varchar(1000) not null, description varchar(5000) not null, - url varchar(255) not null, + url varchar(255) not null unique, icon_url varchar(255) not null, - shared_inbox varchar(255) null, + shared_inbox varchar(255) null unique, software varchar(255) not null, version varchar(255) not null, is_blocked boolean not null, @@ -21,9 +21,9 @@ create table if not exists users screen_name varchar(300) not null, description varchar(10000) not null, password varchar(255) null, - inbox varchar(1000) not null, - outbox varchar(1000) not null, - url varchar(1000) not null, + inbox varchar(1000) not null unique, + outbox varchar(1000) not null unique, + url varchar(1000) not null unique, public_key varchar(10000) not null, private_key varchar(10000) null, created_at bigint not null, @@ -31,6 +31,7 @@ create table if not exists users "following" varchar(1000) null, followers varchar(1000) null, "instance" bigint null, + unique (name, domain), constraint fk_users_instance__id foreign key ("instance") references instance (id) on delete restrict on update restrict ); create table if not exists follow_requests @@ -45,9 +46,9 @@ create table if not exists media ( id bigint primary key, "name" varchar(255) not null, - url varchar(255) not null, - remote_url varchar(255) null, - thumbnail_url varchar(255) null, + url varchar(255) not null unique, + remote_url varchar(255) null unique, + thumbnail_url varchar(255) null unique, "type" int not null, blurhash varchar(255) null, mime_type varchar(255) not null, @@ -73,7 +74,7 @@ create table if not exists posts repost_id bigint null, reply_id bigint null, "sensitive" boolean default false not null, - ap_id varchar(100) not null + ap_id varchar(100) not null unique ); alter table posts add constraint fk_posts_userid__id foreign key (user_id) references users (id) on delete restrict on update restrict; diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/DeleteSerializeTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/DeleteSerializeTest.kt index 4f190250..21de8c61 100644 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/DeleteSerializeTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/DeleteSerializeTest.kt @@ -46,7 +46,6 @@ class DeleteSerializeTest { val readValue = objectMapper.readValue(json) val expected = Delete( - name = null, actor = "https://misskey.usbharu.dev/users/97ws8y3rj6", id = "https://misskey.usbharu.dev/4b5b6ed5-9269-45f3-8403-cba1e74b4b69", `object` = Tombstone( @@ -61,7 +60,6 @@ class DeleteSerializeTest { @Test fun シリアライズできる() { val delete = Delete( - name = null, actor = "https://misskey.usbharu.dev/users/97ws8y3rj6", id = "https://misskey.usbharu.dev/4b5b6ed5-9269-45f3-8403-cba1e74b4b69", `object` = Tombstone( @@ -75,7 +73,7 @@ class DeleteSerializeTest { val actual = objectMapper.writeValueAsString(delete) val expected = - """{"type":"Delete","actor":"https://misskey.usbharu.dev/users/97ws8y3rj6","id":"https://misskey.usbharu.dev/4b5b6ed5-9269-45f3-8403-cba1e74b4b69","object":{"type":"Tombstone","name":"Tombstone","id":"https://misskey.usbharu.dev/notes/9lkwqnwqk9"},"published":"2023-11-02T15:30:34.160Z"}""" + """{"type":"Delete","actor":"https://misskey.usbharu.dev/users/97ws8y3rj6","id":"https://misskey.usbharu.dev/4b5b6ed5-9269-45f3-8403-cba1e74b4b69","object":{"type":"Tombstone","id":"https://misskey.usbharu.dev/notes/9lkwqnwqk9"},"published":"2023-11-02T15:30:34.160Z"}""" assertEquals(expected, actual) } } diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/KeySerializeTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/KeySerializeTest.kt new file mode 100644 index 00000000..d6e1be7a --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/KeySerializeTest.kt @@ -0,0 +1,24 @@ +package dev.usbharu.hideout.activitypub.domain.model + +import com.fasterxml.jackson.module.kotlin.readValue +import dev.usbharu.hideout.application.config.ActivityPubConfig +import org.junit.jupiter.api.Test + +class KeySerializeTest { + @Test + fun Keyのデシリアライズができる() { + //language=JSON + val trimIndent = """ + { + "id": "https://mastodon.social/users/Gargron#main-key", + "owner": "https://mastodon.social/users/Gargron", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvXc4vkECU2/CeuSo1wtn\nFoim94Ne1jBMYxTZ9wm2YTdJq1oiZKif06I2fOqDzY/4q/S9uccrE9Bkajv1dnkO\nVm31QjWlhVpSKynVxEWjVBO5Ienue8gND0xvHIuXf87o61poqjEoepvsQFElA5ym\novljWGSA/jpj7ozygUZhCXtaS2W5AD5tnBQUpcO0lhItYPYTjnmzcc4y2NbJV8hz\n2s2G8qKv8fyimE23gY1XrPJg+cRF+g4PqFXujjlJ7MihD9oqtLGxbu7o1cifTn3x\nBfIdPythWu5b4cujNsB3m3awJjVmx+MHQ9SugkSIYXV0Ina77cTNS0M2PYiH1PFR\nTwIDAQAB\n-----END PUBLIC KEY-----\n" + } + """.trimIndent() + + val objectMapper = ActivityPubConfig().objectMapper() + + val readValue = objectMapper.readValue(trimIndent) + + } +} diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/NoteSerializeTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/NoteSerializeTest.kt index 1b05eef1..9e1397a9 100644 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/NoteSerializeTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/NoteSerializeTest.kt @@ -10,7 +10,6 @@ class NoteSerializeTest { @Test fun Noteのシリアライズができる() { val note = Note( - name = "Note", id = "https://example.com", attributedTo = "https://example.com/actor", content = "Hello", @@ -22,7 +21,7 @@ class NoteSerializeTest { val writeValueAsString = objectMapper.writeValueAsString(note) assertEquals( - "{\"type\":\"Note\",\"name\":\"Note\",\"id\":\"https://example.com\",\"attributedTo\":\"https://example.com/actor\",\"content\":\"Hello\",\"published\":\"2023-05-20T10:28:17.308Z\",\"sensitive\":false}", + """{"type":"Note","id":"https://example.com","attributedTo":"https://example.com/actor","content":"Hello","published":"2023-05-20T10:28:17.308Z","sensitive":false}""", writeValueAsString ) } @@ -65,7 +64,6 @@ class NoteSerializeTest { val readValue = objectMapper.readValue(json) val note = Note( - name = "", id = "https://misskey.usbharu.dev/notes/9f2i9cm88e", type = listOf("Note"), attributedTo = "https://misskey.usbharu.dev/users/97ws8y3rj6", @@ -77,7 +75,6 @@ class NoteSerializeTest { inReplyTo = "https://calckey.jp/notes/9f2i7ymf1d", attachment = emptyList() ) - note.name = null assertEquals(note, readValue) } } diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/PersonSerializeTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/PersonSerializeTest.kt new file mode 100644 index 00000000..9b344337 --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/PersonSerializeTest.kt @@ -0,0 +1,75 @@ +package dev.usbharu.hideout.activitypub.domain.model + +import com.fasterxml.jackson.module.kotlin.readValue +import dev.usbharu.hideout.application.config.ActivityPubConfig +import org.junit.jupiter.api.Test + +class PersonSerializeTest { + @Test + fun MastodonのPersonのデシリアライズができる() { + val personString = """ + { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + ], + "id": "https://mastodon.social/users/Gargron", + "type": "Person", + "following": "https://mastodon.social/users/Gargron/following", + "followers": "https://mastodon.social/users/Gargron/followers", + "inbox": "https://mastodon.social/users/Gargron/inbox", + "outbox": "https://mastodon.social/users/Gargron/outbox", + "featured": "https://mastodon.social/users/Gargron/collections/featured", + "featuredTags": "https://mastodon.social/users/Gargron/collections/tags", + "preferredUsername": "Gargron", + "name": "Eugen Rochko", + "summary": "\u003cp\u003eFounder, CEO and lead developer \u003cspan class=\"h-card\"\u003e\u003ca href=\"https://mastodon.social/@Mastodon\" class=\"u-url mention\"\u003e@\u003cspan\u003eMastodon\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e, Germany.\u003c/p\u003e", + "url": "https://mastodon.social/@Gargron", + "manuallyApprovesFollowers": false, + "discoverable": true, + "published": "2016-03-16T00:00:00Z", + "devices": "https://mastodon.social/users/Gargron/collections/devices", + "alsoKnownAs": [ + "https://tooting.ai/users/Gargron" + ], + "publicKey": { + "id": "https://mastodon.social/users/Gargron#main-key", + "owner": "https://mastodon.social/users/Gargron", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvXc4vkECU2/CeuSo1wtn\nFoim94Ne1jBMYxTZ9wm2YTdJq1oiZKif06I2fOqDzY/4q/S9uccrE9Bkajv1dnkO\nVm31QjWlhVpSKynVxEWjVBO5Ienue8gND0xvHIuXf87o61poqjEoepvsQFElA5ym\novljWGSA/jpj7ozygUZhCXtaS2W5AD5tnBQUpcO0lhItYPYTjnmzcc4y2NbJV8hz\n2s2G8qKv8fyimE23gY1XrPJg+cRF+g4PqFXujjlJ7MihD9oqtLGxbu7o1cifTn3x\nBfIdPythWu5b4cujNsB3m3awJjVmx+MHQ9SugkSIYXV0Ina77cTNS0M2PYiH1PFR\nTwIDAQAB\n-----END PUBLIC KEY-----\n" + }, + "tag": [], + "attachment": [ + { + "type": "PropertyValue", + "name": "Patreon", + "value": "\u003ca href=\"https://www.patreon.com/mastodon\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\"\u003e\u003cspan class=\"invisible\"\u003ehttps://www.\u003c/span\u003e\u003cspan class=\"\"\u003epatreon.com/mastodon\u003c/span\u003e\u003cspan class=\"invisible\"\u003e\u003c/span\u003e\u003c/a\u003e" + }, + { + "type": "PropertyValue", + "name": "GitHub", + "value": "\u003ca href=\"https://github.com/Gargron\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\"\u003e\u003cspan class=\"invisible\"\u003ehttps://\u003c/span\u003e\u003cspan class=\"\"\u003egithub.com/Gargron\u003c/span\u003e\u003cspan class=\"invisible\"\u003e\u003c/span\u003e\u003c/a\u003e" + } + ], + "endpoints": { + "sharedInbox": "https://mastodon.social/inbox" + }, + "icon": { + "type": "Image", + "mediaType": "image/jpeg", + "url": "https://files.mastodon.social/accounts/avatars/000/000/001/original/dc4286ceb8fab734.jpg" + }, + "image": { + "type": "Image", + "mediaType": "image/jpeg", + "url": "https://files.mastodon.social/accounts/headers/000/000/001/original/3b91c9965d00888b.jpeg" + } + } + + """.trimIndent() + + + val objectMapper = ActivityPubConfig().objectMapper() + + val readValue = objectMapper.readValue(personString) + } +} diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/UndoTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/UndoTest.kt index 97ba9bc4..ea279694 100644 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/UndoTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/UndoTest.kt @@ -1,8 +1,8 @@ package dev.usbharu.hideout.activitypub.domain.model +import dev.usbharu.hideout.application.config.ActivityPubConfig import org.intellij.lang.annotations.Language import org.junit.jupiter.api.Test -import utils.JsonObjectMapper import java.time.Clock import java.time.Instant import java.time.ZoneId @@ -12,18 +12,16 @@ class UndoTest { fun Undoのシリアライズができる() { val undo = Undo( emptyList(), - "Undo Follow", "https://follower.example.com/", "https://follower.example.com/undo/1", Follow( emptyList(), - null, "https://follower.example.com/users/", actor = "https://follower.exaple.com/users/1" ), - Instant.now(Clock.tickMillis(ZoneId.systemDefault())) + Instant.now(Clock.tickMillis(ZoneId.systemDefault())).toString() ) - val writeValueAsString = JsonObjectMapper.objectMapper.writeValueAsString(undo) + val writeValueAsString = ActivityPubConfig().objectMapper().writeValueAsString(undo) println(writeValueAsString) } @@ -70,7 +68,7 @@ class UndoTest { """.trimIndent() - val undo = JsonObjectMapper.objectMapper.readValue(json, Undo::class.java) + val undo = ActivityPubConfig().objectMapper().readValue(json, Undo::class.java) println(undo) } } diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/ObjectSerializeTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/ObjectSerializeTest.kt index 77f2579b..41bc114e 100644 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/ObjectSerializeTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/activitypub/domain/model/objects/ObjectSerializeTest.kt @@ -16,10 +16,7 @@ class ObjectSerializeTest { val readValue = objectMapper.readValue(json) val expected = Object( - listOf("Object"), - null, - null, - null + listOf("Object") ) assertEquals(expected, readValue) } @@ -34,10 +31,7 @@ class ObjectSerializeTest { val readValue = objectMapper.readValue(json) val expected = Object( - listOf("Hoge", "Object"), - null, - null, - null + listOf("Hoge", "Object") ) assertEquals(expected, readValue) @@ -53,10 +47,7 @@ class ObjectSerializeTest { val readValue = objectMapper.readValue(json) val expected = Object( - emptyList(), - null, - null, - null + emptyList() ) assertEquals(expected, readValue) diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/actor/UserAPControllerImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/actor/UserAPControllerImplTest.kt index 9520eac2..5d7bab4d 100644 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/actor/UserAPControllerImplTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/actor/UserAPControllerImplTest.kt @@ -49,16 +49,13 @@ class UserAPControllerImplTest { outbox = "https://example.com/users/hoge/outbox", url = "https://example.com/users/hoge", icon = Image( - name = "icon", mediaType = "image/jpeg", url = "https://example.com/users/hoge/icon.jpg" ), publicKey = Key( - name = "Public Key", id = "https://example.com/users/hoge#pubkey", owner = "https://example.com/users/hoge", - publicKeyPem = "-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----", - type = emptyList() + publicKeyPem = "-----BEGIN PUBLIC KEY-----...-----END PUBLIC KEY-----" ), endpoints = mapOf("sharedInbox" to "https://example.com/inbox"), followers = "https://example.com/users/hoge/followers", diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/inbox/InboxControllerImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/inbox/InboxControllerImplTest.kt index a91e5b7f..b1f0b0cc 100644 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/inbox/InboxControllerImplTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/inbox/InboxControllerImplTest.kt @@ -1,11 +1,9 @@ package dev.usbharu.hideout.activitypub.interfaces.api.inbox import dev.usbharu.hideout.activitypub.domain.exception.JsonParseException -import dev.usbharu.hideout.activitypub.interfaces.api.common.ActivityPubStringResponse import dev.usbharu.hideout.activitypub.service.common.APService import dev.usbharu.hideout.activitypub.service.common.ActivityType import dev.usbharu.hideout.core.domain.exception.FailedToGetResourcesException -import io.ktor.http.* import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -13,10 +11,7 @@ import org.junit.jupiter.api.extension.ExtendWith import org.mockito.InjectMocks import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.doThrow -import org.mockito.kotlin.eq -import org.mockito.kotlin.whenever +import org.mockito.kotlin.* import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get @@ -45,16 +40,21 @@ class InboxControllerImplTest { val json = """{"type":"Follow"}""" whenever(apService.parseActivity(eq(json))).doReturn(ActivityType.Follow) - whenever(apService.processActivity(eq(json), eq(ActivityType.Follow))).doReturn( - ActivityPubStringResponse( - HttpStatusCode.Accepted, "" + whenever( + apService.processActivity( + eq(json), + eq(ActivityType.Follow), + any(), + any() + ) - ) + ).doReturn(Unit) mockMvc .post("/inbox") { content = json contentType = MediaType.APPLICATION_JSON + header("Signature", "") } .asyncDispatch() .andExpect { @@ -72,6 +72,7 @@ class InboxControllerImplTest { .post("/inbox") { content = json contentType = MediaType.APPLICATION_JSON + header("Signature", "") } .asyncDispatch() .andExpect { @@ -87,7 +88,9 @@ class InboxControllerImplTest { whenever( apService.processActivity( eq(json), - eq(ActivityType.Follow) + eq(ActivityType.Follow), + any(), + any() ) ).doThrow(FailedToGetResourcesException::class) @@ -95,6 +98,7 @@ class InboxControllerImplTest { .post("/inbox") { content = json contentType = MediaType.APPLICATION_JSON + header("Signature", "") } .asyncDispatch() .andExpect { @@ -114,16 +118,15 @@ class InboxControllerImplTest { val json = """{"type":"Follow"}""" whenever(apService.parseActivity(eq(json))).doReturn(ActivityType.Follow) - whenever(apService.processActivity(eq(json), eq(ActivityType.Follow))).doReturn( - ActivityPubStringResponse( - HttpStatusCode.Accepted, "" - ) + whenever(apService.processActivity(eq(json), eq(ActivityType.Follow), any(), any())).doReturn( + Unit ) mockMvc .post("/users/hoge/inbox") { content = json contentType = MediaType.APPLICATION_JSON + header("Signature", "") } .asyncDispatch() .andExpect { @@ -141,6 +144,7 @@ class InboxControllerImplTest { .post("/users/hoge/inbox") { content = json contentType = MediaType.APPLICATION_JSON + header("Signature", "") } .asyncDispatch() .andExpect { @@ -156,7 +160,9 @@ class InboxControllerImplTest { whenever( apService.processActivity( eq(json), - eq(ActivityType.Follow) + eq(ActivityType.Follow), + any(), + any() ) ).doThrow(FailedToGetResourcesException::class) @@ -164,6 +170,7 @@ class InboxControllerImplTest { .post("/users/hoge/inbox") { content = json contentType = MediaType.APPLICATION_JSON + header("Signature", "") } .asyncDispatch() .andExpect { diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/note/NoteApControllerImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/note/NoteApControllerImplTest.kt index c337e770..bfa7168e 100644 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/note/NoteApControllerImplTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/activitypub/interfaces/api/note/NoteApControllerImplTest.kt @@ -53,7 +53,6 @@ class NoteApControllerImplTest { fun `postAP 匿名で取得できる`() = runTest { SecurityContextHolder.clearContext() val note = Note( - name = "Note", id = "https://example.com/users/hoge/posts/1234", attributedTo = "https://example.com/users/hoge", content = "Hello", @@ -90,7 +89,6 @@ class NoteApControllerImplTest { @Test fun `postAP 認証に成功している場合userIdがnullでない`() = runTest { val note = Note( - name = "Note", id = "https://example.com/users/hoge/posts/1234", attributedTo = "https://example.com/users/hoge", content = "Hello", diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/accept/APAcceptServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/accept/APAcceptServiceImplTest.kt deleted file mode 100644 index 77b3f74d..00000000 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/accept/APAcceptServiceImplTest.kt +++ /dev/null @@ -1,110 +0,0 @@ -package dev.usbharu.hideout.activitypub.service.activity.accept - -import dev.usbharu.hideout.activitypub.domain.exception.IllegalActivityPubObjectException -import dev.usbharu.hideout.activitypub.domain.model.Accept -import dev.usbharu.hideout.activitypub.domain.model.Follow -import dev.usbharu.hideout.activitypub.domain.model.Like -import dev.usbharu.hideout.activitypub.interfaces.api.common.ActivityPubStringResponse -import dev.usbharu.hideout.core.query.FollowerQueryService -import dev.usbharu.hideout.core.query.UserQueryService -import dev.usbharu.hideout.core.service.user.UserService -import io.ktor.http.* -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import org.mockito.kotlin.* -import utils.TestTransaction -import utils.UserBuilder - -class APAcceptServiceImplTest { - - @Test - fun `receiveAccept 正常なAcceptを処理できる`() = runTest { - val actor = "https://example.com" - val follower = "https://follower.example.com" - val targetUser = UserBuilder.localUserOf() - val followerUser = UserBuilder.localUserOf() - val userQueryService = mock { - onBlocking { findByUrl(eq(actor)) } doReturn targetUser - onBlocking { findByUrl(eq(follower)) } doReturn followerUser - } - val followerQueryService = mock { - onBlocking { alreadyFollow(eq(targetUser.id), eq(followerUser.id)) } doReturn false - } - val userService = mock() - val apAcceptServiceImpl = - APAcceptServiceImpl(userService, userQueryService, followerQueryService, TestTransaction) - - val accept = Accept( - name = "Accept", - `object` = Follow( - name = "", - `object` = actor, - actor = follower - ), - actor = actor - ) - - - val actual = apAcceptServiceImpl.receiveAccept(accept) - assertEquals(ActivityPubStringResponse(HttpStatusCode.OK, "accepted"), actual) - verify(userService, times(1)).follow(eq(targetUser.id), eq(followerUser.id)) - } - - @Test - fun `receiveAccept 既にフォローしている場合は無視する`() = runTest { - - val actor = "https://example.com" - val follower = "https://follower.example.com" - val targetUser = UserBuilder.localUserOf() - val followerUser = UserBuilder.localUserOf() - val userQueryService = mock { - onBlocking { findByUrl(eq(actor)) } doReturn targetUser - onBlocking { findByUrl(eq(follower)) } doReturn followerUser - } - val followerQueryService = mock { - onBlocking { alreadyFollow(eq(targetUser.id), eq(followerUser.id)) } doReturn true - } - val userService = mock() - val apAcceptServiceImpl = - APAcceptServiceImpl(userService, userQueryService, followerQueryService, TestTransaction) - - val accept = Accept( - name = "Accept", - `object` = Follow( - name = "", - `object` = actor, - actor = follower - ), - actor = actor - ) - - - val actual = apAcceptServiceImpl.receiveAccept(accept) - assertEquals(ActivityPubStringResponse(HttpStatusCode.OK, "accepted"), actual) - verify(userService, times(0)).follow(eq(targetUser.id), eq(followerUser.id)) - } - - @Test - fun `revieveAccept AcceptのobjectのtypeがFollow以外の場合IllegalActivityPubObjectExceptionがthrowされる`() = - runTest { - val accept = Accept( - name = "Accept", - `object` = Like( - name = "Like", - actor = "actor", - id = "https://example.com", - `object` = "https://example.com", - content = "aaaa" - ), - actor = "https://example.com" - ) - - val apAcceptServiceImpl = APAcceptServiceImpl(mock(), mock(), mock(), TestTransaction) - - assertThrows { - apAcceptServiceImpl.receiveAccept(accept) - } - } -} diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/create/APCreateServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/create/APCreateServiceImplTest.kt deleted file mode 100644 index 87e66044..00000000 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/create/APCreateServiceImplTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -package dev.usbharu.hideout.activitypub.service.activity.create - -import dev.usbharu.hideout.activitypub.domain.exception.IllegalActivityPubObjectException -import dev.usbharu.hideout.activitypub.domain.model.Create -import dev.usbharu.hideout.activitypub.domain.model.Like -import dev.usbharu.hideout.activitypub.domain.model.Note -import dev.usbharu.hideout.activitypub.interfaces.api.common.ActivityPubStringResponse -import dev.usbharu.hideout.activitypub.service.objects.note.APNoteService -import io.ktor.http.* -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import org.mockito.kotlin.* -import utils.TestTransaction - -class APCreateServiceImplTest { - - @Test - fun `receiveCreate 正常なCreateを処理できる`() = runTest { - val create = Create( - name = "Create", - `object` = Note( - name = "Note", - id = "https://example.com/note", - attributedTo = "https://example.com/actor", - content = "Hello World", - published = "Date: Wed, 21 Oct 2015 07:28:00 GMT" - ), - actor = "https://example.com/actor", - id = "https://example.com/create", - ) - - val apNoteService = mock() - val apCreateServiceImpl = APCreateServiceImpl(apNoteService, TestTransaction) - - val actual = ActivityPubStringResponse(HttpStatusCode.OK, "Created") - - val receiveCreate = apCreateServiceImpl.receiveCreate(create) - verify(apNoteService, times(1)).fetchNote(any(), anyOrNull()) - assertEquals(actual, receiveCreate) - } - - @Test - fun `reveiveCreate CreateのobjectのtypeがNote以外の場合IllegalActivityPubObjectExceptionがthrowされる`() = runTest { - val create = Create( - name = "Create", - `object` = Like( - name = "Like", - id = "https://example.com/note", - actor = "https://example.com/actor", - `object` = "https://example.com/create", - content = "aaa" - ), - actor = "https://example.com/actor", - id = "https://example.com/create", - ) - - val apCreateServiceImpl = APCreateServiceImpl(mock(), TestTransaction) - assertThrows { - apCreateServiceImpl.receiveCreate(create) - } - } -} diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/create/ApSendCreateServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/create/ApSendCreateServiceImplTest.kt index 47b5671e..01975882 100644 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/create/ApSendCreateServiceImplTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/create/ApSendCreateServiceImplTest.kt @@ -52,7 +52,6 @@ class ApSendCreateServiceImplTest { val post = PostBuilder.of() val user = UserBuilder.localUserOf(id = post.userId) val note = Note( - name = "Post", id = post.apId, attributedTo = user.url, content = post.text, diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APReceiveFollowServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APReceiveFollowServiceImplTest.kt deleted file mode 100644 index d80183dd..00000000 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APReceiveFollowServiceImplTest.kt +++ /dev/null @@ -1,176 +0,0 @@ -@file:OptIn(ExperimentalCoroutinesApi::class) -@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") - -package dev.usbharu.hideout.activitypub.service.activity.follow - -import dev.usbharu.hideout.activitypub.domain.model.Follow -import dev.usbharu.hideout.activitypub.domain.model.Image -import dev.usbharu.hideout.activitypub.domain.model.Key -import dev.usbharu.hideout.activitypub.domain.model.Person -import dev.usbharu.hideout.activitypub.service.objects.user.APUserService -import dev.usbharu.hideout.application.config.ApplicationConfig -import dev.usbharu.hideout.application.config.CharacterLimit -import dev.usbharu.hideout.core.domain.model.post.Post -import dev.usbharu.hideout.core.domain.model.user.User -import dev.usbharu.hideout.core.external.job.ReceiveFollowJob -import dev.usbharu.hideout.core.query.UserQueryService -import dev.usbharu.hideout.core.service.job.JobQueueParentService -import dev.usbharu.hideout.core.service.user.UserService -import kjob.core.dsl.ScheduleContext -import kjob.core.job.JobProps -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.Json -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import org.mockito.ArgumentMatchers.anyString -import org.mockito.kotlin.* -import utils.JsonObjectMapper.objectMapper -import utils.TestTransaction -import java.net.URL -import java.time.Instant - -class APReceiveFollowServiceImplTest { - - val userBuilder = User.UserBuilder(CharacterLimit(), ApplicationConfig(URL("https://example.com"))) - val postBuilder = Post.PostBuilder(CharacterLimit()) - - @Test - fun `receiveFollow フォロー受付処理`() = runTest { - val jobQueueParentService = mock { - onBlocking { schedule(eq(ReceiveFollowJob), any()) } doReturn Unit - } - val activityPubFollowService = - APReceiveFollowServiceImpl( - jobQueueParentService, - objectMapper - ) - activityPubFollowService.receiveFollow( - Follow( - emptyList(), - "Follow", - "https://example.com", - "https://follower.example.com" - ) - ) - verify(jobQueueParentService, times(1)).schedule(eq(ReceiveFollowJob), any()) - argumentCaptor.(ReceiveFollowJob) -> Unit> { - verify(jobQueueParentService, times(1)).schedule(eq(ReceiveFollowJob), capture()) - val scheduleContext = ScheduleContext(Json) - firstValue.invoke(scheduleContext, ReceiveFollowJob) - val actor = scheduleContext.props.props[ReceiveFollowJob.actor.name] - val targetActor = scheduleContext.props.props[ReceiveFollowJob.targetActor.name] - val follow = scheduleContext.props.props[ReceiveFollowJob.follow.name] as String - assertEquals("https://follower.example.com", actor) - assertEquals("https://example.com", targetActor) - //language=JSON - assertEquals( - Json.parseToJsonElement( - """{ - "type": "Follow", - "name": "Follow", - "actor": "https://follower.example.com", - "object": "https://example.com" - -}""" - ), - Json.parseToJsonElement(follow) - ) - } - } - - @Test - fun `receiveFollowJob フォロー受付処理のJob`() = runTest { - val person = Person( - type = emptyList(), - name = "follower", - id = "https://follower.example.com", - preferredUsername = "followerUser", - summary = "This user is follower user.", - inbox = "https://follower.example.com/inbox", - outbox = "https://follower.example.com/outbox", - url = "https://follower.example.com", - icon = Image( - type = emptyList(), - name = "https://follower.example.com/image", - mediaType = "image/png", - url = "https://follower.example.com/image" - ), - publicKey = Key( - type = emptyList(), - name = "Public Key", - id = "https://follower.example.com#main-key", - owner = "https://follower.example.com", - publicKeyPem = "BEGIN PUBLIC KEY...END PUBLIC KEY", - ), - followers = "", - following = "" - - ) - val apUserService = mock { - onBlocking { fetchPerson(anyString(), any()) } doReturn person - } - val userQueryService = mock { - onBlocking { findByUrl(eq("https://example.com")) } doReturn - userBuilder.of( - id = 1L, - name = "test", - domain = "example.com", - screenName = "testUser", - description = "This user is test user.", - inbox = "https://example.com/inbox", - outbox = "https://example.com/outbox", - url = "https://example.com", - publicKey = "", - password = "a", - privateKey = "a", - createdAt = Instant.now(), - keyId = "a" - ) - onBlocking { findByUrl(eq("https://follower.example.com")) } doReturn - userBuilder.of( - id = 2L, - name = "follower", - domain = "follower.example.com", - screenName = "followerUser", - description = "This user is test follower user.", - inbox = "https://follower.example.com/inbox", - outbox = "https://follower.example.com/outbox", - url = "https://follower.example.com", - publicKey = "", - createdAt = Instant.now(), - keyId = "a" - ) - } - - val userService = mock { - onBlocking { followRequest(any(), any()) } doReturn false - } - val activityPubFollowService = - APReceiveFollowJobServiceImpl( - apUserService, - userQueryService, - mock(), - userService, - objectMapper, - TestTransaction - ) - activityPubFollowService.receiveFollowJob( - JobProps( - data = mapOf( - ReceiveFollowJob.actor.name to "https://follower.example.com", - ReceiveFollowJob.targetActor.name to "https://example.com", - //language=JSON - ReceiveFollowJob.follow.name to """{ - "type": "Follow", - "name": "Follow", - "object": "https://example.com", - "actor": "https://follower.example.com", - "@context": null -}""" - ), - json = Json - ) - ) - } -} diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APSendFollowServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APSendFollowServiceImplTest.kt index 6ce3a084..0fe5f689 100644 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APSendFollowServiceImplTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/follow/APSendFollowServiceImplTest.kt @@ -24,8 +24,7 @@ class APSendFollowServiceImplTest { apSendFollowServiceImpl.sendFollow(sendFollowDto) val value = Follow( - name = "Follow", - `object` = sendFollowDto.followTargetUserId.url, + apObject = sendFollowDto.followTargetUserId.url, actor = sendFollowDto.userId.url ) verify(apRequestService, times(1)).apPost( diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/APLikeServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/APLikeServiceImplTest.kt deleted file mode 100644 index 3b37fffc..00000000 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/APLikeServiceImplTest.kt +++ /dev/null @@ -1,112 +0,0 @@ -package dev.usbharu.hideout.activitypub.service.activity.like - -import dev.usbharu.hideout.activitypub.domain.exception.FailedToGetActivityPubResourceException -import dev.usbharu.hideout.activitypub.domain.model.Like -import dev.usbharu.hideout.activitypub.domain.model.Note -import dev.usbharu.hideout.activitypub.domain.model.Person -import dev.usbharu.hideout.activitypub.interfaces.api.common.ActivityPubStringResponse -import dev.usbharu.hideout.activitypub.service.objects.note.APNoteService -import dev.usbharu.hideout.activitypub.service.objects.user.APUserService -import dev.usbharu.hideout.core.query.PostQueryService -import dev.usbharu.hideout.core.service.reaction.ReactionService -import io.ktor.http.* -import kotlinx.coroutines.async -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import org.mockito.kotlin.* -import utils.PostBuilder -import utils.TestTransaction -import utils.UserBuilder - - -class APLikeServiceImplTest { - @Test - fun `receiveLike 正常なLikeを処理できる`() = runTest { - val actor = "https://example.com/actor" - val note = "https://example.com/note" - val like = Like( - name = "Like", actor = actor, id = "htps://example.com", `object` = note, content = "aaa" - ) - - val user = UserBuilder.localUserOf() - val apUserService = mock { - onBlocking { fetchPersonWithEntity(eq(actor), anyOrNull()) } doReturn (Person( - name = "TestUser", - id = "https://example.com", - preferredUsername = "Test user", - summary = "test user", - inbox = "https://example.com/inbox", - outbox = "https://example.com/outbox", - url = "https://example.com/", - icon = null, - publicKey = null, - followers = null, - following = null - ) to user) - } - val apNoteService = mock { - on { fetchNoteAsync(eq(note), anyOrNull()) } doReturn async { - Note( - name = "Note", - id = "https://example.com/note", - attributedTo = "https://example.com/actor", - content = "Hello World", - published = "Date: Wed, 21 Oct 2015 07:28:00 GMT", - ) - } - } - val post = PostBuilder.of() - val postQueryService = mock { - onBlocking { findByUrl(eq(note)) } doReturn post - } - val reactionService = mock() - val apLikeServiceImpl = APLikeServiceImpl( - reactionService, apUserService, apNoteService, postQueryService, TestTransaction - ) - - val actual = apLikeServiceImpl.receiveLike(like) - - verify(reactionService, times(1)).receiveReaction(eq("aaa"), eq("example.com"), eq(user.id), eq(post.id)) - assertEquals(ActivityPubStringResponse(HttpStatusCode.OK, ""), actual) - } - - @Test - fun `recieveLike Likeのobjectのurlが取得できないとき何もしない`() = runTest { - val actor = "https://example.com/actor" - val note = "https://example.com/note" - val like = Like( - name = "Like", actor = actor, id = "htps://example.com", `object` = note, content = "aaa" - ) - - val user = UserBuilder.localUserOf() - val apUserService = mock { - onBlocking { fetchPersonWithEntity(eq(actor), anyOrNull()) } doReturn (Person( - name = "TestUser", - id = "https://example.com", - preferredUsername = "Test user", - summary = "test user", - inbox = "https://example.com/inbox", - outbox = "https://example.com/outbox", - url = "https://example.com/", - icon = null, - publicKey = null, - followers = null, - following = null - ) to user) - } - val apNoteService = mock { - on { fetchNoteAsync(eq(note), anyOrNull()) } doThrow FailedToGetActivityPubResourceException() - } - - val reactionService = mock() - val apLikeServiceImpl = APLikeServiceImpl( - reactionService, apUserService, apNoteService, mock(), TestTransaction - ) - - val actual = apLikeServiceImpl.receiveLike(like) - - verify(reactionService, times(0)).receiveReaction(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) - assertEquals(ActivityPubStringResponse(HttpStatusCode.OK, ""), actual) - } -} diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/ApReactionJobServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/ApReactionJobServiceImplTest.kt deleted file mode 100644 index 22763ca8..00000000 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/like/ApReactionJobServiceImplTest.kt +++ /dev/null @@ -1,128 +0,0 @@ -@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") - -package dev.usbharu.hideout.activitypub.service.activity.like - -import dev.usbharu.hideout.activitypub.domain.model.Like -import dev.usbharu.hideout.activitypub.domain.model.Undo -import dev.usbharu.hideout.activitypub.service.common.APRequestService -import dev.usbharu.hideout.application.config.ApplicationConfig -import dev.usbharu.hideout.core.external.job.DeliverReactionJob -import dev.usbharu.hideout.core.external.job.DeliverRemoveReactionJob -import dev.usbharu.hideout.core.query.UserQueryService -import kjob.core.job.JobProps -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.Json -import org.junit.jupiter.api.Test -import org.mockito.Mockito.mockStatic -import org.mockito.kotlin.* -import utils.JsonObjectMapper.objectMapper -import utils.UserBuilder -import java.net.URL -import java.time.Instant - -class ApReactionJobServiceImplTest { - @Test - fun `reactionJob Likeが配送される`() = runTest { - - val localUser = UserBuilder.localUserOf() - val remoteUser = UserBuilder.remoteUserOf() - - val userQueryService = mock { - onBlocking { findByUrl(localUser.url) } doReturn localUser - } - val apRequestService = mock() - val apReactionJobServiceImpl = ApReactionJobServiceImpl( - userQueryService = userQueryService, - apRequestService = apRequestService, - applicationConfig = ApplicationConfig(URL("https://example.com")), - objectMapper = objectMapper - ) - - - val postUrl = "${remoteUser.url}/posts/1234" - - apReactionJobServiceImpl.reactionJob( - JobProps( - data = mapOf( - DeliverReactionJob.inbox.name to remoteUser.inbox, - DeliverReactionJob.actor.name to localUser.url, - DeliverReactionJob.postUrl.name to postUrl, - DeliverReactionJob.id.name to "1234", - DeliverReactionJob.reaction.name to "❤", - - ), - json = Json - ) - ) - - val body = Like( - name = "Like", - actor = localUser.url, - `object` = postUrl, - id = "https://example.com/like/note/1234", - content = "❤" - ) - - verify(apRequestService, times(1)).apPost(eq(remoteUser.inbox), eq(body), eq(localUser)) - - } - - @Test - fun `removeReactionJob LikeのUndoが配送される`() = runTest { - - val localUser = UserBuilder.localUserOf() - val remoteUser = UserBuilder.remoteUserOf() - - val userQueryService = mock { - onBlocking { findByUrl(localUser.url) } doReturn localUser - } - val apRequestService = mock() - val apReactionJobServiceImpl = ApReactionJobServiceImpl( - userQueryService = userQueryService, - apRequestService = apRequestService, - applicationConfig = ApplicationConfig(URL("https://example.com")), - objectMapper = objectMapper - ) - - - val postUrl = "${remoteUser.url}/posts/1234" - val like = Like( - name = "Like", - actor = remoteUser.url, - `object` = postUrl, - id = "https://example.com/like/note/1234", - content = "❤" - ) - - val now = Instant.now() - - val body = mockStatic(Instant::class.java).use { - - it.`when`(Instant::now).thenReturn(now) - - apReactionJobServiceImpl.removeReactionJob( - JobProps( - data = mapOf( - DeliverRemoveReactionJob.inbox.name to remoteUser.inbox, - DeliverRemoveReactionJob.actor.name to localUser.url, - DeliverRemoveReactionJob.id.name to "1234", - DeliverRemoveReactionJob.like.name to objectMapper.writeValueAsString(like), - - ), - json = Json - ) - ) - Undo( - name = "Undo Reaction", - actor = localUser.url, - `object` = like, - id = "https://example.com/undo/note/1234", - published = now - ) - } - - - - verify(apRequestService, times(1)).apPost(eq(remoteUser.inbox), eq(body), eq(localUser)) - } -} diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/undo/APUndoServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/undo/APUndoServiceImplTest.kt deleted file mode 100644 index fb187aec..00000000 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/activity/undo/APUndoServiceImplTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package dev.usbharu.hideout.activitypub.service.activity.undo - -import dev.usbharu.hideout.activitypub.domain.model.Follow -import dev.usbharu.hideout.activitypub.domain.model.Undo -import dev.usbharu.hideout.activitypub.interfaces.api.common.ActivityPubStringResponse -import dev.usbharu.hideout.core.query.UserQueryService -import io.ktor.http.* -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.eq -import org.mockito.kotlin.mock -import utils.TestTransaction -import utils.UserBuilder -import java.time.Instant - -class APUndoServiceImplTest { - @Test - fun `receiveUndo FollowのUndoを処理できる`() = runTest { - - val userQueryService = mock { - onBlocking { findByUrl(eq("https://follower.example.com/actor")) } doReturn UserBuilder.remoteUserOf() - onBlocking { findByUrl(eq("https://example.com/actor")) } doReturn UserBuilder.localUserOf() - } - val apUndoServiceImpl = APUndoServiceImpl( - userService = mock(), - apUserService = mock(), - userQueryService = userQueryService, - transaction = TestTransaction - ) - - val undo = Undo( - name = "Undo", - actor = "https://follower.example.com/actor", - id = "https://follower.example.com/undo/follow", - `object` = Follow( - name = "Follow", - `object` = "https://example.com/actor", - actor = "https://follower.example.com/actor" - ), - published = Instant.now() - ) - val activityPubResponse = apUndoServiceImpl.receiveUndo(undo) - assertEquals(ActivityPubStringResponse(HttpStatusCode.OK, "Accept"), activityPubResponse) - } - -} diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/common/APRequestServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/common/APRequestServiceImplTest.kt index ec36d233..2bb41d56 100644 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/common/APRequestServiceImplTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/common/APRequestServiceImplTest.kt @@ -39,7 +39,7 @@ class APRequestServiceImplTest { assertDoesNotThrow { dateTimeFormatter.parse(it.headers["Date"]) } - respond("{}") + respond("""{"type":"Follow","object": "https://example.com","actor": "https://example.com"}""") }), objectMapper, mock(), @@ -47,8 +47,7 @@ class APRequestServiceImplTest { ) val responseClass = Follow( - name = "Follow", - `object` = "https://example.com", + apObject = "https://example.com", actor = "https://example.com" ) apRequestServiceImpl.apGet("https://example.com", responseClass = responseClass::class.java) @@ -65,7 +64,7 @@ class APRequestServiceImplTest { assertDoesNotThrow { dateTimeFormatter.parse(it.headers["Date"]) } - respond("{}") + respond("""{"type":"Follow","object": "https://example.com","actor": "https://example.com"}""") }), objectMapper, mock(), @@ -73,8 +72,7 @@ class APRequestServiceImplTest { ) val responseClass = Follow( - name = "Follow", - `object` = "https://example.com", + apObject = "https://example.com", actor = "https://example.com" ) apRequestServiceImpl.apGet( @@ -106,7 +104,7 @@ class APRequestServiceImplTest { assertDoesNotThrow { dateTimeFormatter.parse(it.headers["Date"]) } - respond("{}") + respond("""{"type":"Follow","object": "https://example.com","actor": "https://example.com"}""") }), objectMapper, httpSignatureSigner, @@ -114,8 +112,7 @@ class APRequestServiceImplTest { ) val responseClass = Follow( - name = "Follow", - `object` = "https://example.com", + apObject = "https://example.com", actor = "https://example.com" ) apRequestServiceImpl.apGet( @@ -166,8 +163,7 @@ class APRequestServiceImplTest { }), objectMapper, mock(), dateTimeFormatter) val body = Follow( - name = "Follow", - `object` = "https://example.com", + apObject = "https://example.com", actor = "https://example.com" ) apRequestServiceImpl.apPost("https://example.com", body, null) @@ -213,8 +209,7 @@ class APRequestServiceImplTest { }), objectMapper, mock(), dateTimeFormatter) val body = Follow( - name = "Follow", - `object` = "https://example.com", + apObject = "https://example.com", actor = "https://example.com" ) apRequestServiceImpl.apPost("https://example.com", body, null) @@ -244,8 +239,7 @@ class APRequestServiceImplTest { }), objectMapper, mock(), dateTimeFormatter) val body = Follow( - name = "Follow", - `object` = "https://example.com", + apObject = "https://example.com", actor = "https://example.com" ) apRequestServiceImpl.apPost("https://example.com", body, UserBuilder.remoteUserOf()) @@ -286,8 +280,7 @@ class APRequestServiceImplTest { }), objectMapper, httpSignatureSigner, dateTimeFormatter) val body = Follow( - name = "Follow", - `object` = "https://example.com", + apObject = "https://example.com", actor = "https://example.com" ) apRequestServiceImpl.apPost( @@ -337,8 +330,7 @@ class APRequestServiceImplTest { }), objectMapper, mock(), dateTimeFormatter) val body = Follow( - name = "Follow", - `object` = "https://example.com", + apObject = "https://example.com", actor = "https://example.com" ) val actual = apRequestServiceImpl.apPost("https://example.com", body, null, body::class.java) diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/common/APServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/common/APServiceImplTest.kt index 1b6591f8..b4dc586f 100644 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/common/APServiceImplTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/common/APServiceImplTest.kt @@ -11,13 +11,7 @@ class APServiceImplTest { @Test fun `parseActivity 正常なActivityをパースできる`() { val apServiceImpl = APServiceImpl( - apReceiveFollowService = mock(), - apUndoService = mock(), - apAcceptService = mock(), - apCreateService = mock(), - apLikeService = mock(), - apReceiveDeleteService = mock(), - objectMapper = objectMapper + objectMapper = objectMapper, jobQueueParentService = mock() ) //language=JSON @@ -29,13 +23,7 @@ class APServiceImplTest { @Test fun `parseActivity Typeが配列のActivityをパースできる`() { val apServiceImpl = APServiceImpl( - apReceiveFollowService = mock(), - apUndoService = mock(), - apAcceptService = mock(), - apCreateService = mock(), - apLikeService = mock(), - apReceiveDeleteService = mock(), - objectMapper = objectMapper + objectMapper = objectMapper, jobQueueParentService = mock() ) //language=JSON @@ -47,13 +35,7 @@ class APServiceImplTest { @Test fun `parseActivity Typeが配列で関係ない物が入っていてもパースできる`() { val apServiceImpl = APServiceImpl( - apReceiveFollowService = mock(), - apUndoService = mock(), - apAcceptService = mock(), - apCreateService = mock(), - apLikeService = mock(), - apReceiveDeleteService = mock(), - objectMapper = objectMapper + objectMapper = objectMapper, jobQueueParentService = mock() ) //language=JSON @@ -65,13 +47,8 @@ class APServiceImplTest { @Test fun `parseActivity jsonとして解釈できない場合JsonParseExceptionがthrowされる`() { val apServiceImpl = APServiceImpl( - apReceiveFollowService = mock(), - apUndoService = mock(), - apAcceptService = mock(), - apCreateService = mock(), - apLikeService = mock(), - apReceiveDeleteService = mock(), - objectMapper = objectMapper + + objectMapper = objectMapper, jobQueueParentService = mock() ) //language=JSON @@ -83,13 +60,8 @@ class APServiceImplTest { @Test fun `parseActivity 空の場合JsonParseExceptionがthrowされる`() { val apServiceImpl = APServiceImpl( - apReceiveFollowService = mock(), - apUndoService = mock(), - apAcceptService = mock(), - apCreateService = mock(), - apLikeService = mock(), - apReceiveDeleteService = mock(), - objectMapper = objectMapper + + objectMapper = objectMapper, jobQueueParentService = mock() ) //language=JSON @@ -101,13 +73,8 @@ class APServiceImplTest { @Test fun `parseActivity jsonにtypeプロパティがない場合JsonParseExceptionがthrowされる`() { val apServiceImpl = APServiceImpl( - apReceiveFollowService = mock(), - apUndoService = mock(), - apAcceptService = mock(), - apCreateService = mock(), - apLikeService = mock(), - apReceiveDeleteService = mock(), - objectMapper = objectMapper + + objectMapper = objectMapper, jobQueueParentService = mock() ) //language=JSON @@ -119,13 +86,8 @@ class APServiceImplTest { @Test fun `parseActivity typeが配列でないときtypeが未定義の場合IllegalArgumentExceptionがthrowされる`() { val apServiceImpl = APServiceImpl( - apReceiveFollowService = mock(), - apUndoService = mock(), - apAcceptService = mock(), - apCreateService = mock(), - apLikeService = mock(), - apReceiveDeleteService = mock(), - objectMapper = objectMapper + + objectMapper = objectMapper, jobQueueParentService = mock() ) //language=JSON @@ -137,13 +99,8 @@ class APServiceImplTest { @Test fun `parseActivity typeが配列のとき定義済みのtypeを見つけられなかった場合IllegalArgumentExceptionがthrowされる`() { val apServiceImpl = APServiceImpl( - apReceiveFollowService = mock(), - apUndoService = mock(), - apAcceptService = mock(), - apCreateService = mock(), - apLikeService = mock(), - apReceiveDeleteService = mock(), - objectMapper = objectMapper + + objectMapper = objectMapper, jobQueueParentService = mock() ) //language=JSON @@ -155,13 +112,8 @@ class APServiceImplTest { @Test fun `parseActivity typeが空の場合IllegalArgumentExceptionがthrowされる`() { val apServiceImpl = APServiceImpl( - apReceiveFollowService = mock(), - apUndoService = mock(), - apAcceptService = mock(), - apCreateService = mock(), - apLikeService = mock(), - apReceiveDeleteService = mock(), - objectMapper = objectMapper + + objectMapper = objectMapper, jobQueueParentService = mock() ) //language=JSON @@ -173,13 +125,8 @@ class APServiceImplTest { @Test fun `parseActivity typeに指定されている文字の判定がcase-insensitiveで行われる`() { val apServiceImpl = APServiceImpl( - apReceiveFollowService = mock(), - apUndoService = mock(), - apAcceptService = mock(), - apCreateService = mock(), - apLikeService = mock(), - apReceiveDeleteService = mock(), - objectMapper = objectMapper + + objectMapper = objectMapper, jobQueueParentService = mock() ) //language=JSON @@ -191,13 +138,8 @@ class APServiceImplTest { @Test fun `parseActivity typeが配列のとき指定されている文字の判定がcase-insensitiveで行われる`() { val apServiceImpl = APServiceImpl( - apReceiveFollowService = mock(), - apUndoService = mock(), - apAcceptService = mock(), - apCreateService = mock(), - apLikeService = mock(), - apReceiveDeleteService = mock(), - objectMapper = objectMapper + + objectMapper = objectMapper, jobQueueParentService = mock() ) //language=JSON @@ -209,13 +151,8 @@ class APServiceImplTest { @Test fun `parseActivity activityがarrayのときJsonParseExceptionがthrowされる`() { val apServiceImpl = APServiceImpl( - apReceiveFollowService = mock(), - apUndoService = mock(), - apAcceptService = mock(), - apCreateService = mock(), - apLikeService = mock(), - apReceiveDeleteService = mock(), - objectMapper = objectMapper + + objectMapper = objectMapper, jobQueueParentService = mock() ) //language=JSON @@ -227,13 +164,8 @@ class APServiceImplTest { @Test fun `parseActivity activityがvalueのときJsonParseExceptionがthrowされる`() { val apServiceImpl = APServiceImpl( - apReceiveFollowService = mock(), - apUndoService = mock(), - apAcceptService = mock(), - apCreateService = mock(), - apLikeService = mock(), - apReceiveDeleteService = mock(), - objectMapper = objectMapper + + objectMapper = objectMapper, jobQueueParentService = mock() ) //language=JSON diff --git a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteServiceImplTest.kt index 540f642c..b8713816 100644 --- a/src/test/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteServiceImplTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/activitypub/service/objects/note/APNoteServiceImplTest.kt @@ -56,7 +56,6 @@ class APNoteServiceImplTest { onBlocking { findById(eq(post.userId)) } doReturn user } val expected = Note( - name = "Post", id = post.apId, attributedTo = user.url, content = post.text, @@ -98,7 +97,6 @@ class APNoteServiceImplTest { onBlocking { findById(eq(post.userId)) } doReturn user } val note = Note( - name = "Post", id = post.apId, attributedTo = user.url, content = post.text, @@ -124,13 +122,10 @@ class APNoteServiceImplTest { url = user.url, icon = Image( type = emptyList(), - name = user.url + "/icon.png", mediaType = "image/png", url = user.url + "/icon.png" ), publicKey = Key( - type = emptyList(), - name = "Public Key", id = user.keyId, owner = user.url, publicKeyPem = user.publicKey @@ -177,7 +172,6 @@ class APNoteServiceImplTest { onBlocking { findById(eq(post.userId)) } doReturn user } val note = Note( - name = "Post", id = post.apId, attributedTo = user.url, content = post.text, @@ -246,11 +240,10 @@ class APNoteServiceImplTest { outbox = user.outbox, url = user.url, icon = Image( - name = user.url + "/icon.png", mediaType = "image/png", url = user.url + "/icon.png" + mediaType = "image/png", + url = user.url + "/icon.png" ), publicKey = Key( - type = emptyList(), - name = "Public Key", id = user.keyId, owner = user.url, publicKeyPem = user.publicKey @@ -278,7 +271,6 @@ class APNoteServiceImplTest { ) val note = Note( - name = "Post", id = post.apId, attributedTo = user.url, content = post.text, @@ -311,7 +303,6 @@ class APNoteServiceImplTest { onBlocking { findById(eq(user.id)) } doReturn user } val note = Note( - name = "Post", id = post.apId, attributedTo = user.url, content = post.text, diff --git a/src/test/kotlin/dev/usbharu/hideout/ap/ContextSerializerTest.kt b/src/test/kotlin/dev/usbharu/hideout/ap/ContextSerializerTest.kt index db434005..a141c11b 100644 --- a/src/test/kotlin/dev/usbharu/hideout/ap/ContextSerializerTest.kt +++ b/src/test/kotlin/dev/usbharu/hideout/ap/ContextSerializerTest.kt @@ -12,9 +12,8 @@ class ContextSerializerTest { val accept = Accept( name = "aaa", actor = "bbb", - `object` = Follow( - name = "ccc", - `object` = "ddd", + apObject = Follow( + apObject = "ddd", actor = "aaa" ) ) diff --git a/src/test/kotlin/dev/usbharu/hideout/core/service/media/ApatcheTikaFileTypeDeterminationServiceTest.kt b/src/test/kotlin/dev/usbharu/hideout/core/service/media/ApatcheTikaFileTypeDeterminationServiceTest.kt new file mode 100644 index 00000000..5b445356 --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/core/service/media/ApatcheTikaFileTypeDeterminationServiceTest.kt @@ -0,0 +1,19 @@ +package dev.usbharu.hideout.core.service.media + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import kotlin.io.path.toPath + +class ApatcheTikaFileTypeDeterminationServiceTest { + @Test + fun png() { + val apatcheTikaFileTypeDeterminationService = ApatcheTikaFileTypeDeterminationService() + + val mimeType = apatcheTikaFileTypeDeterminationService.fileType( + String.javaClass.classLoader.getResource("400x400.png").toURI().toPath(), "400x400.png" + ) + + assertThat(mimeType.type).isEqualTo("image") + assertThat(mimeType.subtype).isEqualTo("png") + } +} diff --git a/src/test/kotlin/dev/usbharu/hideout/core/service/media/LocalFileSystemMediaDataStoreTest.kt b/src/test/kotlin/dev/usbharu/hideout/core/service/media/LocalFileSystemMediaDataStoreTest.kt new file mode 100644 index 00000000..e2854912 --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/core/service/media/LocalFileSystemMediaDataStoreTest.kt @@ -0,0 +1,121 @@ +package dev.usbharu.hideout.core.service.media + +import dev.usbharu.hideout.application.config.ApplicationConfig +import dev.usbharu.hideout.application.config.LocalStorageConfig +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.net.URL +import java.nio.file.Path +import java.util.* +import kotlin.io.path.readBytes +import kotlin.io.path.toPath + +class LocalFileSystemMediaDataStoreTest { + + private val path = String.javaClass.classLoader.getResource("400x400.png")?.toURI()?.toPath()!! + + @Test + fun `save inputStreamを使用して正常に保存できる`() = runTest { + val applicationConfig = ApplicationConfig(URL("https://example.com")) + val storageConfig = LocalStorageConfig("files", null) + + val localFileSystemMediaDataStore = LocalFileSystemMediaDataStore(applicationConfig, storageConfig) + + val fileInputStream = path.readBytes() + + assertThat(fileInputStream.size).isNotEqualTo(0) + + val mediaSave = MediaSave( + "test-media-1${UUID.randomUUID()}.png", + "", + fileInputStream, + fileInputStream + ) + + val save = localFileSystemMediaDataStore.save(mediaSave) + + assertThat(save).isInstanceOf(SuccessSavedMedia::class.java) + + save as SuccessSavedMedia + + assertThat(Path.of("files").toAbsolutePath().resolve(save.name)) + .exists() + .hasSize(fileInputStream.size.toLong()) + assertThat(Path.of("files").toAbsolutePath().resolve("thumbnail-" + save.name)) + .exists() + .hasSize(fileInputStream.size.toLong()) + } + + @Test + fun 一時ファイルを使用して正常に保存できる() = runTest { + val applicationConfig = ApplicationConfig(URL("https://example.com")) + val storageConfig = LocalStorageConfig("files", null) + + val localFileSystemMediaDataStore = LocalFileSystemMediaDataStore(applicationConfig, storageConfig) + + val fileInputStream = path.readBytes() + + assertThat(fileInputStream.size).isNotEqualTo(0) + + val saveRequest = MediaSaveRequest( + "test-media-2${UUID.randomUUID()}.png", + "", + path, + path + ) + + val save = localFileSystemMediaDataStore.save(saveRequest) + + assertThat(save).isInstanceOf(SuccessSavedMedia::class.java) + + save as SuccessSavedMedia + + assertThat(Path.of("files").toAbsolutePath().resolve(save.name)) + .exists() + .hasSize(fileInputStream.size.toLong()) + assertThat(Path.of("files").toAbsolutePath().resolve("thumbnail-" + save.name)) + .exists() + .hasSize(fileInputStream.size.toLong()) + } + + @Test + fun idを使用して削除できる() = runTest { + val applicationConfig = ApplicationConfig(URL("https://example.com")) + val storageConfig = LocalStorageConfig("files", null) + + val localFileSystemMediaDataStore = LocalFileSystemMediaDataStore(applicationConfig, storageConfig) + + val fileInputStream = path.readBytes() + + assertThat(fileInputStream.size).isNotEqualTo(0) + + val saveRequest = MediaSaveRequest( + "test-media-2${UUID.randomUUID()}.png", + "", + path, + path + ) + + val save = localFileSystemMediaDataStore.save(saveRequest) + + assertThat(save).isInstanceOf(SuccessSavedMedia::class.java) + + save as SuccessSavedMedia + + assertThat(Path.of("files").toAbsolutePath().resolve(save.name)) + .exists() + .hasSize(fileInputStream.size.toLong()) + assertThat(Path.of("files").toAbsolutePath().resolve("thumbnail-" + save.name)) + .exists() + .hasSize(fileInputStream.size.toLong()) + + + localFileSystemMediaDataStore.delete(save.name) + + assertThat(Path.of("files").toAbsolutePath().resolve(save.name)) + .doesNotExist() + assertThat(Path.of("files").toAbsolutePath().resolve("thumbnail-" + save.name)) + .doesNotExist() + } +} diff --git a/src/test/kotlin/dev/usbharu/hideout/core/service/media/MediaServiceImplTest.kt b/src/test/kotlin/dev/usbharu/hideout/core/service/media/MediaServiceImplTest.kt new file mode 100644 index 00000000..cec6e5aa --- /dev/null +++ b/src/test/kotlin/dev/usbharu/hideout/core/service/media/MediaServiceImplTest.kt @@ -0,0 +1,11 @@ +package dev.usbharu.hideout.core.service.media + + +import org.junit.jupiter.api.Test + +class MediaServiceImplTest { + @Test + fun png画像をアップロードできる() { + + } +} diff --git a/src/test/resources/400x400.png b/src/test/resources/400x400.png new file mode 100644 index 00000000..0d2e71be Binary files /dev/null and b/src/test/resources/400x400.png differ