diff --git a/src/main/kotlin/dev/usbharu/hideout/controller/mastodon/MastodonApiController.kt b/src/main/kotlin/dev/usbharu/hideout/controller/mastodon/MastodonApiController.kt index 8d3677c5..e04e803e 100644 --- a/src/main/kotlin/dev/usbharu/hideout/controller/mastodon/MastodonApiController.kt +++ b/src/main/kotlin/dev/usbharu/hideout/controller/mastodon/MastodonApiController.kt @@ -1,15 +1,30 @@ package dev.usbharu.hideout.controller.mastodon import dev.usbharu.hideout.controller.mastodon.generated.DefaultApi +import dev.usbharu.hideout.domain.mastodon.model.generated.Status +import dev.usbharu.hideout.domain.mastodon.model.generated.StatusesRequest import dev.usbharu.hideout.domain.mastodon.model.generated.V1Instance +import dev.usbharu.hideout.domain.model.UserDetailsImpl import dev.usbharu.hideout.service.api.mastodon.InstanceApiService +import dev.usbharu.hideout.service.api.mastodon.StatusesApiService import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity +import org.springframework.security.core.context.SecurityContextHolder import org.springframework.stereotype.Controller + @Controller -class MastodonApiController(private val instanceApiService: InstanceApiService) : DefaultApi { +class MastodonApiController( + private val instanceApiService: InstanceApiService, + private val statusesApiService: StatusesApiService +) : DefaultApi { override suspend fun apiV1InstanceGet(): ResponseEntity { return ResponseEntity(instanceApiService.v1Instance(), HttpStatus.OK) } + + override suspend fun apiV1StatusesPost(statusesRequest: StatusesRequest): ResponseEntity { + val principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal() + require(principal is UserDetailsImpl) + return ResponseEntity(statusesApiService.postStatus(statusesRequest, principal), HttpStatus.OK) + } } diff --git a/src/main/kotlin/dev/usbharu/hideout/domain/model/UserDetailsImpl.kt b/src/main/kotlin/dev/usbharu/hideout/domain/model/UserDetailsImpl.kt new file mode 100644 index 00000000..dff04cdc --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/domain/model/UserDetailsImpl.kt @@ -0,0 +1,21 @@ +package dev.usbharu.hideout.domain.model + +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.userdetails.User +import java.io.Serial + +class UserDetailsImpl( + val id: Long, + username: String?, + password: String?, + enabled: Boolean, + accountNonExpired: Boolean, + credentialsNonExpired: Boolean, + accountNonLocked: Boolean, + authorities: MutableCollection? +) : User(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities) { + companion object { + @Serial + private const val serialVersionUID: Long = -899168205656607781L + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/StatusesApiService.kt b/src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/StatusesApiService.kt new file mode 100644 index 00000000..17c8c794 --- /dev/null +++ b/src/main/kotlin/dev/usbharu/hideout/service/api/mastodon/StatusesApiService.kt @@ -0,0 +1,105 @@ +package dev.usbharu.hideout.service.api.mastodon + +import dev.usbharu.hideout.domain.mastodon.model.generated.Status +import dev.usbharu.hideout.domain.mastodon.model.generated.StatusesRequest +import dev.usbharu.hideout.domain.model.UserDetailsImpl +import dev.usbharu.hideout.domain.model.hideout.dto.PostCreateDto +import dev.usbharu.hideout.domain.model.hideout.entity.Visibility +import dev.usbharu.hideout.exception.FailedToGetResourcesException +import dev.usbharu.hideout.query.PostQueryService +import dev.usbharu.hideout.query.UserQueryService +import dev.usbharu.hideout.service.mastodon.AccountService +import dev.usbharu.hideout.service.post.PostService +import org.springframework.stereotype.Service +import java.time.Instant + +@Service +interface StatusesApiService { + suspend fun postStatus(statusesRequest: StatusesRequest, user: UserDetailsImpl): Status +} + + +@Service +class StatsesApiServiceImpl( + private val postService: PostService, + private val accountService: AccountService, + private val postQueryService: PostQueryService, + private val userQueryService: UserQueryService +) : + StatusesApiService { + override suspend fun postStatus(statusesRequest: StatusesRequest, user: UserDetailsImpl): Status { + + 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 + } + + val post = postService.createLocal( + PostCreateDto( + statusesRequest.status.orEmpty(), + statusesRequest.spoilerText, + visibility, + null, + statusesRequest.inReplyToId?.toLongOrNull(), + user.id + ) + ) + val account = accountService.findById(user.id) + + 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 + } catch (e: FailedToGetResourcesException) { + null + } + } else { + null + } + + + return Status( + id = post.id.toString(), + uri = post.apId, + createdAt = Instant.ofEpochMilli(post.createdAt).toString(), + account = account, + content = post.text, + visibility = postVisibility, + sensitive = post.sensitive, + spoilerText = post.overview.orEmpty(), + mediaAttachments = emptyList(), + mentions = emptyList(), + tags = emptyList(), + emojis = emptyList(), + reblogsCount = 0, + favouritesCount = 0, + repliesCount = 0, + url = post.url, + post.replyId?.toString(), + inReplyToAccountId = replyUser?.toString(), + reblog = null, + language = null, + text = post.text, + editedAt = null, + application = null, + poll = null, + card = null, + favourited = null, + reblogged = null, + muted = null, + bookmarked = null, + pinned = null, + filtered = null + ) + } +} diff --git a/src/main/kotlin/dev/usbharu/hideout/service/mastodon/AccountService.kt b/src/main/kotlin/dev/usbharu/hideout/service/mastodon/AccountService.kt index 4486857c..d3347aca 100644 --- a/src/main/kotlin/dev/usbharu/hideout/service/mastodon/AccountService.kt +++ b/src/main/kotlin/dev/usbharu/hideout/service/mastodon/AccountService.kt @@ -1,8 +1,39 @@ package dev.usbharu.hideout.service.mastodon +import dev.usbharu.hideout.domain.mastodon.model.generated.Account +import dev.usbharu.hideout.query.UserQueryService import org.springframework.stereotype.Service @Service interface AccountService { - suspend fun findById() + suspend fun findById(id: Long): Account +} + +@Service +class AccountServiceImpl(private val userQueryService: UserQueryService) : AccountService { + override suspend fun findById(id: Long): Account { + val findById = userQueryService.findById(id) + return Account( + id = findById.id.toString(), + username = findById.name, + acct = "${findById.name}@${findById.domain}", + url = findById.url, + displayName = findById.screenName, + note = findById.description, + avatar = findById.url + "/icon.jpg", + avatarStatic = findById.url + "/icon.jpg", + header = findById.url + "/header.jpg", + headerStatic = findById.url + "/header.jpg", + locked = false, + emptyList(), + emptyList(), + false, + false, + false, + findById.createdAt.toString(), + findById.createdAt.toString(), + 0, + 0, + ) + } } diff --git a/src/main/resources/openapi/mastodon.yaml b/src/main/resources/openapi/mastodon.yaml index aa18edf3..4c9755ad 100644 --- a/src/main/resources/openapi/mastodon.yaml +++ b/src/main/resources/openapi/mastodon.yaml @@ -97,6 +97,27 @@ paths: application/json: schema: $ref: "#/components/schemas/V1Instance" + + /api/v1/statuses: + post: + security: + - OAuth2: + - "write:statuses" + requestBody: + description: 投稿する内容 + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/StatusesRequest" + responses: + 200: + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Status" + components: schemas: Account: @@ -250,7 +271,7 @@ components: type: array items: $ref: "#/components/schemas/StatusMention" - tag: + tags: type: array items: $ref: "#/components/schemas/StatusTag" @@ -277,7 +298,7 @@ components: $ref: "#/components/schemas/Status" poll: $ref: "#/components/schemas/Poll" - PreviewCard: + card: $ref: "#/components/schemas/PreviewCard" language: type: string @@ -302,7 +323,28 @@ components: type: array items: $ref: "#/components/schemas/FilterResult" - + required: + - id + - uri + - created_at + - account + - content + - visibility + - sensitive + - spoiler_text + - media_attachments + - mentions + - tags + - emojis + - reblogs_count + - favourites_count + - replies_count + - url + - in_reply_to_id + - in_reply_to_account_id + - language + - text + - edited_at MediaAttachment: type: object @@ -844,3 +886,95 @@ components: type: string content: type: string + + StatusesRequest: + type: object + properties: + status: + type: string + nullable: true + media_ids: + type: array + items: + type: string + poll: + $ref: "#/components/schemas/StatusesRequestPoll" + in_reply_to_id: + type: string + sensitive: + type: boolean + spoiler_text: + type: string + visibility: + type: string + enum: + - public + - unlisted + - private + - direct + language: + type: string + scheduled_at: + type: string + + StatusesRequestPoll: + type: object + properties: + options: + type: array + items: + type: string + expires_in: + type: integer + multiple: + type: boolean + hide_totals: + type: boolean + + securitySchemes: + OAuth2: + type: oauth2 + description: Mastodon Oauth + flows: + authorizationCode: + authorizationUrl: /oauth/authorize + tokenUrl: /oauth/token + scopes: + read:accounts: "" + read:blocks: "" + read:bookmarks: "" + read:favourites: "" + read:filters: "" + read:follows: "" + read:lists: "" + read:mutes: "" + read:notifications: "" + read:search: "" + read:statuses: "" + write:accounts: "" + write:blocks: "" + write:bookmarks: "" + write:conversations: "" + write:favourites: "" + write:filters: "" + write:follows: "" + write:lists: "" + write:media: "" + write:mutes: "" + write:notifications: "" + write:reports: "" + write:statuses: "" + admin:read:accounts: "" + admin:read:reports: "" + admin:read:domain_allows: "" + admin:read:domain_blocks: "" + admin:read:ip_blocks: "" + admin:read:email_domain_blocks: "" + admin:read:canonical_email_blocks: "" + admin:write:accounts: "" + admin:write:reports: "" + admin:write:domain_allows: "" + admin:write:domain_blocks: "" + admin:write:ip_blocks: "" + admin:write:email_domain_blocks: "" + admin:write:canonical_email_blocks: ""