/*
* 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.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.KeystoreManager;
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.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.objects.Actor;
import com.juick.www.api.activity.model.objects.Application;
import com.juick.www.api.activity.model.objects.Image;
import com.juick.www.api.activity.model.objects.Key;
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.www.api.activity.model.objects.Tombstone;
import com.juick.util.HttpNotFoundException;
import com.juick.www.WebApp;
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.juick.service.security.annotation.Visitor;
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.context.ApplicationEventPublisher;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
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.Collectors;
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 KeystoreManager keystoreManager;
@Inject
private ActivityPubManager activityPubManager;
@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 WebApp webApp;
@Inject
private Remark remarkConverter;
@Inject
private User serviceUser;
@GetMapping(value = "/u/{userName}", produces = { Context.LD_JSON_MEDIA_TYPE,
Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE, Context.FALLBACK_JSON_MEDIA_TYPE })
public Actor getUser(@PathVariable String userName) {
User user = userService.getUserByName(userName);
if (!user.isAnonymous()) {
Actor profile = user.equals(serviceUser) ? new Application() : new Person();
profile.setId(activityPubManager.personUri(user));
profile.setUrl(activityPubManager.personWebUri(user));
profile.setName(userName);
profile.setPreferredUsername(userName);
Key publicKey = new Key();
publicKey.setId(profile.getId() + "#main-key");
publicKey.setOwner(profile.getId());
publicKey.setPublicKeyPem(keystoreManager.getPublicKeyPem());
profile.setPublicKey(publicKey);
profile.setInbox(activityPubManager.inboxUri());
profile.setOutbox(activityPubManager.outboxUri(user));
profile.setFollowers(activityPubManager.followersUri(user));
profile.setFollowing(activityPubManager.followingUri(user));
Image avatar = new Image();
avatar.setUrl(webApp.getAvatarUrl(user));
avatar.setMediaType("image/png");
profile.setIcon(avatar);
return (Actor) Context.build(profile);
}
throw new HttpNotFoundException();
}
@GetMapping(value = "/u/{userName}/blog/toc", produces = { Context.LD_JSON_MEDIA_TYPE,
Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE })
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.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE })
public OrderedCollectionPage getOutboxPage(@Visitor 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)
.collect(Collectors.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;
}).collect(Collectors.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.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE })
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.getStatsMyReaders(user.getUid()));
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.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE })
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(activityPubManager.personWebUri(a));
return follower;
}).collect(Collectors.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.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE })
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.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE })
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(activityPubManager.personWebUri(a));
return follower;
}).collect(Collectors.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.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE })
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();
}
@PostMapping(value = "/api/inbox", consumes = { Context.LD_JSON_MEDIA_TYPE,
Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE })
public ResponseEntity processInbox(@Visitor 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) {
Activity object = (Activity) activity.getObject();
if (object instanceof Follow) {
applicationEventPublisher
.publishEvent(new UndoFollowEvent(this, activity.getActor(), 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(), 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 = activityPubManager.postId(inReplyTo);
User user = new User();
user.setUri(URI.create(activity.getActor()));
String markdown = remarkConverter.convertFragment((String) note.getContent());
String commandBody = 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));
CommandResult result = commandsManager.processCommand(user,
String.format("#%s %s", postId, commandBody), 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()));
String markdown = remarkConverter.convertFragment((String) note.getContent());
// combine note text with attachment urls
String commandBody = 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));
CommandResult result = commandsManager.processCommand(user,
String.format("#%d/%d %s", reply.getMid(), reply.getRid(), commandBody),
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) {
// Delete gone user
// TODO: check if it is really deleted and remove copy-paste
if (activity.getActor().equals(activity.getObject().getId())) {
return new ResponseEntity<>(CommandResult.fromString("Delete request accepted"),
HttpStatus.ACCEPTED);
}
if (activity.getObject() instanceof Tombstone) {
Tombstone tombstone = (Tombstone) activity.getObject();
URI actor = URI.create(activity.getActor());
URI reply = URI.create((String) tombstone.getId());
messagesService.deleteReply(actor, reply);
return new ResponseEntity<>(CommandResult.fromString("Delete request accepted"), HttpStatus.OK);
}
}
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);
}
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) {
return activityPubManager.personToUser(uri);
}
}