Merge pull request #679 from usbharu/webfinger

Webfinger
This commit is contained in:
usbharu 2025-02-19 13:19:58 +09:00 committed by GitHub
commit f43507383e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 275 additions and 14 deletions

View File

@ -52,6 +52,7 @@ dependencies {
testImplementation(libs.coroutines.test)
testImplementation(libs.h2db)
testImplementation(libs.flyway.core)
testImplementation(libs.mockito.kotlin)
}
tasks.test {

View File

@ -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<String, XRD>(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)
}
}

View File

@ -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
)
}
}

View File

@ -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<Link> = emptyList()) {
@Order(1)
@GetMapping("/host-meta")
fun hostmeta(): ResponseEntity<WebHostMetadata> {
fun hostmeta(): ResponseEntity<XRD> {
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)
}
}

View File

@ -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)
}

View File

@ -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<Link>,
@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?,
)

View File

@ -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<IllegalArgumentException> {
webFingerApplicationService.execute("a", Anonymous)
}
}
@Test
fun ドメインが自ドメインと一致しないとだめ() = runTest {
assertThrows<IllegalArgumentException> {
webFingerApplicationService.execute("acct:test@remote.example.com", Anonymous)
}
}
@Test
fun `acct@username@hostはだめ`() = runTest {
assertThrows<IllegalArgumentException> {
webFingerApplicationService.execute("acct:@username@example.com", Anonymous)
}
}
@Test
fun actorが存在しないとだめ() = runTest {
assertThrows<IllegalArgumentException> {
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)
}
}

View File

@ -0,0 +1,8 @@
package util
import dev.usbharu.hideout.core.application.shared.Transaction
object TestTransaction : Transaction {
override suspend fun <T> transaction(block: suspend () -> T): T = block()
override suspend fun <T> transaction(transactionLevel: Int, block: suspend () -> T): T = block()
}

View File

@ -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()
}
}
}

View File

@ -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<DefaultMockMvcBuilder>(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()
}
}
}