diff --git a/hideout/hideout-activitypub/build.gradle.kts b/hideout/hideout-activitypub/build.gradle.kts index 1791af68..68951ff6 100644 --- a/hideout/hideout-activitypub/build.gradle.kts +++ b/hideout/hideout-activitypub/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { testImplementation(libs.coroutines.test) testImplementation(libs.h2db) testImplementation(libs.flyway.core) + testImplementation(libs.mockito.kotlin) } tasks.test { diff --git a/hideout/hideout-activitypub/src/main/kotlin/dev/usbharu/hideout/activitypub/application/webfinger/WebFingerApplicationService.kt b/hideout/hideout-activitypub/src/main/kotlin/dev/usbharu/hideout/activitypub/application/webfinger/WebFingerApplicationService.kt new file mode 100644 index 00000000..a12bfdf3 --- /dev/null +++ b/hideout/hideout-activitypub/src/main/kotlin/dev/usbharu/hideout/activitypub/application/webfinger/WebFingerApplicationService.kt @@ -0,0 +1,49 @@ +package dev.usbharu.hideout.activitypub.application.webfinger + +import dev.usbharu.hideout.activitypub.interfaces.wellknown.Link +import dev.usbharu.hideout.activitypub.interfaces.wellknown.XRD +import dev.usbharu.hideout.core.application.shared.AbstractApplicationService +import dev.usbharu.hideout.core.application.shared.Transaction +import dev.usbharu.hideout.core.config.ApplicationConfig +import dev.usbharu.hideout.core.domain.model.actor.ActorRepository +import dev.usbharu.hideout.core.domain.model.support.domain.apHost +import dev.usbharu.hideout.core.domain.model.support.principal.Principal +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.net.URI + +@Service +class WebFingerApplicationService( + transaction: Transaction, + private val applicationConfig: ApplicationConfig, + private val actorRepository: ActorRepository, +) : AbstractApplicationService(transaction, logger) { + + override suspend fun internalExecute(resource: String, principal: Principal): XRD { + if (resource.startsWith("acct:").not()) { + throw IllegalArgumentException("Parameter (resource) is invalid.") + } + val acct = resource.substringAfter("acct:") + + val host = acct.substringAfter('@', "") + if (applicationConfig.url.apHost != host) { + throw IllegalArgumentException("Parameter (resource) is invalid.") + } + val username = acct.substringBefore('@', "") + if (username.isEmpty()) { + throw IllegalArgumentException("Actor is not found.") + } + + val actor = actorRepository.findByNameAndDomain(username, applicationConfig.url.apHost) + ?: throw IllegalArgumentException("Actor $username not found.") + + return XRD( + listOf(Link("self", null, "application/activity+json", actor.url.toString())), + URI.create("acct:${actor.name.name}@${actor.domain.domain}") + ) + } + + companion object { + private val logger = LoggerFactory.getLogger(WebFingerApplicationService::class.java) + } +} diff --git a/hideout/hideout-activitypub/src/main/kotlin/dev/usbharu/hideout/activitypub/config/WebFingerHostMetaLinkConfiguration.kt b/hideout/hideout-activitypub/src/main/kotlin/dev/usbharu/hideout/activitypub/config/WebFingerHostMetaLinkConfiguration.kt index 03220ad2..7882f5b8 100644 --- a/hideout/hideout-activitypub/src/main/kotlin/dev/usbharu/hideout/activitypub/config/WebFingerHostMetaLinkConfiguration.kt +++ b/hideout/hideout-activitypub/src/main/kotlin/dev/usbharu/hideout/activitypub/config/WebFingerHostMetaLinkConfiguration.kt @@ -1,6 +1,6 @@ package dev.usbharu.hideout.activitypub.config -import dev.usbharu.hideout.activitypub.application.hostmeta.Link +import dev.usbharu.hideout.activitypub.interfaces.wellknown.Link import dev.usbharu.hideout.core.config.ApplicationConfig import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -12,7 +12,8 @@ class WebFingerHostMetaLinkConfiguration(private val applicationConfig: Applicat return Link( rel = "lrdd", type = "application/jrd+json", - template = applicationConfig.url.resolve(".well-known/webfinger").toString() + "?resource={uri}" + template = applicationConfig.url.resolve(".well-known/webfinger").toString() + "?resource={uri}", + href = null ) } } diff --git a/hideout/hideout-activitypub/src/main/kotlin/dev/usbharu/hideout/activitypub/interfaces/wellknown/HostmetaController.kt b/hideout/hideout-activitypub/src/main/kotlin/dev/usbharu/hideout/activitypub/interfaces/wellknown/HostmetaController.kt index 2f1a2671..7b11c9cb 100644 --- a/hideout/hideout-activitypub/src/main/kotlin/dev/usbharu/hideout/activitypub/interfaces/wellknown/HostmetaController.kt +++ b/hideout/hideout-activitypub/src/main/kotlin/dev/usbharu/hideout/activitypub/interfaces/wellknown/HostmetaController.kt @@ -1,7 +1,5 @@ package dev.usbharu.hideout.activitypub.interfaces.wellknown -import dev.usbharu.hideout.activitypub.application.hostmeta.Link -import dev.usbharu.hideout.activitypub.application.hostmeta.WebHostMetadata import org.springframework.core.annotation.Order import org.springframework.http.MediaType import org.springframework.http.ResponseEntity @@ -14,19 +12,19 @@ import org.springframework.web.bind.annotation.RestController class HostmetaController(private val linkList: List = emptyList()) { @Order(1) @GetMapping("/host-meta") - fun hostmeta(): ResponseEntity { + fun hostmeta(): ResponseEntity { return ResponseEntity.ok().contentType(MediaType("application", "xrd+xml")) - .body(WebHostMetadata(linkList)) + .body(XRD(linkList)) } @Order(2) @GetMapping("/host-meta", produces = ["application/json"]) - fun hostmetaJson(): WebHostMetadata { - return WebHostMetadata(linkList) + fun hostmetaJson(): XRD { + return XRD(linkList) } @GetMapping("/host-meta.json", produces = ["application/json"]) - fun hostmetaJson2(): WebHostMetadata { - return WebHostMetadata(linkList) + fun hostmetaJson2(): XRD { + return XRD(linkList) } } diff --git a/hideout/hideout-activitypub/src/main/kotlin/dev/usbharu/hideout/activitypub/interfaces/wellknown/WebFingerController.kt b/hideout/hideout-activitypub/src/main/kotlin/dev/usbharu/hideout/activitypub/interfaces/wellknown/WebFingerController.kt new file mode 100644 index 00000000..30b892c2 --- /dev/null +++ b/hideout/hideout-activitypub/src/main/kotlin/dev/usbharu/hideout/activitypub/interfaces/wellknown/WebFingerController.kt @@ -0,0 +1,18 @@ +package dev.usbharu.hideout.activitypub.interfaces.wellknown + +import dev.usbharu.hideout.activitypub.application.webfinger.WebFingerApplicationService +import dev.usbharu.hideout.core.domain.model.support.principal.Anonymous +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/.well-known") +class WebFingerController( + private val webFingerApplicationService: WebFingerApplicationService, +) { + @GetMapping("/webfinger", produces = ["application/json"]) + suspend fun webfinger(@RequestParam(name = "resource", required = true) resource: String): XRD = + webFingerApplicationService.execute(resource, Anonymous) +} diff --git a/hideout/hideout-activitypub/src/main/kotlin/dev/usbharu/hideout/activitypub/application/hostmeta/WebHostMetadata.kt b/hideout/hideout-activitypub/src/main/kotlin/dev/usbharu/hideout/activitypub/interfaces/wellknown/XRD.kt similarity index 57% rename from hideout/hideout-activitypub/src/main/kotlin/dev/usbharu/hideout/activitypub/application/hostmeta/WebHostMetadata.kt rename to hideout/hideout-activitypub/src/main/kotlin/dev/usbharu/hideout/activitypub/interfaces/wellknown/XRD.kt index 9cb4bdf5..e6617c3f 100644 --- a/hideout/hideout-activitypub/src/main/kotlin/dev/usbharu/hideout/activitypub/application/hostmeta/WebHostMetadata.kt +++ b/hideout/hideout-activitypub/src/main/kotlin/dev/usbharu/hideout/activitypub/interfaces/wellknown/XRD.kt @@ -1,20 +1,31 @@ -package dev.usbharu.hideout.activitypub.application.hostmeta +package dev.usbharu.hideout.activitypub.interfaces.wellknown +import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement +import java.net.URI @JacksonXmlRootElement(localName = "XRD", namespace = "http://docs.oasis-open.org/ns/xri/xrd-1.0") -class WebHostMetadata( +@JsonInclude(JsonInclude.Include.NON_NULL) +data class XRD( @JacksonXmlProperty(localName = "Link", namespace = "http://docs.oasis-open.org/ns/xri/xrd-1.0") @JacksonXmlElementWrapper(useWrapping = false) @JsonProperty("links") val links: List, + @JacksonXmlProperty(localName = "subject") + @JsonProperty(value = "subject") + @JsonInclude(JsonInclude.Include.NON_NULL) + val subject: URI? = null, ) -class Link( +@JsonInclude(JsonInclude.Include.NON_NULL) +data class Link( @JacksonXmlProperty(localName = "rel", isAttribute = true) val rel: String, - @JacksonXmlProperty(localName = "template", isAttribute = true) val template: String, + @JsonInclude(JsonInclude.Include.NON_NULL) + @JacksonXmlProperty(localName = "template", isAttribute = true) val template: String?, @JacksonXmlProperty(localName = "type", isAttribute = true) val type: String, + @JsonInclude(JsonInclude.Include.NON_NULL) + @JacksonXmlProperty(localName = "href", isAttribute = true) val href: String?, ) diff --git a/hideout/hideout-activitypub/src/test/kotlin/dev/usbharu/hideout/activitypub/application/webfinger/WebFingerApplicationServiceTest.kt b/hideout/hideout-activitypub/src/test/kotlin/dev/usbharu/hideout/activitypub/application/webfinger/WebFingerApplicationServiceTest.kt new file mode 100644 index 00000000..cb341b90 --- /dev/null +++ b/hideout/hideout-activitypub/src/test/kotlin/dev/usbharu/hideout/activitypub/application/webfinger/WebFingerApplicationServiceTest.kt @@ -0,0 +1,98 @@ +package dev.usbharu.hideout.activitypub.application.webfinger + +import dev.usbharu.hideout.activitypub.external.activitystreams.TestActorFactory +import dev.usbharu.hideout.activitypub.interfaces.wellknown.Link +import dev.usbharu.hideout.activitypub.interfaces.wellknown.XRD +import dev.usbharu.hideout.core.config.ApplicationConfig +import dev.usbharu.hideout.core.domain.model.actor.ActorRepository +import dev.usbharu.hideout.core.domain.model.support.principal.Anonymous +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.Spy +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.eq +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import util.TestTransaction +import java.net.URI +import kotlin.test.assertEquals + +@ExtendWith(MockitoExtension::class) +class WebFingerApplicationServiceTest { + @InjectMocks + lateinit var webFingerApplicationService: WebFingerApplicationService + + @Mock + lateinit var actorRepository: ActorRepository + + @Spy + val transaction = TestTransaction + + @Spy + val applicationConfig = ApplicationConfig(URI.create("https://example.com")) + + @Test + fun acctから始まらないとだめ() = runTest { + assertThrows { + webFingerApplicationService.execute("a", Anonymous) + } + } + + @Test + fun ドメインが自ドメインと一致しないとだめ() = runTest { + assertThrows { + webFingerApplicationService.execute("acct:test@remote.example.com", Anonymous) + } + } + + @Test + fun `acct@username@hostはだめ`() = runTest { + assertThrows { + webFingerApplicationService.execute("acct:@username@example.com", Anonymous) + } + } + + @Test + fun actorが存在しないとだめ() = runTest { + assertThrows { + webFingerApplicationService.execute("acct:test2@example.com", Anonymous) + } + + verify(actorRepository, times(1)).findByNameAndDomain(eq("test2"), eq("example.com")) + } + + @Test + fun actorが存在したら返す() = runTest { + whenever( + actorRepository.findByNameAndDomain( + eq("test"), eq("example.com") + ) + ).thenReturn( + TestActorFactory.create( + actorName = "test", + domain = "example.com", + uri = URI.create("https://example.com/users/test") + ) + ) + + val execute = webFingerApplicationService.execute("acct:test@example.com", Anonymous) + + val expected = XRD( + listOf( + Link( + rel = "self", + href = "https://example.com/users/test", + type = "application/activity+json", + template = null + ) + ), URI.create("acct:test@example.com") + ) + + assertEquals(expected, execute) + } +} \ No newline at end of file diff --git a/hideout/hideout-activitypub/src/test/kotlin/util/TestTransaction.kt b/hideout/hideout-activitypub/src/test/kotlin/util/TestTransaction.kt new file mode 100644 index 00000000..067d8758 --- /dev/null +++ b/hideout/hideout-activitypub/src/test/kotlin/util/TestTransaction.kt @@ -0,0 +1,8 @@ +package util + +import dev.usbharu.hideout.core.application.shared.Transaction + +object TestTransaction : Transaction { + override suspend fun transaction(block: suspend () -> T): T = block() + override suspend fun transaction(transactionLevel: Int, block: suspend () -> T): T = block() +} diff --git a/hideout/hideout-activitypub/src/test/kotlin/wellknown/HostMetaControllerTest.kt b/hideout/hideout-activitypub/src/test/kotlin/wellknown/HostMetaControllerTest.kt index f34906a8..0d571c1e 100644 --- a/hideout/hideout-activitypub/src/test/kotlin/wellknown/HostMetaControllerTest.kt +++ b/hideout/hideout-activitypub/src/test/kotlin/wellknown/HostMetaControllerTest.kt @@ -1,6 +1,8 @@ package wellknown 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 @@ -62,4 +64,13 @@ class HostMetaControllerTest { .andExpect { status { isOk() } } .andExpect { content { contentType(MediaType("application", "json")) } } } + + companion object { + @JvmStatic + @AfterAll + fun dropDatabase(@Autowired flyway: Flyway) { + flyway.clean() + flyway.migrate() + } + } } \ No newline at end of file diff --git a/hideout/hideout-activitypub/src/test/kotlin/wellknown/WebFingerControllerTest.kt b/hideout/hideout-activitypub/src/test/kotlin/wellknown/WebFingerControllerTest.kt new file mode 100644 index 00000000..5a04645f --- /dev/null +++ b/hideout/hideout-activitypub/src/test/kotlin/wellknown/WebFingerControllerTest.kt @@ -0,0 +1,66 @@ +package wellknown + +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.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.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/actors.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +class WebFingerControllerTest { + @Autowired + private lateinit var context: WebApplicationContext + + private lateinit var mockMvc: MockMvc + + @BeforeEach + fun setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(springSecurity()) + .build() + } + + @Test + fun webfinger() { + mockMvc + .get("/.well-known/webfinger?resource=acct:test@example.com") { + accept(MediaType.APPLICATION_JSON) + } + .asyncDispatch() + .andDo { print() } + .andExpect { status { isOk() } } + } + + @Test + fun `webfinger resourceが無いと400`() { + mockMvc + .get("/.well-known/webfinger") { + accept(MediaType.APPLICATION_JSON) + } + .andExpect { status { isBadRequest() } } + } + + companion object { + @JvmStatic + @AfterAll + fun dropDatabase(@Autowired flyway: Flyway) { + flyway.clean() + flyway.migrate() + } + } +} \ No newline at end of file