/* * Copyright (C) 2008-2020, Juick * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ package com.juick.www.api.activity; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.juick.model.Message; import com.juick.model.User; import com.juick.util.formatters.PlainTextFormatter; import com.juick.model.CommandResult; import com.juick.ActivityPubManager; import com.juick.CommandsManager; import com.juick.www.api.activity.helpers.ProfileUriBuilder; import com.juick.www.api.activity.model.Activity; import com.juick.www.api.activity.model.Context; import com.juick.www.api.activity.model.activities.Announce; import com.juick.www.api.activity.model.activities.Block; import com.juick.www.api.activity.model.activities.Create; import com.juick.www.api.activity.model.activities.Delete; import com.juick.www.api.activity.model.activities.Flag; import com.juick.www.api.activity.model.activities.Follow; import com.juick.www.api.activity.model.activities.Like; import com.juick.www.api.activity.model.activities.Undo; import com.juick.www.api.activity.model.activities.Update; import com.juick.www.api.activity.model.objects.Actor; import com.juick.www.api.activity.model.objects.Note; import com.juick.www.api.activity.model.objects.OrderedCollection; import com.juick.www.api.activity.model.objects.OrderedCollectionPage; import com.juick.www.api.activity.model.objects.Person; import com.juick.util.HttpBadRequestException; import com.juick.util.HttpNotFoundException; import com.juick.service.MessagesService; import com.juick.service.UserService; import com.juick.service.activities.AnnounceEvent; import com.juick.service.activities.DirectMessageEvent; import com.juick.service.activities.FollowEvent; import com.juick.service.activities.UndoAnnounceEvent; import com.juick.service.activities.UndoFollowEvent; import com.overzealous.remark.Remark; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.annotation.CacheEvict; import org.springframework.context.ApplicationEventPublisher; import org.springframework.core.convert.ConversionService; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import org.springframework.web.util.UriComponentsBuilder; import javax.inject.Inject; import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.NoSuchElementException; import java.util.stream.Stream; @RestController public class Profile { private static final Logger logger = LoggerFactory.getLogger("ActivityPub"); @Inject private UserService userService; @Inject private MessagesService messagesService; @Inject private ActivityPubManager activityPubManager; @Inject private ProfileUriBuilder profileUriBuilder; @Inject private ApplicationEventPublisher applicationEventPublisher; @Inject private CommandsManager commandsManager; @Value("${web_domain:localhost}") private String domain; @Value("${ap_base_uri:http://localhost:8080/}") private String baseUri; @Inject private ObjectMapper jsonMapper; @Inject private Remark remarkConverter; @Inject private User serviceUser; @Inject private ConversionService conversionService; @GetMapping(value = "/u/{userName}", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_MEDIA_TYPE, Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE, MediaType.APPLICATION_JSON_VALUE }) public Actor getUser(@PathVariable String userName) { User user = userService.getUserByName(userName); if (!user.isAnonymous()) { if (user.equals(serviceUser)) { user.setService(true); } return conversionService.convert(user, Actor.class); } throw new HttpNotFoundException(); } @GetMapping(value = "/u/{userName}/blog/toc", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_MEDIA_TYPE, Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE, MediaType.APPLICATION_JSON_VALUE }) public OrderedCollection getOutbox(@PathVariable String userName) { User user = userService.getUserByName(userName); if (!user.isAnonymous()) { UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(baseUri); OrderedCollection blog = new OrderedCollection(); blog.setId(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString()); blog.setTotalItems(userService.getStatsMessages(user.getUid())); blog.setFirst(uriComponentsBuilder.path(String.format("/u/%s/blog", userName)).toUriString()); return (OrderedCollection) Context.build(blog); } throw new HttpNotFoundException(); } @GetMapping(value = "/u/{userName}/blog", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_MEDIA_TYPE, Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE, MediaType.APPLICATION_JSON_VALUE }) public OrderedCollectionPage getOutboxPage(@ModelAttribute User visitor, @PathVariable String userName, @RequestParam(required = false, defaultValue = "0") int before) { User user = userService.getUserByName(userName); if (!user.isAnonymous() && !user.isBanned()) { UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUri); String personUri = uri.path(String.format("/u/%s", userName)).toUriString(); List mids = messagesService.getUserBlog(user.getUid(), 0, before); List notes = messagesService.getMessages(visitor, mids).stream().map(activityPubManager::makeNote) .toList(); OrderedCollectionPage page = new OrderedCollectionPage(); page.setPartOf(uri.replacePath(String.format("/u/%s/blog/toc", userName)).toUriString()); page.setFirst(uri.replacePath(String.format("/u/%s/blog", userName)).toUriString()); page.setId(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString()); page.setOrderedItems(notes.stream().map(a -> { Create create = new Create(); create.setId(a.getId() + "#Create"); create.setTo(a.getTo()); create.setActor(personUri); create.setObject(a); create.setPublished(a.getPublished()); return create; }).toList()); int beforeNext = mids.stream().reduce((fst, second) -> second).orElse(0); if (beforeNext > 0) { page.setNext(uri.queryParam("before", beforeNext).toUriString()); } page.setLast(uri.replaceQueryParam("before", "1").toUriString()); return (OrderedCollectionPage) Context.build(page); } throw new HttpNotFoundException(); } @GetMapping(value = "/u/{userName}/followers/toc", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_MEDIA_TYPE, Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE, MediaType.APPLICATION_JSON_VALUE }) public OrderedCollection getFollowers(@PathVariable String userName) { User user = userService.getUserByName(userName); if (!user.isAnonymous()) { UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(baseUri); OrderedCollection followers = new OrderedCollection(); followers.setId(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString()); followers.setTotalItems(userService.getUserReaders(user.getUid()).size()); followers.setFirst(uriComponentsBuilder.path(String.format("/u/%s/followers", userName)).toUriString()); return (OrderedCollection) Context.build(followers); } throw new HttpNotFoundException(); } @GetMapping(value = "/u/{userName}/followers", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_MEDIA_TYPE, Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE, MediaType.APPLICATION_JSON_VALUE }) public OrderedCollectionPage getFollowersPage(@PathVariable String userName, @RequestParam(required = false, defaultValue = "0") int page) { User user = userService.getUserByName(userName); if (!user.isAnonymous()) { UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(baseUri); uriComponentsBuilder.path(String.format("/u/%s/followers", userName)); List followers = userService.getUserReaders(user.getUid()); Stream followersPage = followers.stream().skip(20 * page).limit(20); OrderedCollectionPage result = new OrderedCollectionPage(); result.setId(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString()); result.setOrderedItems(followersPage.map(a -> { Person follower = new Person(); follower.setName(a.getName()); follower.setPreferredUsername(a.getName()); follower.setUrl(profileUriBuilder.personWebUri(a)); return follower; }).toList()); boolean hasNext = followers.size() <= 20 * page; if (hasNext) { result.setNext(uriComponentsBuilder.queryParam("page", page + 1).toUriString()); } return (OrderedCollectionPage) Context.build(result); } throw new HttpNotFoundException(); } @GetMapping(value = "/u/{userName}/following/toc", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_MEDIA_TYPE, Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE, MediaType.APPLICATION_JSON_VALUE }) public OrderedCollection getFollowing(@PathVariable String userName) { User user = userService.getUserByName(userName); if (!user.isAnonymous()) { UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(baseUri); OrderedCollection following = new OrderedCollection(); following.setId(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString()); following.setTotalItems(userService.getUserFriends(user.getUid()).size()); following.setFirst(uriComponentsBuilder.path(String.format("/u/%s/followers", userName)).toUriString()); return (OrderedCollection) Context.build(following); } throw new HttpNotFoundException(); } @GetMapping(value = "/u/{userName}/following", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_MEDIA_TYPE, Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE, MediaType.APPLICATION_JSON_VALUE }) public OrderedCollectionPage getFollowingPage(@PathVariable String userName, @RequestParam(required = false, defaultValue = "0") int page) { User user = userService.getUserByName(userName); if (!user.isAnonymous()) { UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(baseUri); uriComponentsBuilder.path(String.format("/u/%s/following", userName)); List following = userService.getUserFriends(user.getUid()); Stream followingPage = following.stream().skip(20 * page).limit(20); OrderedCollectionPage result = new OrderedCollectionPage(); result.setId(ServletUriComponentsBuilder.fromCurrentRequestUri().toUriString()); result.setOrderedItems(followingPage.map(a -> { Person follower = new Person(); follower.setName(a.getName()); follower.setPreferredUsername(a.getName()); follower.setUrl(profileUriBuilder.personWebUri(a)); return follower; }).toList()); boolean hasNext = following.size() <= 20 * page; if (hasNext) { result.setNext(uriComponentsBuilder.queryParam("page", page + 1).toUriString()); } return (OrderedCollectionPage) Context.build(result); } throw new HttpNotFoundException(); } @GetMapping(value = "/n/{mid}-{rid}", produces = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_MEDIA_TYPE, Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE, MediaType.APPLICATION_JSON_VALUE }) public Context showNote(@PathVariable int mid, @PathVariable int rid) { try { Message message = rid > 0 ? messagesService.getReply(mid, rid) : messagesService.getMessage(mid).get(); if (message != null) { return Context.build(activityPubManager.makeNote(message)); } } catch (NoSuchElementException e) { throw new HttpNotFoundException(); } throw new HttpNotFoundException(); } private String formatNote(Note note) { String markdown = remarkConverter.convertFragment((String) note.getContent()); // combine note text with attachment urls return note.getAttachment() == null ? markdown : note.getAttachment().stream().map(attachment -> { String attachmentUrl = attachment.getUrl(); String attachmentName = attachment.getName(); return PlainTextFormatter.markdownUrl(attachmentUrl, attachmentName); }).reduce(markdown, (currentUrl, nextUrl) -> String.format("%s\n%s", currentUrl, nextUrl)); } @CacheEvict(cacheNames = "profiles", key = "{ #visitor.uri }") @PostMapping(value = "/api/inbox", consumes = { Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITY_MEDIA_TYPE, Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE, MediaType.APPLICATION_JSON_VALUE }) public ResponseEntity processInbox(@ModelAttribute User visitor, InputStream inboxData) throws Exception { String inbox = IOUtils.toString(inboxData, StandardCharsets.UTF_8); Activity activity = jsonMapper.readValue(inbox, Activity.class); if ((StringUtils.isNotEmpty(visitor.getUri().toString()) && visitor.getUri().equals(URI.create(activity.getActor()))) || !visitor.isAnonymous()) { if (activity instanceof Follow) { Follow followRequest = (Follow) activity; applicationEventPublisher.publishEvent(new FollowEvent(this, followRequest)); return new ResponseEntity<>(CommandResult.fromString("Follow request accepted"), HttpStatus.ACCEPTED); } if (activity instanceof Undo) { Context object = activity.getObject(); if (object instanceof Follow) { applicationEventPublisher.publishEvent( new UndoFollowEvent(this, activity.getActor(), ((Activity) object).getObject().getId())); return new ResponseEntity<>(CommandResult.fromString("Undo follow request accepted"), HttpStatus.OK); } else if (object instanceof Like || object instanceof Announce) { applicationEventPublisher.publishEvent( new UndoAnnounceEvent(this, activity.getActor(), ((Activity) object).getObject().getId())); return new ResponseEntity<>(CommandResult.fromString("Undo like/announce request accepted"), HttpStatus.OK); } } if (activity instanceof Create) { if (activity.getObject() instanceof Note) { Note note = (Note) activity.getObject(); URI noteId = URI.create((String) note.getId()); if (messagesService.replyExists(noteId)) { return new ResponseEntity<>(CommandResult.fromString("Reply already exists"), HttpStatus.OK); } else { String inReplyTo = (String) note.getInReplyTo(); if (StringUtils.isNotBlank(inReplyTo)) { if (inReplyTo.startsWith(baseUri)) { String postId = profileUriBuilder.postId(inReplyTo); User user = new User(); user.setUri(URI.create(activity.getActor())); CommandResult result = commandsManager.processCommand(user, String.format("#%s %s", postId, formatNote(note)), URI.create(StringUtils.EMPTY)); logger.info(jsonMapper.writeValueAsString(result)); if (result.getNewMessage().isPresent()) { messagesService.updateReplyUri(result.getNewMessage().get(), noteId); return new ResponseEntity<>(result, HttpStatus.OK); } else { logger.warn("Invalid request: {}", inbox); return new ResponseEntity<>(result, HttpStatus.BAD_REQUEST); } } else { Message reply = messagesService.getReplyByUri(inReplyTo); if (reply != null) { User user = new User(); user.setUri(URI.create(activity.getActor())); CommandResult result = commandsManager.processCommand(user, String.format("#%d/%d %s", reply.getMid(), reply.getRid(), formatNote(note)), URI.create(StringUtils.EMPTY)); logger.info(jsonMapper.writeValueAsString(result)); if (result.getNewMessage().isPresent()) { messagesService.updateReplyUri(result.getNewMessage().get(), noteId); return new ResponseEntity<>(result, HttpStatus.OK); } } } } else { if (note.getTo().stream().anyMatch(recipient -> recipient.startsWith(baseUri))) { logger.warn("Possible direct message from {}", note.getAttributedTo()); applicationEventPublisher.publishEvent(new DirectMessageEvent(this, note)); return new ResponseEntity<>(CommandResult.fromString("Message accepted"), HttpStatus.ACCEPTED); } } logger.warn("Request with invalid recipient from {}", activity.getActor()); return new ResponseEntity<>(CommandResult.fromString("Message accepted"), HttpStatus.ACCEPTED); } } } if (activity instanceof Delete) { URI objectId = URI.create(activity.getObject().getId()); if (messagesService.replyExists(objectId)) { URI actor = URI.create(activity.getActor()); messagesService.deleteReply(actor, objectId); } // accept all authenticated Delete activities return new ResponseEntity<>(CommandResult.fromString("Delete request accepted"), HttpStatus.ACCEPTED); } if (activity instanceof Like || activity instanceof Announce) { String messageUri = activity.getObject().getId(); applicationEventPublisher.publishEvent(new AnnounceEvent(this, activity.getActor(), messageUri)); return new ResponseEntity<>(CommandResult.fromString("Like/announce request accepted"), HttpStatus.OK); } if (activity instanceof Flag) { URI actor = URI.create(activity.getActor()); logger.info("{} flag some objects: {}", actor, activity.getObject()); return new ResponseEntity<>(CommandResult.fromString("Report accepted"), HttpStatus.ACCEPTED); } if (activity instanceof Update) { if (activity.getObject() instanceof Person && activity.getActor().equals(activity.getObject().getId())) { logger.info("{} update they profile"); return new ResponseEntity<>(CommandResult.fromString("Update accepted"), HttpStatus.ACCEPTED); } if (activity.getObject() instanceof Note) { Note note = (Note) activity.getObject(); logger.info("Got update to {}", note.getId()); if (activity.getActor().equals(note.getAttributedTo())) { Message reply = messagesService.getReplyByUri(note.getId()); if (reply != null) { if (messagesService.updateMessage(reply.getMid(), reply.getRid(), formatNote(note), true)) { logger.info("{} update they message {}", activity.getActor(), note.getId()); return new ResponseEntity<>(HttpStatus.ACCEPTED); } logger.warn("Unable to update {}", note.getId()); return new ResponseEntity<>(HttpStatus.SERVICE_UNAVAILABLE); } else { logger.warn("Update not found: {}", note.getId()); return new ResponseEntity<>(HttpStatus.ACCEPTED); } } else { logger.warn("Invalid Update: {}", jsonMapper.writeValueAsString(activity)); return new ResponseEntity<>(HttpStatus.BAD_REQUEST); } } } if (activity instanceof Block) { logger.info("{} blocks {} (room_full_of_people_who_care.jpg)", activity.getActor(), activity.getObject().getId()); } logger.warn("Unknown activity: {}", jsonMapper.writeValueAsString(activity)); return new ResponseEntity<>(CommandResult.fromString("Unknown activity"), HttpStatus.NOT_IMPLEMENTED); } if (activity instanceof Delete) { // Delete gone user if (activity.getActor().equals(activity.getObject().getId())) { return new ResponseEntity<>(CommandResult.fromString("Delete request accepted"), HttpStatus.ACCEPTED); } } return new ResponseEntity<>(CommandResult.fromString("Can not authenticate"), HttpStatus.UNAUTHORIZED); } @PostMapping(value = { "/u/", "/api/u/" }, produces = MediaType.APPLICATION_JSON_VALUE) public User fetchUser(@RequestParam URI uri) throws JsonProcessingException, HttpBadRequestException { return activityPubManager.actorToUser(uri); } }