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 249c3ab4..5b7bbf73 100644 --- a/src/main/kotlin/dev/usbharu/hideout/application/config/SecurityConfig.kt +++ b/src/main/kotlin/dev/usbharu/hideout/application/config/SecurityConfig.kt @@ -92,6 +92,7 @@ import java.security.interfaces.RSAPrivateKey import java.security.interfaces.RSAPublicKey import java.util.* + @EnableWebSecurity(debug = false) @Configuration @Suppress("FunctionMaxLength", "TooManyFunctions", "LongMethod") @@ -239,6 +240,7 @@ class SecurityConfig { authorize(POST, "/api/v1/media", rf.hasScope("write:media")) authorize(POST, "/api/v1/statuses", rf.hasScope("write:statuses")) + authorize(GET, "/api/v1/statuses/*", permitAll) authorize(GET, "/api/v1/timelines/public", permitAll) authorize(GET, "/api/v1/timelines/home", rf.hasScope("read:statuses")) diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/domain/exception/ClientException.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/domain/exception/ClientException.kt index dcbab814..189428b1 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/domain/exception/ClientException.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/domain/exception/ClientException.kt @@ -18,4 +18,21 @@ package dev.usbharu.hideout.mastodon.domain.exception import dev.usbharu.hideout.mastodon.domain.model.MastodonApiErrorResponse -open class ClientException(response: MastodonApiErrorResponse<*>) : MastodonApiException(response) \ No newline at end of file +open class ClientException : MastodonApiException { + constructor(response: MastodonApiErrorResponse<*>) : super(response) + constructor(message: String?, response: MastodonApiErrorResponse<*>) : super(message, response) + constructor(message: String?, cause: Throwable?, response: MastodonApiErrorResponse<*>) : super( + message, + cause, + response + ) + + constructor(cause: Throwable?, response: MastodonApiErrorResponse<*>) : super(cause, response) + constructor( + message: String?, + cause: Throwable?, + enableSuppression: Boolean, + writableStackTrace: Boolean, + response: MastodonApiErrorResponse<*>, + ) : super(message, cause, enableSuppression, writableStackTrace, response) +} \ No newline at end of file diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/domain/exception/MastodonApiException.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/domain/exception/MastodonApiException.kt index 61447bf4..5c7289a5 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/domain/exception/MastodonApiException.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/domain/exception/MastodonApiException.kt @@ -18,4 +18,38 @@ package dev.usbharu.hideout.mastodon.domain.exception import dev.usbharu.hideout.mastodon.domain.model.MastodonApiErrorResponse -abstract class MastodonApiException(val response: MastodonApiErrorResponse<*>) : RuntimeException() \ No newline at end of file +abstract class MastodonApiException : RuntimeException { + + val response: MastodonApiErrorResponse<*> + + constructor(response: MastodonApiErrorResponse<*>) : super() { + this.response = response + } + + constructor(message: String?, response: MastodonApiErrorResponse<*>) : super(message) { + this.response = response + } + + constructor(message: String?, cause: Throwable?, response: MastodonApiErrorResponse<*>) : super(message, cause) { + this.response = response + } + + constructor(cause: Throwable?, response: MastodonApiErrorResponse<*>) : super(cause) { + this.response = response + } + + constructor( + message: String?, + cause: Throwable?, + enableSuppression: Boolean, + writableStackTrace: Boolean, + response: MastodonApiErrorResponse<*>, + ) : super( + message, + cause, + enableSuppression, + writableStackTrace + ) { + this.response = response + } +} \ No newline at end of file diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/domain/exception/StatusNotFoundException.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/domain/exception/StatusNotFoundException.kt new file mode 100644 index 00000000..8197434a --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/domain/exception/StatusNotFoundException.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2024 usbharu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.usbharu.hideout.mastodon.domain.exception + +import dev.usbharu.hideout.domain.mastodon.model.generated.NotFoundResponse +import dev.usbharu.hideout.mastodon.domain.model.MastodonApiErrorResponse + +class StatusNotFoundException : ClientException { + + fun getTypedResponse(): MastodonApiErrorResponse { + return response as MastodonApiErrorResponse + } + + constructor(response: MastodonApiErrorResponse) : super(response) + constructor(message: String?, response: MastodonApiErrorResponse) : super(message, response) + constructor(message: String?, cause: Throwable?, response: MastodonApiErrorResponse) : super( + message, + cause, + response + ) + + constructor(cause: Throwable?, response: MastodonApiErrorResponse) : super(cause, response) + constructor( + message: String?, + cause: Throwable?, + enableSuppression: Boolean, + writableStackTrace: Boolean, + response: MastodonApiErrorResponse, + ) : super(message, cause, enableSuppression, writableStackTrace, response) + + companion object { + fun ofId(id: Long): StatusNotFoundException = StatusNotFoundException( + "id: $id was not found.", + MastodonApiErrorResponse( + NotFoundResponse( + "Record not found" + ), 404 + ), + ) + } +} \ No newline at end of file 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 007eb561..3194f368 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 @@ -122,12 +122,13 @@ class StatusQueryServiceImpl : StatusQueryService { ) } - override suspend fun findByPostId(id: Long): Status { + override suspend fun findByPostId(id: Long): Status? { val map = Posts .leftJoin(PostsMedia) .leftJoin(Actors) .leftJoin(Media) - .selectAll().where { Posts.id eq id } + .selectAll() + .where { Posts.id eq id } .groupBy { it[Posts.id] } .map { it.value } .map { @@ -138,7 +139,7 @@ class StatusQueryServiceImpl : StatusQueryService { emojis = it.mapNotNull { resultRow -> resultRow.toCustomEmojiOrNull()?.toMastodonEmoji() } ) to it.first()[Posts.repostId] } - return resolveReplyAndRepost(map).single() + return resolveReplyAndRepost(map).singleOrNull() } private fun resolveReplyAndRepost(pairs: List>): List { diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/springweb/MastodonApiControllerAdvice.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/springweb/MastodonApiControllerAdvice.kt new file mode 100644 index 00000000..e27f1a27 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/infrastructure/springweb/MastodonApiControllerAdvice.kt @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2024 usbharu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.usbharu.hideout.mastodon.infrastructure.springweb + +import dev.usbharu.hideout.domain.mastodon.model.generated.NotFoundResponse +import dev.usbharu.hideout.domain.mastodon.model.generated.UnprocessableEntityResponse +import dev.usbharu.hideout.domain.mastodon.model.generated.UnprocessableEntityResponseDetails +import dev.usbharu.hideout.mastodon.domain.exception.StatusNotFoundException +import dev.usbharu.hideout.mastodon.interfaces.api.account.MastodonAccountApiController +import dev.usbharu.hideout.mastodon.interfaces.api.apps.MastodonAppsApiController +import dev.usbharu.hideout.mastodon.interfaces.api.filter.MastodonFilterApiController +import dev.usbharu.hideout.mastodon.interfaces.api.instance.MastodonInstanceApiController +import dev.usbharu.hideout.mastodon.interfaces.api.media.MastodonMediaApiController +import dev.usbharu.hideout.mastodon.interfaces.api.notification.MastodonNotificationApiController +import dev.usbharu.hideout.mastodon.interfaces.api.status.MastodonStatusesApiContoller +import dev.usbharu.hideout.mastodon.interfaces.api.timeline.MastodonTimelineApiController +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.validation.BindException +import org.springframework.web.bind.annotation.ControllerAdvice +import org.springframework.web.bind.annotation.ExceptionHandler + +@ControllerAdvice( + assignableTypes = [ + MastodonAccountApiController::class, + MastodonAppsApiController::class, + MastodonFilterApiController::class, + MastodonInstanceApiController::class, + MastodonMediaApiController::class, + MastodonNotificationApiController::class, + MastodonStatusesApiContoller::class, + MastodonTimelineApiController::class + ] +) +class MastodonApiControllerAdvice { + + @ExceptionHandler(BindException::class) + fun handleException(ex: BindException): ResponseEntity { + logger.debug("Failed bind entity.", ex) + val error = ex.bindingResult.fieldErrors + val message = error.map { + "${it.field} ${it.defaultMessage}" + }.joinToString(prefix = "Validation failed: ") + + val details = error.associate { + it.field to UnprocessableEntityResponseDetails( + when (it.code) { + "Email" -> "ERR_INVALID" + "Pattern" -> "ERR_INVALID" + else -> "ERR_INVALID" + }, + it.defaultMessage + ) + } + + return ResponseEntity.unprocessableEntity().body(UnprocessableEntityResponse(message, details)) + } + + @ExceptionHandler(StatusNotFoundException::class) + fun handleException(ex: StatusNotFoundException): ResponseEntity { + logger.warn("Status not found.", ex) + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getTypedResponse().response) + } + + companion object { + private val logger = LoggerFactory.getLogger(MastodonApiControllerAdvice::class.java) + } +} \ No newline at end of file diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/status/MastodonStatusesApiContoller.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/status/MastodonStatusesApiContoller.kt index 0ee0d263..0edd2d35 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/status/MastodonStatusesApiContoller.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/interfaces/api/status/MastodonStatusesApiContoller.kt @@ -43,6 +43,7 @@ class MastodonStatusesApiContoller( ) } + override suspend fun apiV1StatusesIdEmojiReactionsEmojiDelete(id: String, emoji: String): ResponseEntity { val uid = loginUserContextHolder.getLoginUserId() @@ -57,5 +58,9 @@ class MastodonStatusesApiContoller( return ResponseEntity.ok(statusesApiService.emojiReactions(id.toLong(), uid, emoji)) } - override suspend fun apiV1StatusesIdGet(id: String): ResponseEntity = super.apiV1StatusesIdGet(id) + override suspend fun apiV1StatusesIdGet(id: String): ResponseEntity { + val uid = loginUserContextHolder.getLoginUserIdOrNull() + + return ResponseEntity.ok(statusesApiService.findById(id.toLong(), uid)) + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/mastodon/query/StatusQueryService.kt b/src/main/kotlin/dev/usbharu/hideout/mastodon/query/StatusQueryService.kt index b67a3308..e5640509 100644 --- a/src/main/kotlin/dev/usbharu/hideout/mastodon/query/StatusQueryService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/mastodon/query/StatusQueryService.kt @@ -37,5 +37,5 @@ interface StatusQueryService { page: Page ): PaginationList - suspend fun findByPostId(id: Long): Status + suspend fun findByPostId(id: Long): Status? } 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 a6e4598e..1c9e8d7b 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 @@ -29,6 +29,7 @@ import dev.usbharu.hideout.core.service.post.PostService import dev.usbharu.hideout.core.service.reaction.ReactionService import dev.usbharu.hideout.domain.mastodon.model.generated.Status import dev.usbharu.hideout.domain.mastodon.model.generated.Status.Visibility.* +import dev.usbharu.hideout.mastodon.domain.exception.StatusNotFoundException 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 @@ -43,25 +44,25 @@ import java.time.Instant interface StatusesApiService { suspend fun postStatus( statusesRequest: StatusesRequest, - userId: Long + userId: Long, ): Status suspend fun findById( id: Long, - userId: Long? - ): Status? + userId: Long?, + ): Status suspend fun emojiReactions( postId: Long, userId: Long, - emojiName: String - ): Status? + emojiName: String, + ): Status suspend fun removeEmojiReactions( postId: Long, userId: Long, - emojiName: String - ): Status? + emojiName: String, + ): Status } @Service @@ -76,12 +77,12 @@ class StatsesApiServiceImpl( private val statusQueryService: StatusQueryService, private val relationshipRepository: RelationshipRepository, private val reactionService: ReactionService, - private val emojiService: EmojiService + private val emojiService: EmojiService, ) : StatusesApiService { override suspend fun postStatus( statusesRequest: StatusesRequest, - userId: Long + userId: Long, ): Status = transaction.transaction { logger.debug("START create post by mastodon api. {}", statusesRequest) @@ -140,39 +141,51 @@ class StatsesApiServiceImpl( ) } - override suspend fun findById(id: Long, userId: Long?): Status? { - val status = statusQueryService.findByPostId(id) + override suspend fun findById(id: Long, userId: Long?): Status = transaction.transaction { + val status = statusQueryService.findByPostId(id) ?: statusNotFound(id) - return status(status, userId) + return@transaction status(status, userId) + } + + private fun accessDenied(id: String): Nothing { + logger.debug("Access Denied $id") + throw StatusNotFoundException.ofId(id.toLong()) + } + + private fun statusNotFound(id: Long): Nothing { + logger.debug("Status Not Found $id") + throw StatusNotFoundException.ofId(id) } private suspend fun status( status: Status, - userId: Long? - ): Status? { + userId: Long?, + ): Status { + + return when (status.visibility) { public -> status unlisted -> status private -> { if (userId == null) { - return null + accessDenied(status.id) } val relationship = relationshipRepository.findByUserIdAndTargetUserId(userId, status.account.id.toLong()) - ?: return null + ?: accessDenied(status.id) if (relationship.following) { return status } - return null + accessDenied(status.id) } - direct -> null + direct -> accessDenied(status.id) } } - override suspend fun emojiReactions(postId: Long, userId: Long, emojiName: String): Status? { - status(statusQueryService.findByPostId(postId), userId) ?: return null + override suspend fun emojiReactions(postId: Long, userId: Long, emojiName: String): Status { + status(statusQueryService.findByPostId(postId) ?: statusNotFound(postId), userId) val emoji = try { if (EmojiUtil.isEmoji(emojiName)) { @@ -186,13 +199,13 @@ class StatsesApiServiceImpl( UnicodeEmoji("❤") } reactionService.sendReaction(emoji, userId, postId) - return statusQueryService.findByPostId(postId) + return statusQueryService.findByPostId(postId) ?: statusNotFound(postId) } - override suspend fun removeEmojiReactions(postId: Long, userId: Long, emojiName: String): Status? { + override suspend fun removeEmojiReactions(postId: Long, userId: Long, emojiName: String): Status { reactionService.removeReaction(userId, postId) - return status(statusQueryService.findByPostId(postId), userId) + return status(statusQueryService.findByPostId(postId) ?: statusNotFound(postId), userId) } companion object { diff --git a/src/main/resources/openapi/mastodon.yaml b/src/main/resources/openapi/mastodon.yaml index 9c108b00..66fb6704 100644 --- a/src/main/resources/openapi/mastodon.yaml +++ b/src/main/resources/openapi/mastodon.yaml @@ -2840,6 +2840,18 @@ components: properties: error: type: string + details: + type: object + additionalProperties: + $ref: "#/components/schemas/UnprocessableEntityResponseDetails" + + UnprocessableEntityResponseDetails: + type: object + properties: + error: + type: string + description: + type: string UnauthorizedResponse: type: object @@ -2877,11 +2889,7 @@ components: PartialContentResponse: type: object - properties: - error: - type: string - required: - - error + responses: forbidden: