diff options
Diffstat (limited to 'src/main/java/com/juick/server')
114 files changed, 11727 insertions, 0 deletions
diff --git a/src/main/java/com/juick/server/ActivityPubManager.java b/src/main/java/com/juick/server/ActivityPubManager.java new file mode 100644 index 00000000..4601f7d1 --- /dev/null +++ b/src/main/java/com/juick/server/ActivityPubManager.java @@ -0,0 +1,331 @@ +package com.juick.server; + +import com.juick.Message; +import com.juick.User; +import com.juick.formatters.PlainTextFormatter; +import com.juick.server.api.activity.model.Context; +import com.juick.server.api.activity.model.activities.Accept; +import com.juick.server.api.activity.model.activities.Announce; +import com.juick.server.api.activity.model.activities.Create; +import com.juick.server.api.activity.model.activities.Delete; +import com.juick.server.api.activity.model.objects.Hashtag; +import com.juick.server.api.activity.model.objects.Image; +import com.juick.server.api.activity.model.objects.Mention; +import com.juick.server.api.activity.model.objects.Note; +import com.juick.server.api.activity.model.objects.Person; +import com.juick.server.util.HttpUtils; +import com.juick.service.SocialService; +import com.juick.service.UserService; +import com.juick.service.activities.*; +import com.juick.service.component.*; +import com.juick.util.MessageUtils; +import com.mitchellbosecke.pebble.PebbleEngine; +import com.mitchellbosecke.pebble.template.PebbleTemplate; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import javax.annotation.Nonnull; +import javax.annotation.PostConstruct; +import javax.inject.Inject; +import java.io.IOException; +import java.io.StringWriter; +import java.io.Writer; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Component +public class ActivityPubManager implements ActivityListener, NotificationListener { + private static final Logger logger = LoggerFactory.getLogger(ActivityPubManager.class); + @Inject + private SignatureManager signatureManager; + @Inject + private SocialService socialService; + @Inject + private UserService userService; + @Inject + private PebbleEngine pebbleEngine; + @Value("${ap_base_uri:http://localhost:8080/}") + private String baseUri; + @Value("${service_user:juick}") + private String serviceUsername; + + private User serviceUser; + + @PostConstruct + public void init() { + serviceUser = userService.getUserByName(serviceUsername); + } + + @Override + public void processFollowEvent(@Nonnull FollowEvent followEvent) { + String acct = (String)followEvent.getRequest().getObject(); + logger.info("received follower request to {}", acct); + User followedUser = socialService.getUserByAccountUri(acct); + if (!followedUser.isAnonymous()) { + // automatically accept follower requests + Person me = (Person) signatureManager.getContext(URI.create(acct)).get(); + Person follower = (Person) signatureManager.getContext(URI.create(followEvent.getRequest().getActor())).get(); + Accept accept = new Accept(); + accept.setActor(me.getId()); + accept.setObject(followEvent.getRequest()); + try { + signatureManager.post(me, follower, accept); + socialService.addFollower(followedUser, follower.getId()); + logger.info("Follower added for {}", followedUser.getName()); + } catch (IOException e) { + logger.info("activitypub exception", e); + } + } + } + + @Override + public void undoFollowEvent(UndoFollowEvent event) { + String actor = event.getActor(); + String me = event.getObject(); + logger.info("{} stopping to follow {}", actor, me); + User followedUser = socialService.getUserByAccountUri(me); + if (!followedUser.isAnonymous()) { + socialService.removeFollower(followedUser, actor); + } + } + + @Override + public void deleteUserEvent(DeleteUserEvent event) { + String acct = event.getUserUri(); + logger.info("Deleting {} from followers", acct); + socialService.removeAccount(acct); + } + + @Override + public void deleteMessageEvent(DeleteMessageEvent event) { + Message msg = event.getMessage(); + User user = msg.getUser(); + String userUri = personUri(user); + Note note = makeNote(msg); + Person me = (Person) signatureManager.getContext(URI.create(userUri)).get(); + socialService.getFollowers(user).forEach(acct -> { + Person follower = (Person) signatureManager.getContext(URI.create(acct)).get(); + Delete delete = new Delete(); + delete.setId(note.getId()); + delete.setActor(me.getId()); + delete.setPublished(note.getPublished()); + delete.setObject(note); + try { + logger.info("Deletion to follower {}", follower.getId()); + signatureManager.post(me, follower, delete); + } catch (IOException e) { + logger.warn("activitypub exception", e); + } + }); + } + + @Override + public void processMessageEvent(MessageEvent messageEvent) { + Message msg = messageEvent.getMessage(); + if (MessageUtils.isPM(msg)) { + return; + } + User user = msg.getUser(); + String userUri = personUri(user); + Note note = makeNote(msg); + Person me = (Person) signatureManager.getContext(URI.create(userUri)).get(); + Set<String> subscribers = new HashSet<>(socialService.getFollowers(user)); + if (MessageUtils.isReply(msg) && msg.getTo().getUri().toASCIIString().length() > 0) { + String replier = msg.getTo().getUri().toASCIIString(); + subscribers.add(replier); + List<String> cc = new ArrayList<>(note.getCc()); + cc.add(replier); + note.setCc(cc); + } + subscribers.forEach(acct -> { + Optional<Context> context = signatureManager.getContext(URI.create(acct)); + if (context.isPresent()) { + Person follower = (Person)context.get(); + Create create = new Create(); + create.setId(note.getId()); + create.setActor(me.getId()); + create.setPublished(note.getPublished()); + create.setObject(note); + try { + logger.info("Posting to subscriber {}", follower.getId()); + signatureManager.post(me, follower, create); + } catch (IOException e) { + logger.warn("activitypub exception", e); + } + } + }); + } + + public String inboxUri() { + UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUri); + return uri.replacePath("/api/inbox").toUriString(); + } + + public String outboxUri(User user) { + UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUri); + return uri.replacePath(String.format("/u/%s/blog/toc", user.getName())).toUriString(); + } + + public String personUri(User user) { + if (user.getUri().toString().length() > 0) { + return user.getUri().toASCIIString(); + } + UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUri); + return uri.replacePath(String.format("/u/%s", user.getName())).toUriString(); + } + public String personWebUri(User user) { + UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUri); + return uri.replacePath(String.format("/%s/", user.getName())).toUriString(); + } + + public String followersUri(User user) { + UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUri); + return uri.replacePath(String.format("/u/%s/followers/toc", user.getName())).toUriString(); + } + + public String followingUri(User user) { + UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUri); + return uri.replacePath(String.format("/u/%s/following/toc", user.getName())).toUriString(); + } + public String messageUri(Message msg) { + return messageUri(msg.getMid(), msg.getRid()); + } + public String messageUri(int mid, int rid) { + UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUri); + uri.replacePath(String.format("/n/%d-%d", mid, rid)); + return uri.toUriString(); + } + public String tagUri(com.juick.Tag tag) { + UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUri); + return uri.replacePath(String.format("/t/%s", tag.getName())).toUriString(); + } + + public Note makeNote(Message msg) { + Note note = new Note(); + note.setId(messageUri(msg)); + note.setUrl(PlainTextFormatter.formatUrl(msg)); + note.setAttributedTo(personUri(msg.getUser())); + if (MessageUtils.isReply(msg)) { + if (msg.getReplyToUri().toASCIIString().length() > 0) { + note.setInReplyTo(msg.getReplyToUri().toASCIIString()); + } else { + note.setInReplyTo(messageUri(msg.getMid(), msg.getReplyto())); + } + } + if (MessageUtils.isPM(msg)) { + note.setTo(Collections.singletonList(personUri(msg.getTo()))); + } else { + note.setTo(Collections.singletonList("https://www.w3.org/ns/activitystreams#Public")); + note.setCc(Collections.singletonList(followersUri(msg.getUser()))); + } + note.setPublished(msg.getTimestamp()); + if (StringUtils.isNotBlank(msg.getAttachmentType())) { + Image attachment = new Image(); + attachment.setId(msg.getAttachment().getMedium().getUrl()); + attachment.setUrl(msg.getAttachment().getMedium().getUrl()); + attachment.setMediaType(HttpUtils.mediaType(msg.getAttachmentType())); + note.setAttachment(Collections.singletonList(attachment)); + } + note.setTags(msg.getTags().stream().map(t -> { + Hashtag hashtag = new Hashtag(); + hashtag.setId(tagUri(t)); + hashtag.setName(t.getName()); + return hashtag; + }).collect(Collectors.toList())); + if (msg.getReplyToUri() != null && msg.getReplyToUri().toASCIIString().length() > 0) { + Optional<Context> noteContext = signatureManager.getContext(msg.getReplyToUri()); + if (noteContext.isPresent()) { + Note activity = (Note) noteContext.get(); + Optional<Context> personContext = signatureManager.getContext(URI.create(activity.getAttributedTo())); + if (personContext.isPresent()) { + Person person = (Person) personContext.get(); + note.getTags().add(new Mention(person.getUrl(), person.getPreferredUsername())); + msg.getTo().setName(person.getPreferredUsername()); + note.setInReplyTo(activity.getInReplyTo()); + } + } + } else if (MessageUtils.isReply(msg)) { + note.getTags().add(new Mention(personWebUri(msg.getTo()), msg.getTo().getName())); + } + MessageUtils.getGlobalMentions(msg).forEach(m -> { + // @user@server.tld -> user@server.tld + Optional<Context> personContext = signatureManager.discoverPerson(m.substring(1)); + if (personContext.isPresent()) { + Person person = (Person) personContext.get(); + note.getTags().add(new Mention(person.getUrl(), person.getPreferredUsername())); + List<String> cc = new ArrayList<>(note.getCc()); + cc.add(person.getUrl()); + note.setCc(cc); + } + }); + if (msg.isHtml()) { + note.setContent(msg.getText()); + } else { + PebbleTemplate noteTemplate = pebbleEngine.getTemplate("layouts/note"); + Map<String, Object> context = new HashMap<>(); + context.put("msg", msg); + context.put("baseUri", baseUri); + try { + Writer writer = new StringWriter(); + noteTemplate.evaluate(writer, context); + note.setContent(writer.toString()); + } catch (IOException e) { + logger.warn("template not rendered, falling back"); + note.setContent(MessageUtils.formatMessage(StringUtils.defaultString(msg.getText()))); + } + } + return note; + } + + @Override + public void processSubscribeEvent(SubscribeEvent subscribeEvent) { + + } + + @Override + public void processLikeEvent(LikeEvent likeEvent) { + + } + + @Override + public void processPingEvent(PingEvent pingEvent) { + + } + + @Override + public void processMessageReadEvent(MessageReadEvent messageReadEvent) { + + } + + @Override + public void processTopEvent(TopEvent topEvent) { + Message message = topEvent.getMessage(); + Note note = makeNote(message); + Announce announce = new Announce(); + announce.setId(note.getId() + "#top"); + announce.setActor(personUri(serviceUser)); + announce.setObject(note); + Person me = (Person) signatureManager.getContext(URI.create(announce.getActor())).get(); + socialService.getFollowers(serviceUser).forEach(acct -> { + Person follower = (Person) signatureManager.getContext(URI.create(acct)).get(); + try { + logger.info("Announcing top: {}", message.getMid()); + signatureManager.post(me, follower, announce); + } catch (IOException e) { + logger.warn("activitypub exception", e); + } + }); + } +} diff --git a/src/main/java/com/juick/server/CommandsManager.java b/src/main/java/com/juick/server/CommandsManager.java new file mode 100644 index 00000000..82143482 --- /dev/null +++ b/src/main/java/com/juick/server/CommandsManager.java @@ -0,0 +1,540 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server; + +import com.juick.Message; +import com.juick.Tag; +import com.juick.User; +import com.juick.formatters.PlainTextFormatter; +import com.juick.service.activities.DeleteMessageEvent; +import com.juick.service.component.*; +import com.juick.model.CommandResult; +import com.juick.model.TagStats; +import com.juick.server.helpers.annotation.UserCommand; +import com.juick.server.util.HttpUtils; +import com.juick.service.*; +import com.juick.util.MessageUtils; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; +import org.apache.commons.lang3.reflect.MethodUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +import javax.annotation.Nonnull; +import javax.inject.Inject; +import java.lang.reflect.Method; +import java.net.URI; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * + * @author ugnich + */ +@Component +public class CommandsManager { + @Inject + private MessagesService messagesService; + @Inject + private UserService userService; + @Inject + private TagService tagService; + @Inject + private PMQueriesService pmQueriesService; + @Inject + private ShowQueriesService showQueriesService; + @Inject + private PrivacyQueriesService privacyQueriesService; + @Inject + private SubscriptionService subscriptionService; + @Value("${upload_tmp_dir:#{systemEnvironment['TEMP'] ?: '/tmp'}}") + private String tmpDir; + @Value("${img_path:#{systemEnvironment['TEMP'] ?: '/tmp'}}") + private String imgDir; + @Inject + private ApplicationEventPublisher applicationEventPublisher; + @Inject + private ImagesService imagesService; + + public CommandResult processCommand(User user, String data, @Nonnull URI attachment) throws Exception { + if (!user.isAnonymous()) { + userService.updateLastSeen(user); + } + String strippedData = StringUtils.stripStart(data, null); + if (strippedData.startsWith("?OTR")) { + return CommandResult.fromString("?OTR Error: we are not using OTR"); + } + String input = MessageUtils.stripNonSafeUrls(strippedData); + Optional<Method> cmd = MethodUtils.getMethodsListWithAnnotation(getClass(), UserCommand.class).stream() + .filter(m -> Pattern.compile(m.getAnnotation(UserCommand.class).pattern(), + m.getAnnotation(UserCommand.class).patternFlags()).matcher(input).matches()) + .findFirst(); + if (cmd.isPresent()) { + Matcher matcher = Pattern.compile(cmd.get().getAnnotation(UserCommand.class).pattern(), + cmd.get().getAnnotation(UserCommand.class).patternFlags()).matcher(input); + List<String> groups = new ArrayList<>(); + while (matcher.find()) { + for (int i = 1; i <= matcher.groupCount(); i++) { + groups.add(matcher.group(i)); + } + } + CommandResult commandResult = (CommandResult) getClass().getMethod(cmd.get().getName(), User.class, URI.class, String[].class) + .invoke(this, user, attachment, groups.toArray(new String[groups.size()])); + if (StringUtils.isNotEmpty(commandResult.getText())) { + return commandResult; + } + } + Pair<String, List<Tag>> tags = tagService.fromString(input); + if (tags.getRight().size() > 5) { + return CommandResult.fromString("Sorry, 5 tags maximum."); + } + // new message + String body = tags.getLeft().trim(); + boolean haveAttachment = StringUtils.isNotEmpty(attachment.toString()); + String attachmentFName = null; + String attachmentType = null; + if (haveAttachment) { + attachmentFName = attachment.getScheme().equals("juick") ? attachment.getHost() + : HttpUtils.downloadImage(attachment.toURL(), tmpDir).getHost(); + attachmentType = attachmentFName.substring(attachmentFName.length() - 3); + } + int mid = messagesService.createMessage(user.getUid(), body, attachmentType, tags.getRight()); + if (haveAttachment) { + String fname = String.format("%d.%s", mid, attachmentType); + imagesService.saveImageWithPreviews(attachmentFName, fname); + } + Message msg = messagesService.getMessage(mid); + subscriptionService.subscribeMessage(msg, user); + + applicationEventPublisher.publishEvent(new MessageReadEvent(this, user, msg)); + applicationEventPublisher.publishEvent(new MessageEvent(this, msg, subscriptionService.getSubscribedUsers(msg.getUser().getUid(), msg))); + return CommandResult.build(msg, "New message posted.\n#" + msg.getMid() + " https://juick.com/m/" + msg.getMid(), String.format("[New message](%s) posted", PlainTextFormatter.formatUrl(msg))); + } + + @UserCommand(pattern = "^ping$", patternFlags = Pattern.CASE_INSENSITIVE, + help = "PING - returns you a PONG") + public CommandResult commandPing(User user, URI attachment, String[] input) { + applicationEventPublisher.publishEvent(new PingEvent(this, user)); + return CommandResult.fromString("PONG"); + } + + @UserCommand(pattern = "^help$", patternFlags = Pattern.CASE_INSENSITIVE, + help = "HELP - returns this help message") + public CommandResult commandHelp(User user, URI attachment, String[] input) { + return CommandResult.fromString(Arrays.stream(getClass().getDeclaredMethods()) + .filter(m -> m.isAnnotationPresent(UserCommand.class)) + .map(m -> m.getAnnotation(UserCommand.class).help()) + .collect(Collectors.joining("\n"))); + } + + @UserCommand(pattern = "^login$", patternFlags = Pattern.CASE_INSENSITIVE, + help = "LOGIN - log in to Juick website") + public CommandResult commandLogin(User user_from, URI attachment, String[] input) { + return CommandResult.fromString("http://juick.com/login?hash=" + userService.getHashByUID(user_from.getUid())); + } + @UserCommand(pattern = "^\\@(\\S+)\\s+([\\s\\S]+)$", help = "@username message - send PM to username") + public CommandResult commandPM(User user_from, URI attachment, String... arguments) { + String body = arguments[1]; + + User user_to = userService.getUserByName(arguments[0]); + + if (!user_to.isAnonymous()) { + if (!userService.isInBLAny(user_to.getUid(), user_from.getUid())) { + if (pmQueriesService.createPM(user_from.getUid(), user_to.getUid(), body)) { + com.juick.Message jmsg = new com.juick.Message(); + jmsg.setUser(user_from); + jmsg.setTo(user_to); + jmsg.setText(body); + applicationEventPublisher.publishEvent(new MessageEvent(this, jmsg, Collections.singletonList(user_to))); + return CommandResult.fromString("Private message sent"); + } + } + } + return CommandResult.fromString("Error"); + } + @UserCommand(pattern = "^bl$", patternFlags = Pattern.CASE_INSENSITIVE, + help = "BL - Show your blacklist") + public CommandResult commandBLShow(User user_from, URI attachment, String... arguments) { + List<User> blusers = userService.getUserBLUsers(user_from.getUid()); + List<String> bltags = tagService.getUserBLTags(user_from.getUid()); + + String txt = StringUtils.EMPTY; + if (bltags.size() > 0) { + for (String bltag : bltags) { + txt += "*" + bltag + "\n"; + } + + if (blusers.size() > 0) { + txt += "\n"; + } + } + if (blusers.size() > 0) { + for (User bluser : blusers) { + txt += "@" + bluser.getName() + "\n"; + } + } + if (txt.isEmpty()) { + txt = "You don't have any users or tags in your blacklist."; + } + + return CommandResult.fromString(txt); + } + + @UserCommand(pattern = "^#\\+$", help = "#+ - Show last Juick messages") + public CommandResult commandLast(User user_from, URI attachment, String... arguments) { + return CommandResult.fromString("Last messages:\n" + + printMessages(user_from, messagesService.getAll(user_from.getUid(), 0), true)); + } + + @UserCommand(pattern = "@", help = "@ - Show recommendations and popular personal blogs") + public CommandResult commandUsers(User user_from, URI attachment, String... arguments) { + StringBuilder msg = new StringBuilder(); + msg.append("Recommended blogs"); + List<String> recommendedUsers = showQueriesService.getRecommendedUsers(user_from); + if (recommendedUsers.size() > 0) { + for (String user : recommendedUsers) { + msg.append("\n@").append(user); + } + } else { + msg.append("\nNo recommendations now. Subscribe to more blogs. ;)"); + } + msg.append("\n\nTop 10 personal blogs:"); + List<String> topUsers = showQueriesService.getTopUsers(); + if (topUsers.size() > 0) { + for (String user : topUsers) { + msg.append("\n@").append(user); + } + } else { + msg.append("\nNo top users. Empty DB? ;)"); + } + return CommandResult.fromString(msg.toString()); + } + @UserCommand(pattern = "^bl\\s+@([^\\s\\n\\+]+)", patternFlags = Pattern.CASE_INSENSITIVE, + help = "BL @username - add @username to your blacklist") + public CommandResult blacklistUser(User user_from, URI attachment, String... arguments) { + User blUser = userService.getUserByName(arguments[0]); + if (!blUser.isAnonymous()) { + PrivacyQueriesService.PrivacyResult result = privacyQueriesService.blacklistUser(user_from, blUser); + if (result == PrivacyQueriesService.PrivacyResult.Added) { + return CommandResult.fromString("User added to your blacklist"); + } else { + return CommandResult.fromString("User removed from your blacklist"); + } + } + return CommandResult.fromString("User not found"); + } + @UserCommand(pattern = "^bl\\s\\*(\\S+)$", patternFlags = Pattern.CASE_INSENSITIVE, + help = "BL *tag - add *tag to your blacklist") + public CommandResult blacklistTag(User user_from, URI attachment, String... arguments) { + if (!user_from.isAnonymous()) { + Tag tag = tagService.getTag(arguments[0], false); + if (tag != null) { + PrivacyQueriesService.PrivacyResult result = privacyQueriesService.blacklistTag(user_from, tag); + if (result == PrivacyQueriesService.PrivacyResult.Added) { + return CommandResult.fromString("Tag added to your blacklist"); + } else { + return CommandResult.fromString("Tag removed from your blacklist"); + } + } + } + return CommandResult.fromString("Tag not found"); + } + @UserCommand(pattern = "\\*", help = "* - Show your tags") + public CommandResult commandTags(User currentUser, URI attachment, String... args) { + List<TagStats> tags = tagService.getUserTagStats(currentUser.getUid()); + String msg = "Your tags: (tag - messages)\n" + + tags.stream() + .map(t -> String.format("\n*%s - %d", t.getTag().getName(), t.getUsageCount())).collect(Collectors.joining()); + return CommandResult.fromString(msg); + } + @UserCommand(pattern = "S", help = "S - Show your subscriptions", patternFlags = Pattern.CASE_INSENSITIVE) + public CommandResult commandSubscriptions(User currentUser, URI attachment, String... args) { + List<User> friends = userService.getUserFriends(currentUser.getUid()); + List<String> tags = subscriptionService.getSubscribedTags(currentUser); + String msg = friends.size() > 0 ? "You are subscribed to users:" + friends.stream().map(u -> "\n@" + u.getName()) + .collect(Collectors.joining()) + : "You are not subscribed to any user."; + msg += tags.size() > 0 ? "\nYou are subscribed to tags:" + tags.stream().map(t -> "\n*" + t) + .collect(Collectors.joining()) + : "\nYou are not subscribed to any tag."; + return CommandResult.fromString(msg); + } + @UserCommand(pattern = "!", help = "! - Show your favorite messages") + public CommandResult commandFavorites(User currentUser, URI attachment, String... args) { + List<Integer> mids = messagesService.getUserRecommendations(currentUser.getUid(), 0); + if (mids.size() > 0) { + return CommandResult.fromString("Favorite messages: \n" + printMessages(currentUser, mids, false)); + } + return CommandResult.fromString("No favorite messages, try to \"like\" something ;)"); + } + @UserCommand(pattern = "^\\!\\s+#(\\d+)", help = "! #12345 - recommend message") + public CommandResult commandRecommend(User user, URI attachment, String... arguments) { + int mid = NumberUtils.toInt(arguments[0], 0); + if (mid > 0) { + com.juick.Message msg = messagesService.getMessage(mid); + if (msg != null) { + if (msg.getUser() == user) { + return CommandResult.fromString("You can't recommend your own messages."); + } + MessagesService.RecommendStatus status = messagesService.recommendMessage(mid, user.getUid()); + switch (status) { + case Added: + applicationEventPublisher.publishEvent(new LikeEvent(this, user, msg, + subscriptionService.getUsersSubscribedToUserRecommendations( + user.getUid(), msg))); + return CommandResult.fromString("Message is added to your recommendations"); + case Deleted: + return CommandResult.fromString("Message deleted from your recommendations."); + } + } + return CommandResult.fromString("Message not found"); + } + return CommandResult.fromString("Message not found"); + } + // TODO: target notification + @UserCommand(pattern = "^(s|u)\\s+\\@(\\S+)$", help = "S @username - subscribe to user" + + "\nU @username - unsubscribe from user", patternFlags = Pattern.CASE_INSENSITIVE) + public CommandResult commandSubscribeUser(User user, URI attachment, String... args) { + boolean subscribe = args[0].equalsIgnoreCase("s"); + User toUser = userService.getUserByName(args[1]); + if (toUser.isAnonymous()) { + return CommandResult.fromString("User not found"); + } + if (subscribe) { + if (subscriptionService.subscribeUser(user, toUser)) { + // TODO: already subscribed case + applicationEventPublisher.publishEvent(new SubscribeEvent(this, user, toUser)); + return CommandResult.fromString("Subscribed to @" + toUser.getName()); + } + } else { + if (subscriptionService.unSubscribeUser(user, toUser)) { + return CommandResult.fromString("Unsubscribed from @" + toUser.getName()); + } + return CommandResult.fromString("You were not subscribed to @" + toUser.getName()); + } + return CommandResult.fromString("Error"); + } + @UserCommand(pattern = "^(s|u)\\s+\\*(\\S+)$", help = "S *tag - subscribe to tag" + + "\nU *tag - unsubscribe from tag", patternFlags = Pattern.CASE_INSENSITIVE) + public CommandResult commandSubscribeTag(User user, URI attachment, String... args) { + boolean subscribe = args[0].equalsIgnoreCase("s"); + Tag tag = tagService.getTag(args[1], true); + if (subscribe) { + if (subscriptionService.subscribeTag(user, tag)) { + return CommandResult.fromString("Subscribed"); + } + } else { + if (subscriptionService.unSubscribeTag(user, tag)) { + return CommandResult.fromString("Unsubscribed from " + tag.getName()); + } + return CommandResult.fromString("You were not subscribed to " + tag.getName()); + } + return CommandResult.fromString("Error"); + } + @UserCommand(pattern = "^(s|u)\\s+#(\\d+)$", help = "S #1234 - subscribe to comments" + + "\nU #1234 - unsubscribe from comments", patternFlags = Pattern.CASE_INSENSITIVE) + public CommandResult commandSubscribeMessage(User user, URI attachment, String... args) { + boolean subscribe = args[0].equalsIgnoreCase("s"); + int mid = NumberUtils.toInt(args[1], 0); + Message msg = messagesService.getMessage(mid); + if (msg != null) { + if (subscribe) { + if (subscriptionService.subscribeMessage(msg, user)) { + applicationEventPublisher.publishEvent( + new MessageReadEvent(this, user, msg)); + return CommandResult.fromString("Subscribed"); + } + } else { + if (subscriptionService.unSubscribeMessage(mid, user.getUid())) { + return CommandResult.fromString("Unsubscribed from #" + mid); + } + return CommandResult.fromString("You were not subscribed to #" + mid); + } + } + return CommandResult.fromString("Error"); + } + @UserCommand(pattern = "^(on|off)$", patternFlags = Pattern.CASE_INSENSITIVE, + help = "ON/OFF - Enable/disable subscriptions delivery") + public CommandResult commandOnOff(User user, URI attachment, String[] input) { + UserService.ActiveStatus newStatus; + String retValUpdated; + if (input[0].toLowerCase().equals("on")) { + newStatus = UserService.ActiveStatus.Active; + retValUpdated = "XMPP notifications are activated"; + } else { + newStatus = UserService.ActiveStatus.Inactive; + retValUpdated = "XMPP notifications are disabled"; + } + if (userService.getAllJIDs(user).stream().allMatch(jid -> userService.setActiveStatusForJID(jid, newStatus))) { + return CommandResult.fromString(retValUpdated); + } + return CommandResult.fromString("Error"); + } + @UserCommand(pattern = "^\\@([^\\s\\n\\+]+)(\\+?)$", + help = "@username+ - Show user's info and last 20 messages") + public CommandResult commandUser(User user, URI attachment, String... arguments) { + User blogUser = userService.getUserByName(arguments[0]); + int page = arguments[1].length(); + if (!blogUser.isAnonymous()) { + List<Integer> mids = messagesService.getUserBlog(blogUser.getUid(), 0, 0); + return CommandResult.fromString(String.format("Last messages from @%s:\n%s", arguments[0], + printMessages(user, mids, false))); + } + return CommandResult.fromString("User not found"); + } + @UserCommand(pattern = "^#(\\d+)(\\+?)$", help = "#1234 - Show message (#1234+ - message with replies)") + public CommandResult commandShow(User user, URI attachment, String... arguments) { + boolean showReplies = arguments[1].length() > 0; + int mid = NumberUtils.toInt(arguments[0], 0); + if (mid == 0) { + return CommandResult.fromString("Error"); + } + com.juick.Message msg = messagesService.getMessage(mid); + if (msg != null) { + if (showReplies) { + List<com.juick.Message> replies = messagesService.getReplies(user, mid); + applicationEventPublisher.publishEvent( + new MessageReadEvent(this, user, msg)); + replies.add(0, msg); + return CommandResult.fromString(String.join("\n", + replies.stream().map(PlainTextFormatter::formatPostSummary).collect(Collectors.toList()))); + } + return CommandResult.fromString(PlainTextFormatter.formatPost(msg)); + } + return CommandResult.fromString("Message not found"); + } + @UserCommand(pattern = "^#(\\d+)\\/(\\d+)$", help = "#1234/5 - Show reply") + public CommandResult commandShowReply(User user, URI attachment, String... arguments) { + int mid = NumberUtils.toInt(arguments[0], 0); + int rid = NumberUtils.toInt(arguments[1], 0); + com.juick.Message reply = messagesService.getReply(mid, rid); + if (reply != null) { + return CommandResult.fromString(PlainTextFormatter.formatPost(reply)); + } + return CommandResult.fromString("Reply not found"); + } + @UserCommand(pattern = "^\\*(\\S+)(\\+?)$", help = "*tag - Show last messages with tag") + public CommandResult commandShowTag(User user, URI attachment, String... arguments) { + if (StringUtils.isNotEmpty(attachment.toString())) { + // new message with tag + return CommandResult.fromString(StringUtils.EMPTY); + } + Tag tag = tagService.getTag(arguments[0], false); + if (tag != null) { + // TODO: synonyms + List<Integer> mids = messagesService.getTag(tag.TID, user.getUid(), 0, 10); + return CommandResult.fromString("Last messages with *" + tag.getName() + ":\n" + printMessages(user, mids, true)); + } + return CommandResult.fromString("Tag not found"); + } + @UserCommand(pattern = "^D #(\\d+)$", help = "D #1234 - Delete post", patternFlags = Pattern.CASE_INSENSITIVE) + public CommandResult commandDeletePost(User user, URI attachment, String... args) { + int mid = Integer.valueOf(args[0]); + Message message = messagesService.getMessage(mid); + if (message != null && messagesService.deleteMessage(user.getUid(), mid)) { + applicationEventPublisher.publishEvent(new DeleteMessageEvent(this, message)); + return CommandResult.fromString("Message deleted"); + } + return CommandResult.fromString("This is not your message"); + } + @UserCommand(pattern = "^D #(\\d+)(\\.|\\-|\\/)(\\d+)$", help = "D #1234/5 - Delete comment", patternFlags = Pattern.CASE_INSENSITIVE) + public CommandResult commandDeleteReply(User user, URI attachment, String... args) { + int mid = Integer.valueOf(args[0]); + int rid = Integer.valueOf(args[2]); + if (messagesService.deleteReply(user.getUid(), mid, rid)) { + return CommandResult.fromString("Reply deleted"); + } else { + return CommandResult.fromString("This is not your reply"); + } + } + @UserCommand(pattern = "^(D L|DL|D LAST)$", help = "D L - Delete last message", patternFlags = Pattern.CASE_INSENSITIVE) + public CommandResult commandDeleteLast(User user, URI attachment, String... args) { + return CommandResult.fromString("Temporarily unavailable"); + } + @UserCommand(pattern = "^\\?\\s+\\@([a-zA-Z0-9\\-\\.\\@]+)\\s+([\\s\\S]+)$", help = "? @user string - search in user messages") + public CommandResult commandSearch(User user, URI attachment, String... args) { + return CommandResult.fromString("Temporarily unavailable"); + } + @UserCommand(pattern = "^\\?\\s+([\\s\\S]+)$", help = "? string - search in all messages") + public CommandResult commandSearchAll(User user, URI attachment, String... args) { + return CommandResult.fromString("Temporarily unavailable"); + } + @UserCommand(pattern = "^(#+)$", help = "# - Show last messages from your feed (## - second page, ...)") + public CommandResult commandMyFeed(User user, URI attachment, String... arguments) { + // number of # is the page count + int page = arguments[0].length() - 1; + List<Integer> mids = messagesService.getMyFeed(user.getUid(), page, false); + if (mids.size() > 0) { + return CommandResult.fromString("Your feed: \n" + printMessages(user, mids, true)); + } + return CommandResult.fromString("Your feed is empty"); + } + @UserCommand(pattern = "^(#|\\.)(\\d+)((\\.|\\-|\\/)(\\d+))?\\s([\\s\\S]+)?", + help = "#1234 *tag *tag2 - edit tags\n#1234 text - reply to message") + public CommandResult EditOrReply(User user, @Nonnull URI attachment, String... args) throws Exception { + int mid = NumberUtils.toInt(args[1]); + int rid = NumberUtils.toInt(args[4], 0); + String txt = StringUtils.defaultString(args[5]); + Message msg = messagesService.getMessage(mid); + Pair<String, List<Tag>> messageTags = tagService.fromString(txt); + if (messageTags.getRight().size() > 0) { + if (user.getUid() != msg.getUser().getUid()) { + return CommandResult.fromString("It is not your message"); + } + if (!CollectionUtils.isEqualCollection(tagService.updateTags(mid, messageTags.getRight()), msg.getTags())) { + return CommandResult.fromString("Tags are updated"); + } else { + return CommandResult.fromString("Tags are NOT updated (5 tags maximum?)"); + } + } else { + boolean haveAttachment = StringUtils.isNotEmpty(attachment.toString()); + String attachmentFName = null; + String attachmentType = null; + if (haveAttachment) { + attachmentFName = attachment.getScheme().equals("juick") ? attachment.getHost() + : HttpUtils.downloadImage(attachment.toURL(), tmpDir).getHost(); + attachmentType = attachmentFName.substring(attachmentFName.length() - 3); + } + int newrid = messagesService.createReply(mid, rid, user, txt, attachmentType); + if (haveAttachment) { + String fname = String.format("%d-%d.%s", mid, newrid, attachmentType); + imagesService.saveImageWithPreviews(attachmentFName, fname); + } + applicationEventPublisher.publishEvent( + new MessageReadEvent(this, user, msg)); + Message original = messagesService.getMessage(mid); + subscriptionService.subscribeMessage(original, user); + Message reply = messagesService.getReply(mid, newrid); + applicationEventPublisher.publishEvent(new MessageEvent(this, reply, subscriptionService.getUsersSubscribedToComments(original, reply))); + return CommandResult.build(reply,"Reply posted.\n#" + mid + "/" + newrid + " " + + "https://juick.com/m/" + mid + "#" + newrid, + String.format("[Reply](%s) posted", PlainTextFormatter.formatUrl(reply))); + } + } + + String printMessages(User visitor, List<Integer> mids, boolean crop) { + return messagesService.getMessages(visitor, mids).stream() + .sorted(Collections.reverseOrder()) + .map(PlainTextFormatter::formatPostSummary).collect(Collectors.joining("\n\n")); + } +} diff --git a/src/main/java/com/juick/server/EmailManager.java b/src/main/java/com/juick/server/EmailManager.java new file mode 100644 index 00000000..1cdafac6 --- /dev/null +++ b/src/main/java/com/juick/server/EmailManager.java @@ -0,0 +1,165 @@ +package com.juick.server; + +import com.juick.Message; +import com.juick.User; +import com.juick.service.EmailService; +import com.juick.service.MessagesService; +import com.juick.service.UserService; +import com.juick.service.component.*; +import com.juick.util.MessageUtils; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import javax.annotation.Nonnull; +import javax.inject.Inject; +import javax.mail.MessagingException; +import javax.mail.Multipart; +import javax.mail.Session; +import javax.mail.Transport; +import javax.mail.internet.InternetAddress; +import javax.mail.internet.MimeBodyPart; +import javax.mail.internet.MimeMessage; +import javax.mail.internet.MimeMultipart; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +import static com.juick.formatters.PlainTextFormatter.formatPost; +import static com.juick.formatters.PlainTextFormatter.formatUrl; + +@Component +public class EmailManager implements NotificationListener { + + public static final String MSGID_PATTERN = "\\.|@|<"; + + private static final Logger logger = LoggerFactory.getLogger(EmailManager.class); + @Inject + private EmailService emailService; + @Inject + private MessagesService messagesService; + @Inject + private UserService userService; + @Override + public void processMessageEvent(@Nonnull MessageEvent event) { + Message msg = event.getMessage(); + List<User> subscribedUsers = event.getUsers(); + if (msg.isService()) { + return; + } + if (MessageUtils.isPM(msg)) { + String subject = String.format("Private message from %s", msg.getUser().getName()); + emailService.getEmails(msg.getTo().getUid(), true).forEach(email -> { + emailNotify(email, subject, msg); + }); + } else if (MessageUtils.isReply(msg)) { + Message originalMessage = messagesService.getMessage(msg.getMid()); + String subject = String.format("New reply to %s", originalMessage.getUser().getName()); + subscribedUsers.stream() + .flatMap(user -> emailService.getEmails(user.getUid(), true).stream()) + .forEach(email -> emailNotify(email, subject, msg)); + } else { + String subject = String.format("New message from %s", msg.getUser().getName()); + subscribedUsers + .forEach(user -> emailService.getEmails(user.getUid(), true) + .forEach(email -> emailNotify(email, subject, msg))); + } + } + + @Override + public void processSubscribeEvent(SubscribeEvent subscribeEvent) { + + } + + @Override + public void processLikeEvent(LikeEvent likeEvent) { + + } + + @Override + public void processPingEvent(PingEvent pingEvent) { + + } + + @Override + public void processMessageReadEvent(MessageReadEvent messageReadEvent) { + + } + + @Override + public void processTopEvent(TopEvent topEvent) { + + } + + private void emailNotify(String email, String subject, Message msg) { + Map<String, String> headers = new HashMap<>(); + if (!MessageUtils.isPM(msg)) { + headers.put("Message-ID", String.format("<%d.%d@juick.com>", msg.getMid(), msg.getRid())); + } + if (MessageUtils.isReply(msg)) { + if (msg.getReplyto() > 0) { + Message replyto = messagesService.getReply(msg.getMid(), msg.getReplyto()); + headers.put("In-Reply-To", String.format("<%d.%d@juick.com>", replyto.getMid(), replyto.getRid())); + } else { + Message original = messagesService.getMessage(msg.getMid()); + headers.put("In-Reply-To", String.format("<%d.%d@juick.com>", original.getMid(), original.getRid())); + } + } + String plainText = String.format("%s\n\n--\nYou are receiving this because you are subscribed to this user," + + " discussion, tag or mentioned. Reply to this email directly or view it on Juick: %s.", + formatPost(msg), formatUrl(msg)); + String hash = userService.getHashByUID(userService.getUserByEmail(email).getUid()); + String htmlText = String.format("%s<br /><br />--<br />You are receiving this because you are subscribed to this user" + + ", discussion, tag or mentioned. Reply to this email directly or <a href=\"%s\"><img src=\"https://api.juick.com/thread/mark_read/%d-%d.gif?hash=%s\" />view it</a> on Juick." + + "<br /><a href=\"https://juick.com/settings?hash=%s\">Configure or disable notifications</a>", + msg.isHtml() ? msg.getText() : MessageUtils.formatHtml(msg), formatUrl(msg), + msg.getMid(), msg.getRid(), hash, hash); + sendEmail(email, subject, plainText, htmlText, headers); + } + public void sendEmail(String to, String subject, String textPart, String htmlPart, Map<String, String> messageHeaders) { + Properties prop = System.getProperties(); + prop.put("mail.smtp.starttls.enable", "true"); + Session session = Session.getDefaultInstance(prop); + try { + Transport transport = session.getTransport("smtp"); + MimeMessage message = new MimeMessage(session) { + protected void updateMessageID() throws MessagingException { + for (Map.Entry<String, String> entry: messageHeaders.entrySet()) { + setHeader(entry.getKey(), entry.getValue()); + } + } + }; + message.setFrom(new InternetAddress("juick@juick.com")); + message.addRecipient(javax.mail.Message.RecipientType.TO, new InternetAddress(to)); + message.setSubject(subject); + MimeBodyPart textBodyPart = new MimeBodyPart(); + textBodyPart.setContent(textPart, "text/plain; charset=UTF-8"); + + Multipart multipart = new MimeMultipart("alternative"); + multipart.addBodyPart(textBodyPart); + if (StringUtils.isNotBlank(htmlPart)) { + MimeBodyPart htmlBodyPart = new MimeBodyPart(); + htmlBodyPart.setContent(htmlPart, "text/html; charset=UTF-8"); + multipart.addBodyPart(htmlBodyPart); + } + message.setContent(multipart); + User emailUser = userService.getUserByEmail(to); + if (!emailUser.isAnonymous()) { + message.setHeader("List-Id", "Juick notifications <mail-notifications.juick.com>"); + message.setHeader("List-Post", "<mailto:juick@juick.com>"); + message.setHeader("List-Owner", "<mailto:support@juick.com>"); + message.setHeader("List-Archive", "<https://juick.com/>"); + message.setHeader("List-Unsubscribe", String.format("https://juick.com/settings/unsubscribe?hash=%s", + userService.getHashByUID(emailUser.getUid()))); + message.setHeader("List-Unsubscribe-Post", "List-Unsubscribe=One-Click"); + } + message.saveChanges(); + transport.connect(); + transport.sendMessage(message, message.getAllRecipients()); + } catch (MessagingException ex) { + logger.error("mail exception", ex); + } + } +} diff --git a/src/main/java/com/juick/server/KeystoreManager.java b/src/main/java/com/juick/server/KeystoreManager.java new file mode 100644 index 00000000..97c3a224 --- /dev/null +++ b/src/main/java/com/juick/server/KeystoreManager.java @@ -0,0 +1,92 @@ +package com.juick.server; + +import com.juick.server.api.activity.model.objects.Person; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.Base64Utils; + +import javax.annotation.PostConstruct; +import javax.net.ssl.KeyManagerFactory; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.*; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.spec.X509EncodedKeySpec; +import java.util.Arrays; +import java.util.stream.Collectors; + +@Component +public class KeystoreManager { + private static final Logger logger = LoggerFactory.getLogger("com.juick.server"); + @Value("${keystore:../juick.p12}") + private String keystore; + @Value("${keystore_password:secret}") + private String keystorePassword; + + private KeyStore ks; + + private KeyManagerFactory kmf; + + @PostConstruct + public void init() { + try (InputStream ksIs = new FileInputStream(keystore)) { + ks = KeyStore.getInstance("PKCS12"); + ks.load(ksIs, keystorePassword.toCharArray()); + kmf = KeyManagerFactory.getInstance(KeyManagerFactory + .getDefaultAlgorithm()); + kmf.init(ks, keystorePassword.toCharArray()); + } catch (IOException | KeyStoreException | NoSuchAlgorithmException | UnrecoverableKeyException | CertificateException e) { + logger.error("Keystore error", e); + } + } + + public KeyStore getKeystore() { + return ks; + } + + public KeyManagerFactory getKeymanagerFactory() { + return kmf; + } + + private KeyPair getKeyPair() { + Key privateKey = null; + try { + privateKey = ks.getKey("1", keystorePassword.toCharArray()); + Certificate certificate = ks.getCertificate("1"); + return new KeyPair(certificate.getPublicKey(), (PrivateKey) privateKey); + } catch (KeyStoreException | NoSuchAlgorithmException | UnrecoverableKeyException e) { + e.printStackTrace(); + } + return null; + } + public PrivateKey getPrivateKey() { + return getKeyPair().getPrivate(); + } + public PublicKey getPublicKey() { + return getKeyPair().getPublic(); + } + public String getPublicKeyPem() { + String[] key = Base64Utils.encodeToString(getKeyPair().getPublic().getEncoded()).split("(?<=\\G.{64})"); + return String.format("-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n", + Arrays.asList(key).stream().collect(Collectors.joining("\n"))); + } + public static PublicKey publicKeyOf(Person person) { + String pubkeyPem = person.getPublicKey().getPublicKeyPem(); + String[] rawKey = pubkeyPem.split("\\n"); + String pubkeyData = String.join("", Arrays.asList(rawKey).subList(1, rawKey.length - 1)); + try{ + byte[] byteKey = Base64Utils.decodeFromString(pubkeyData); + X509EncodedKeySpec X509publicKey = new X509EncodedKeySpec(byteKey); + KeyFactory kf = KeyFactory.getInstance("RSA"); + return kf.generatePublic(X509publicKey); + } + catch(Exception e){ + e.printStackTrace(); + } + return null; + } +} diff --git a/src/main/java/com/juick/server/ServerManager.java b/src/main/java/com/juick/server/ServerManager.java new file mode 100644 index 00000000..ef848526 --- /dev/null +++ b/src/main/java/com/juick/server/ServerManager.java @@ -0,0 +1,295 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ +package com.juick.server; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.juick.Message; +import com.juick.User; +import com.juick.model.AnonymousUser; +import com.juick.service.MessagesService; +import com.juick.service.SubscriptionService; +import com.juick.service.UserService; +import com.juick.service.component.LikeEvent; +import com.juick.service.component.MessageEvent; +import com.juick.service.component.MessageReadEvent; +import com.juick.service.component.NotificationListener; +import com.juick.service.component.PingEvent; +import com.juick.service.component.SubscribeEvent; +import com.juick.service.component.TopEvent; +import com.juick.util.MessageUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import org.springframework.web.socket.TextMessage; + +import javax.annotation.PostConstruct; +import javax.inject.Inject; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; + +/** + * @author Ugnich Anton + */ +@Component +public class ServerManager implements NotificationListener { + private static Logger logger = LoggerFactory.getLogger(ServerManager.class); + + @Inject + private ObjectMapper jsonMapper; + @Inject + private MessagesService messagesService; + @Inject + private WebsocketManager wsHandler; + @Inject + private SubscriptionService subscriptionService; + @Inject + private UserService userService; + private CopyOnWriteArrayList<EventSession> sessions = new CopyOnWriteArrayList<>(); + + @Value("${service_user:juick}") + private String serviceUsername; + + private User serviceUser; + + @PostConstruct + public void init() { + serviceUser = userService.getUserByName(serviceUsername); + } + + + private void onJuickPM(final User to, final com.juick.Message jmsg) { + try { + String json = jsonMapper.writeValueAsString(jmsg); + synchronized (wsHandler.getClients()) { + wsHandler.getClients().stream().filter(c -> + (!c.legacy && c.visitor.getUid() == to.getUid()) || c.visitor.equals(serviceUser)) + .forEach(c -> { + try { + logger.debug("sending pm to {}", c.visitor.getUid()); + c.sendMessage(new TextMessage(json)); + } catch (IOException e) { + logger.warn("ws error", e); + } + }); + } + } catch (JsonProcessingException e) { + logger.warn("Invalid JSON", e); + } + messageEvent(jmsg, Collections.singletonList(to)); + } + + private void onJuickMessagePost(final com.juick.Message jmsg, List<User> subscribedUsers) { + try { + String json = jsonMapper.writeValueAsString(jmsg); + List<Integer> uids = subscribedUsers + .stream().map(User::getUid).collect(Collectors.toList()); + synchronized (wsHandler.getClients()) { + wsHandler.getClients().stream().filter(c -> + (!c.legacy && c.visitor.isAnonymous()) // anonymous users + || c.visitor.equals(serviceUser) // services + || (!c.legacy && uids.contains(c.visitor.getUid()))) // subscriptions + .forEach(c -> { + try { + logger.debug("sending message to {}", c.visitor.getUid()); + c.sendMessage(new TextMessage(json)); + } catch (IOException e) { + logger.warn("ws error", e); + } + }); + wsHandler.getClients().stream().filter(c -> + c.legacy && c.allMessages) // legacy all posts + .forEach(c -> { + try { + logger.debug("sending message to legacy client {}", c.visitor.getUid()); + c.sendMessage(new TextMessage(json)); + } catch (IOException e) { + logger.warn("ws error", e); + } + }); + } + } catch (JsonProcessingException e) { + logger.warn("Invalid JSON", e); + } + messageEvent(jmsg, subscribedUsers); + messageEvent(jmsg, Collections.singletonList(AnonymousUser.INSTANCE)); + } + + private void onJuickMessageReply(final com.juick.Message jmsg, final List<User> subscribedUsers) { + try { + + String json = jsonMapper.writeValueAsString(jmsg); + com.juick.Message op = messagesService.getMessage(jmsg.getMid()); + List<Integer> threadUsers = + subscribedUsers + .stream().map(User::getUid).collect(Collectors.toList()); + synchronized (wsHandler.getClients()) { + wsHandler.getClients().stream().filter(c -> + (!c.legacy && c.visitor.isAnonymous()) // anonymous users + || c.visitor.equals(serviceUser) // services + || (!c.legacy && threadUsers.contains(c.visitor.getUid()))) // subscriptions + .forEach(c -> { + try { + logger.debug("sending reply to {}", c.visitor.getUid()); + c.sendMessage(new TextMessage(json)); + } catch (IOException e) { + logger.warn("ws error", e); + } + }); + wsHandler.getClients().stream().filter(c -> + (c.legacy && c.allReplies) || (c.legacy && c.MID == jmsg.getMid())) // legacy replies + .forEach(c -> { + try { + logger.debug("sending reply to legacy client {}", c.visitor.getUid()); + c.sendMessage(new TextMessage(json)); + } catch (IOException e) { + logger.warn("ws error", e); + } + }); + } + } catch (JsonProcessingException e) { + logger.warn("Invalid JSON", e); + } + messageEvent(jmsg, subscribedUsers); + messageEvent(jmsg, Collections.singletonList(AnonymousUser.INSTANCE)); + } + + @Override + public void processMessageEvent(MessageEvent event) { + com.juick.Message jmsg = event.getMessage(); + List<User> subscribedUsers = event.getUsers(); + if (jmsg.isService()) { + readEvent(jmsg, Collections.singletonList(serviceUser)); + return; + } + if (MessageUtils.isPM(jmsg)) { + onJuickPM(jmsg.getTo(), jmsg); + } else if (!MessageUtils.isReply(jmsg)) { + // to get full message with attachment, etc. + onJuickMessagePost(messagesService.getMessage(jmsg.getMid()), subscribedUsers); + } else { + // to get quote and attachment + Message op = messagesService.getMessage(jmsg.getMid()); + com.juick.Message reply = messagesService.getReply(jmsg.getMid(), jmsg.getRid()); + subscriptionService.getUsersSubscribedToComments(op, reply, true).stream() + .filter(u -> userService.isReplyToBL(u, reply)) + .forEach(b -> messagesService.setLastReadComment(b, reply.getMid(), reply.getRid())); + onJuickMessageReply(reply, subscribedUsers); + } + messageEvent(jmsg, Collections.singletonList(serviceUser)); + } + + @Override + public void processSubscribeEvent(SubscribeEvent subscribeEvent) { + + } + + @Override + public void processLikeEvent(LikeEvent likeEvent) { + + } + + @Override + public void processPingEvent(PingEvent pingEvent) { + + } + + @Override + public void processMessageReadEvent(MessageReadEvent messageReadEvent) { + User user = messageReadEvent.getUser(); + Message source = messageReadEvent.getMessage(); + + logger.info("Message read event from {} for {}", user.getUid(), source.getMid()); + Message serviceMessage = new Message(); + serviceMessage.setService(true); + serviceMessage.setUser(user); + serviceMessage.setMid(source.getMid()); + serviceMessage.setUnread(false); + wsHandler.getClients().stream().filter(c -> + (!c.legacy && c.visitor == user) || c.visitor.equals(serviceUser) + ).forEach(u -> { + try { + u.sendMessage(new TextMessage(jsonMapper.writeValueAsString(serviceMessage))); + } catch (IOException e) { + logger.error("JSON error", e); + } + }); + readEvent(serviceMessage, Collections.singletonList(serviceUser)); + } + + @Override + public void processTopEvent(TopEvent topEvent) { + User topUser = topEvent.getMessage().getUser(); + topEvent(topEvent.getMessage(), Arrays.asList(topUser, serviceUser)); + } + + public void topEvent(Message msg, List<User> subscribers){ + sendSseEvent(msg, "top", subscribers); + } + + public void readEvent(Message msg, List<User> subscribers){ + sendSseEvent(msg, "read", subscribers); + } + + public void messageEvent(Message msg, List<User> subscribers){ + sendSseEvent(msg, "msg", subscribers); + } + + private void sendSseEvent(Message msg, String name, List<User> subscribers) { + List<EventSession> deadEmitters = new ArrayList<>(); + this.sessions.stream().filter(s -> subscribers.contains(s.user)).forEach(session -> { + try { + SseEmitter.SseEventBuilder builder = SseEmitter.event() + .name(name) + .data(msg); + session.getEmitter().send(builder); + } catch (Exception e) { + deadEmitters.add(session); + } + }); + this.sessions.removeAll(deadEmitters); + } + + public static class EventSession { + private User user; + private SseEmitter emitter; + + public EventSession(User user, SseEmitter sseEmitter) { + this.user = user; + this.emitter = sseEmitter; + } + + public User getUser() { + return user; + } + + public SseEmitter getEmitter() { + return emitter; + } + } + + public CopyOnWriteArrayList<EventSession> getSessions() { + return sessions; + } +} diff --git a/src/main/java/com/juick/server/SignatureManager.java b/src/main/java/com/juick/server/SignatureManager.java new file mode 100644 index 00000000..b3b7a301 --- /dev/null +++ b/src/main/java/com/juick/server/SignatureManager.java @@ -0,0 +1,113 @@ +package com.juick.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.juick.server.api.activity.model.Context; +import com.juick.server.api.activity.model.objects.Person; +import com.juick.server.api.webfinger.model.Account; +import com.juick.server.api.webfinger.model.Link; +import com.juick.util.DateFormattersHolder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; +import org.tomitribe.auth.signatures.Signature; +import org.tomitribe.auth.signatures.Signer; +import org.tomitribe.auth.signatures.Verifier; +import rocks.xmpp.addr.Jid; + +import javax.inject.Inject; +import java.io.IOException; +import java.net.URI; +import java.security.Key; +import java.security.NoSuchAlgorithmException; +import java.security.SignatureException; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import static com.juick.server.api.activity.model.Context.ACTIVITY_MEDIA_TYPE; + +@Component +public class SignatureManager { + private static final Logger logger = LoggerFactory.getLogger(ActivityPubManager.class); + @Inject + private KeystoreManager keystoreManager; + @Inject + private ObjectMapper jsonMapper; + @Inject + private ApplicationEventPublisher applicationEventPublisher; + @Inject + private RestTemplate apClient; + + public void post(Person from, Person to, Context data) throws IOException { + UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(to.getInbox()); + URI inbox = uriComponentsBuilder.build().toUri(); + Instant now = Instant.now(); + String requestDate = DateFormattersHolder.getHttpDateFormatter().format(now); + Signature templateSignature = new Signature(from.getPublicKey().getId(), "rsa-sha256", null, + "(request-target)", "host", "date"); + Signer signer = new Signer(keystoreManager.getPrivateKey(), templateSignature); + Map<String, String> headers = new HashMap<>(); + headers.put("host", inbox.getHost()); + headers.put("date", requestDate); + Signature signature = signer.sign("POST", inbox.getPath(), headers); + HttpHeaders requestHeaders = new HttpHeaders(); + requestHeaders.add("Content-Type", Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE); + requestHeaders.add("Date", requestDate); + requestHeaders.add("Signature", signature.toString().substring(10)); + HttpEntity<Context> request = new HttpEntity<>(Context.build(data), requestHeaders); + //boolean valid = verifySignature(Signature.fromString(requestHeaders.getFirst("Signature")), + // keystoreManager.getPublicKey(), "POST", inbox.getPath(), headers); + logger.info("Sending context: {}", jsonMapper.writeValueAsString(data)); + logger.info("Request date: {}", requestDate); + ResponseEntity<Void> response = apClient.postForEntity(inbox, request, Void.class); + logger.info("accepted follower: {}", response.getStatusCodeValue()); + + } + public boolean verifySignature(String signatureString, URI actor, String method, String path, Map<String, String> headers) { + Optional<Context> context = getContext(actor); + if (context.isPresent() && context.get() instanceof Person) { + Person person = (Person) context.get(); + Key key = KeystoreManager.publicKeyOf(person); + Verifier verifier = new Verifier(key, Signature.fromString(signatureString)); + try { + boolean result = verifier.verify(method, path, headers); + logger.info("signature is valid: {}", result); + return result; + } catch (NoSuchAlgorithmException | SignatureException | IOException e) { + logger.info("signature exception", e); + return false; + } + } + logger.info("person not found"); + return false; + } + public Optional<Context> getContext(URI contextUri) { + Context context = apClient.getForEntity(contextUri, Context.class).getBody(); + if (context == null) { + logger.warn("Cannot identify {}", contextUri); + return Optional.empty(); + } + return Optional.of(context); + } + public Optional<Context> discoverPerson(String acct) { + Jid acctId = Jid.of(acct); + URI resourceUri = UriComponentsBuilder.fromUriString( + String.format("https://%s/.well-known/webfinger?resource=acct:%s", acctId.getDomain(), acct)).build().toUri(); + Account acctData = apClient.getForEntity(resourceUri, Account.class).getBody(); + if (acctData != null) { + for (Link l : acctData.getLinks()) { + if (l.getRel().equals("self") && l.getType().equals(ACTIVITY_MEDIA_TYPE)) { + return getContext(URI.create(l.getHref())); + } + } + } + return Optional.empty(); + } +} diff --git a/src/main/java/com/juick/server/TelegramBotManager.java b/src/main/java/com/juick/server/TelegramBotManager.java new file mode 100644 index 00000000..8e8d0104 --- /dev/null +++ b/src/main/java/com/juick/server/TelegramBotManager.java @@ -0,0 +1,412 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server; + +import com.juick.User; +import com.juick.service.component.*; +import com.juick.model.AnonymousUser; +import com.juick.model.CommandResult; +import com.juick.server.util.HttpUtils; +import com.juick.service.MessagesService; +import com.juick.service.TelegramService; +import com.juick.service.UserService; +import com.juick.util.MessageUtils; +import com.pengrad.telegrambot.Callback; +import com.pengrad.telegrambot.TelegramBot; +import com.pengrad.telegrambot.UpdatesListener; +import com.pengrad.telegrambot.model.Message; +import com.pengrad.telegrambot.model.MessageEntity; +import com.pengrad.telegrambot.model.PhotoSize; +import com.pengrad.telegrambot.model.Update; +import com.pengrad.telegrambot.model.request.ParseMode; +import com.pengrad.telegrambot.request.GetFile; +import com.pengrad.telegrambot.request.SendMessage; +import com.pengrad.telegrambot.request.SendPhoto; +import com.pengrad.telegrambot.request.SetWebhook; +import com.pengrad.telegrambot.response.GetFileResponse; +import com.pengrad.telegrambot.response.SendResponse; +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.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +import javax.annotation.Nonnull; +import javax.annotation.PostConstruct; +import javax.inject.Inject; +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.util.*; + +import static com.juick.formatters.PlainTextFormatter.formatPost; +import static com.juick.formatters.PlainTextFormatter.formatUrl; + +/** + * Created by vt on 12/05/16. + */ +public class TelegramBotManager implements NotificationListener { + private static final Logger logger = LoggerFactory.getLogger(TelegramBotManager.class); + + private TelegramBot bot; + + @Value("${telegram_token:12345678}") + private String telegramToken; + @Value("${telegram_debug:false}") + private boolean telegramDebug; + @Inject + private TelegramService telegramService; + @Inject + private MessagesService messagesService; + @Inject + private UserService userService; + @Inject + private CommandsManager commandsManager; + @Inject + private ApplicationEventPublisher applicationEventPublisher; + @Value("${upload_tmp_dir:#{systemEnvironment['TEMP'] ?: '/tmp'}}") + private String tmpDir; + + private static final String MSG_LINK = "🔗"; + + @PostConstruct + public void init() { + if (StringUtils.isBlank(telegramToken)) { + logger.info("telegram token is not set, exiting"); + return; + } + bot = new TelegramBot(telegramToken); + if (!telegramDebug) { + try { + SetWebhook webhook = new SetWebhook().url("https://api.juick.com/tlgmbtwbhk"); + if (!bot.execute(webhook).isOk()) { + logger.error("error setting webhook"); + } + } catch (Exception e) { + logger.warn("couldn't initialize telegram bot", e); + } + } else { + bot.setUpdatesListener(updates -> { + updates.forEach(this::processUpdate); + return UpdatesListener.CONFIRMED_UPDATES_ALL; + }); + } + } + + public void processUpdate(Update update) { + Message message = update.message(); + if (update.message() == null) { + message = update.editedMessage(); + if (message == null) { + logger.error("error parsing telegram update: {}", update); + return; + } + } + User user_from = userService.getUserByUID(telegramService.getUser(message.chat().id())).orElse(AnonymousUser.INSTANCE); + logger.info("Found juick user {}", user_from.getUid()); + + String username = message.from().username(); + if (username == null) { + username = message.from().firstName(); + } + if (!user_from.isAnonymous()) { + URI attachment = URI.create(StringUtils.EMPTY); + if (message.photo() != null) { + String fileId = Arrays.stream(message.photo()).max(Comparator.comparingInt(PhotoSize::fileSize)) + .orElse(new PhotoSize()).fileId(); + if (StringUtils.isNotEmpty(fileId)) { + GetFile request = new GetFile(fileId); + GetFileResponse response = bot.execute(request); + logger.info("got file {}", response.file()); + try { + URL fileURL = new URL(bot.getFullFilePath(response.file())); + attachment = HttpUtils.downloadImage(fileURL, tmpDir); + } catch (Exception e) { + logger.warn("attachment exception", e); + } + logger.info("received {}", attachment); + } + } + String text = message.text(); + if (StringUtils.isBlank(text)) { + text = message.caption(); + } + if (StringUtils.isBlank(text)) { + text = StringUtils.EMPTY; + } + if (StringUtils.isNotEmpty(text) || StringUtils.isNotEmpty(attachment.toString())) { + if (text.equalsIgnoreCase("LOGIN") + || text.equalsIgnoreCase("PING") + || text.equalsIgnoreCase("HELP") + || text.equalsIgnoreCase("/login") + || text.equalsIgnoreCase("/logout") + || text.equalsIgnoreCase("/start") + || text.equalsIgnoreCase("/help")) { + String msgUrl = "http://juick.com/login?hash=" + userService.getHashByUID(user_from.getUid()); + String msg = String.format("Hi, %s!\nYou can post messages and images to Juick there.\n" + + "Tap to [log into website](%s) to get more info", user_from.getName(), msgUrl); + telegramNotify(message.from().id().longValue(), msg, new com.juick.Message()); + } else { + Message replyMessage = message.replyToMessage(); + if (replyMessage != null) { + MessageEntity[] entities = replyMessage.entities(); + if (entities == null) { + entities = replyMessage.captionEntities(); + } + if (entities != null) { + Optional<MessageEntity> juickLink = Arrays.stream(entities) + .filter(this::isJuickLink) + .findFirst(); + if (juickLink.isPresent()) { + if (StringUtils.isNotEmpty(juickLink.get().url())) { + UriComponents uriComponents = UriComponentsBuilder.fromUriString( + juickLink.get().url()).build(); + String path = uriComponents.getPath(); + if (StringUtils.isNotEmpty(path) && path.length() > 1) { + int mid = 0; + try { + mid = Integer.valueOf(path.substring(3)); + } catch (NumberFormatException e) { + logger.warn("wrong mid received"); + return; + } + String prefix = String.format("#%d ", mid); + if (StringUtils.isNotEmpty(uriComponents.getFragment())) { + int rid = Integer.valueOf(uriComponents.getFragment()); + prefix = String.format("#%d/%d ", mid, rid); + } + CommandResult result = null; + try { + result = commandsManager.processCommand(user_from, prefix + text, attachment); + String messageTxt = StringUtils.isNotEmpty(result.getMarkdown()) ? result.getMarkdown() + : "Unknown error or unsupported command"; + telegramNotify(message.from().id().longValue(), messageTxt, new com.juick.Message()); + } catch (Exception e) { + logger.warn("telegram exception", e); + } + } else { + logger.warn("invalid path: {}", path); + } + } else { + logger.warn("invalid entity: {}", juickLink); + } + } else { + telegramNotify(message.from().id().longValue(), + "Can not reply to this message", replyMessage.messageId(), new com.juick.Message()); + } + } else { + telegramNotify(message.from().id().longValue(), + "Can not reply to this message", replyMessage.messageId(), new com.juick.Message()); + } + } else { + try { + CommandResult result = commandsManager.processCommand(user_from, text, attachment); + String messageTxt = StringUtils.isNotEmpty(result.getMarkdown()) ? result.getMarkdown() + : "Unknown error or unsupported command"; + telegramNotify(message.from().id().longValue(), messageTxt, new com.juick.Message()); + } catch (Exception e) { + logger.warn("telegram reply exception", e); + } + } + } + } + } else { + List<Long> chats = telegramService.getAnonymous(); + if (!chats.contains(message.chat().id())) { + logger.info("added chat with {}", username); + telegramService.createTelegramUser(message.from().id(), username); + } + telegramSignupNotify(message.from().id().longValue(), userService.getSignUpHashByTelegramID(message.from().id().longValue(), username)); + } + } + + private boolean isJuickLink(MessageEntity e) { + return e.offset() == 0 && e.type().equals(MessageEntity.Type.text_link) && e.length() == 2; + } + + public void telegramNotify(Long chatId, String msg, @Nonnull com.juick.Message source) { + telegramNotify(chatId, msg, 0, source); + } + + public void telegramNotify(Long chatId, String msg, Integer replyTo, @Nonnull com.juick.Message source) { + String attachment = MessageUtils.attachmentUrl(source); + if (StringUtils.isEmpty(attachment)) { + SendMessage telegramMessage = new SendMessage(chatId, msg); + if (replyTo > 0) { + telegramMessage.replyToMessageId(replyTo); + } + telegramMessage.parseMode(ParseMode.Markdown).disableWebPagePreview(true); + bot.execute(telegramMessage, new Callback<SendMessage, SendResponse>() { + @Override + public void onResponse(SendMessage request, SendResponse response) { + processTelegramResponse(chatId, response, source); + } + + @Override + public void onFailure(SendMessage request, IOException e) { + logger.warn("telegram failure", e); + } + }); + } else { + SendPhoto telegramPhoto = new SendPhoto(chatId, attachment); + String trimmedPost = msg.length() > 1024 ? msg.substring(0, 1023) + "..." : msg; + telegramPhoto.caption(trimmedPost); + if (replyTo > 0) { + telegramPhoto.replyToMessageId(replyTo); + } + telegramPhoto.parseMode(ParseMode.Markdown); + bot.execute(telegramPhoto, new Callback<SendPhoto, SendResponse>() { + @Override + public void onResponse(SendPhoto request, SendResponse response) { + processTelegramResponse(chatId, response, source); + } + + @Override + public void onFailure(SendPhoto request, IOException e) { + logger.warn("telegram failure", e); + } + }); + } + } + + private void processTelegramResponse(Long chatId, SendResponse response, com.juick.Message source) { + int userId = telegramService.getUser(chatId); + if (!response.isOk()) { + if (response.errorCode() == 403) { + // remove from anonymous users + telegramService.getAnonymous().stream().filter(c -> c.equals(chatId)).findFirst().ifPresent( + d -> { + telegramService.deleteAnonymous(d); + logger.info("deleted {} chat", d); + } + ); + if (userId > 0) { + User userToDelete = userService.getUserByUID(userId) + .orElseThrow(IllegalStateException::new); + boolean status = telegramService.deleteTelegramUser(userToDelete.getUid()); + logger.info("deleting telegram id of @{} : {}", userToDelete.getName(), status); + } + } else { + logger.warn("error response, isOk: {}, errorCode: {}, description: {}", + response.isOk(), response.errorCode(), response.description()); + } + } else { + if (MessageUtils.isReply(source)) { + messagesService.setLastReadComment(userService.getUserByUID(userId) + .orElseThrow(IllegalStateException::new), source.getMid(), source.getRid()); + User user = userService.getUserByUID(userId).orElseThrow(IllegalStateException::new); + userService.updateLastSeen(user); + applicationEventPublisher.publishEvent( + new MessageReadEvent(this, user, source)); + } + } + } + + public void telegramSignupNotify(Long telegramId, String hash) { + bot.execute(new SendMessage(telegramId, + String.format("You are subscribed to all Juick messages. " + + "[Create or link](http://juick.com/signup?type=durov&hash=%s) " + + "an existing Juick account to get your subscriptions and ability to post messages", hash)) + .parseMode(ParseMode.Markdown), new Callback<SendMessage, SendResponse>() { + @Override + public void onResponse(SendMessage request, SendResponse response) { + logger.info("got response: {}", response.message()); + } + + @Override + public void onFailure(SendMessage request, IOException e) { + logger.warn("telegram failure", e); + } + }); + } + + @Override + public void processMessageEvent(MessageEvent messageEvent) { + com.juick.Message jmsg = messageEvent.getMessage(); + List<User> subscribedUsers = messageEvent.getUsers(); + if (jmsg.isService()) { + return; + } + String msgUrl = formatUrl(jmsg); + if (MessageUtils.isPM(jmsg)) { + telegramService.getTelegramIdentifiers(Collections.singletonList(jmsg.getTo())) + .forEach(c -> telegramNotify(c, formatPost(jmsg, true), jmsg)); + } else if (MessageUtils.isReply(jmsg)) { + com.juick.Message op = messagesService.getMessage(jmsg.getMid()); + String fmsg = String.format("[%s](%s) %s", MSG_LINK, msgUrl, formatPost(jmsg, true)); + telegramService.getTelegramIdentifiers( + subscribedUsers + ).forEach(c -> telegramNotify(c, fmsg, jmsg)); + } else { + String msg = String.format("[%s](%s) %s", MSG_LINK, msgUrl, formatPost(jmsg, true)); + + List<Long> users = telegramService.getTelegramIdentifiers(subscribedUsers); + List<Long> chats = telegramService.getAnonymous(); + // registered subscribed users + + users.forEach(c -> telegramNotify(c, msg, jmsg)); + // anonymous + chats.stream().filter(u -> telegramService.getUser(u) == 0).forEach(c -> telegramNotify(c, msg, jmsg)); + } + } + + @Override + public void processLikeEvent(LikeEvent likeEvent) { + User liker = likeEvent.getUser(); + com.juick.Message message = likeEvent.getMessage(); + List<User> subscribers = likeEvent.getSubscribers(); + logger.info("Like received in tg listener"); + if (!userService.isInBLAny(message.getUser().getUid(), liker.getUid())) { + telegramService.getTelegramIdentifiers(Collections.singletonList(message.getUser())) + .forEach(c -> telegramNotify(c, String.format("%s recommends your [post](%s)", + MessageUtils.getMarkdownUser(liker), formatUrl(message)), new com.juick.Message())); + } + telegramService.getTelegramIdentifiers(subscribers) + .forEach(c -> telegramNotify(c, String.format("%s recommends you someone's [post](%s)", + MessageUtils.getMarkdownUser(liker), formatUrl(message)), new com.juick.Message())); + } + + @Override + public void processPingEvent(PingEvent pingEvent) { + + } + + @Override + public void processMessageReadEvent(MessageReadEvent messageReadEvent) { + + } + + @Override + public void processTopEvent(TopEvent topEvent) { + com.juick.Message message = topEvent.getMessage(); + telegramService.getTelegramIdentifiers(Collections.singletonList(message.getUser())) + .forEach(c -> telegramNotify(c, String.format("Your [post](%s) became popular!", + formatUrl(message)), new com.juick.Message())); + } + + @Override + public void processSubscribeEvent(SubscribeEvent subscribeEvent) { + User subscriber = subscribeEvent.getUser(); + User target = subscribeEvent.getToUser(); + telegramService.getTelegramIdentifiers(Collections.singletonList(target)) + .forEach(c -> telegramNotify(c, String.format("%s subscribed to your blog", + MessageUtils.getMarkdownUser(subscriber)), new com.juick.Message())); + } +} diff --git a/src/main/java/com/juick/server/TopManager.java b/src/main/java/com/juick/server/TopManager.java new file mode 100644 index 00000000..e5c00242 --- /dev/null +++ b/src/main/java/com/juick/server/TopManager.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server; + +import com.juick.Message; +import com.juick.Tag; +import com.juick.service.MessagesService; +import com.juick.service.component.TopEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import javax.inject.Inject; +import java.util.List; +import java.util.stream.Collectors; + +@Component +public class TopManager { + private static Logger logger = LoggerFactory.getLogger(TopManager.class); + @Inject + private MessagesService messagesService; + @Inject + private ApplicationEventPublisher applicationEventPublisher; + + @Scheduled(fixedRate = 3600000) + public void updateTop() { + messagesService.getPopularCandidates().forEach(m -> { + Message jmsg = messagesService.getMessage(m); + logger.info("added {} to popular", m); + messagesService.setMessagePopular(m, 1); + List<String> tags = jmsg.getTags().stream().map(Tag::getName).map(String::toLowerCase).collect(Collectors.toList()); + if (!tags.contains("juick")) { + applicationEventPublisher.publishEvent(new TopEvent(this, jmsg)); + } + }); + } +} diff --git a/src/main/java/com/juick/server/TwitterManager.java b/src/main/java/com/juick/server/TwitterManager.java new file mode 100644 index 00000000..613594e6 --- /dev/null +++ b/src/main/java/com/juick/server/TwitterManager.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ +package com.juick.server; + +import com.juick.Message; +import com.juick.User; +import com.juick.service.UserService; +import com.juick.service.component.*; +import com.juick.service.CrosspostService; +import com.juick.util.MessageUtils; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import twitter4j.TwitterFactory; +import twitter4j.conf.ConfigurationBuilder; + +import javax.annotation.PostConstruct; +import javax.inject.Inject; + +/** + * @author Ugnich Anton + */ +@Component +public class TwitterManager implements NotificationListener { + + private static Logger logger = LoggerFactory.getLogger(TwitterManager.class); + + @Inject + private CrosspostService crosspostService; + + @Value("${twitter_consumer_key:12345678}") + private String twitter_consumer_key; + @Value("${twitter_consumer_secret:secret}") + private String twitter_consumer_secret; + @Inject + private UserService userService; + + @Value("${service_user:juick}") + private String serviceUsername; + + private User serviceUser; + + @PostConstruct + public void init() { + serviceUser = userService.getUserByName(serviceUsername); + } + + void twitterPost(final com.juick.Message jmsg) { + crosspostService.getTwitterToken(jmsg.getUser().getUid()).ifPresent(t -> { + String status = MessageUtils.getMessageHashTags(jmsg) + StringUtils.defaultString(jmsg.getText()); + if (status.length() > 253) { + status = status.substring(0, 252) + "…"; + } + status += " http://juick.com/" + jmsg.getMid(); + ConfigurationBuilder configurationBuilder = new ConfigurationBuilder() + .setDebugEnabled(true) + .setOAuthConsumerKey(twitter_consumer_key) + .setOAuthConsumerSecret(twitter_consumer_secret); + TwitterFactory tf = new TwitterFactory(configurationBuilder + .setOAuthAccessToken(t.getToken()) + .setOAuthAccessTokenSecret(t.getSecret()).build()); + try { + tf.getInstance().updateStatus(status); + } catch (Exception e) { + logger.info("Twitter exception", e); + } + }); + } + + @Override + public void processMessageEvent(MessageEvent messageEvent) { + Message msg = messageEvent.getMessage(); + if (MessageUtils.isPM(msg) || MessageUtils.isReply(msg) || msg.isService()) { + return; + } + if (StringUtils.isNotEmpty(crosspostService.getTwitterName(msg.getUser().getUid()))) { + if (msg.getTags().stream().noneMatch(t -> t.getName().equals("notwitter"))) { + twitterPost(msg); + } + } + } + + @Override + public void processSubscribeEvent(SubscribeEvent subscribeEvent) { + + } + + @Override + public void processLikeEvent(LikeEvent likeEvent) { + + } + + @Override + public void processPingEvent(PingEvent pingEvent) { + + } + + @Override + public void processMessageReadEvent(MessageReadEvent messageReadEvent) { + + } + + @Override + public void processTopEvent(TopEvent topEvent) { + Message jmsg = topEvent.getMessage(); + jmsg.setUser(serviceUser); + twitterPost(jmsg); + } +} diff --git a/src/main/java/com/juick/server/Utils.java b/src/main/java/com/juick/server/Utils.java new file mode 100644 index 00000000..23768ed2 --- /dev/null +++ b/src/main/java/com/juick/server/Utils.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ +package com.juick.server; + +import javax.servlet.http.HttpServletRequest; +import java.util.Optional; + +/** + * + * @author Ugnich Anton + */ +public class Utils { + + + public static String encodeSphinx(String str) { + return str.replaceAll("@", "\\\\@") + .replaceAll("\\'", "\\\\'") + .replaceAll("=", "\\\\\\\\="); + } + /** + * Returns the viewName to return for coming back to the sender url + * + * @param request Instance of {@link HttpServletRequest} or use an injected instance + * @return Optional with the view name. Recomended to use an alternativa url with + * {@link Optional#orElse(java.lang.Object)} + */ + public static Optional<String> getPreviousPageByRequest(HttpServletRequest request) + { + return Optional.ofNullable(request.getHeader("Referer")); + } +} diff --git a/src/main/java/com/juick/server/WebsocketManager.java b/src/main/java/com/juick/server/WebsocketManager.java new file mode 100644 index 00000000..1b62b984 --- /dev/null +++ b/src/main/java/com/juick/server/WebsocketManager.java @@ -0,0 +1,174 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server; + +import com.juick.User; +import com.juick.model.AnonymousUser; +import com.juick.model.CommandResult; +import com.juick.server.util.HttpForbiddenException; +import com.juick.server.util.HttpNotFoundException; +import com.juick.service.MessagesService; +import com.juick.service.UserService; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.PingMessage; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator; +import org.springframework.web.socket.handler.TextWebSocketHandler; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +import javax.annotation.Nonnull; +import javax.inject.Inject; +import java.io.IOException; +import java.net.URI; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Created by vitalyster on 28.06.2016. + */ +@Component +public class WebsocketManager extends TextWebSocketHandler { + private static final Logger logger = LoggerFactory.getLogger(WebsocketManager.class); + + private final List<UserSession> clients = new CopyOnWriteArrayList<>(); + + @Inject + private UserService userService; + @Inject + private MessagesService messagesService; + @Inject + private CommandsManager commandsManager; + + + @Override + public void afterConnectionEstablished(WebSocketSession session) { + + UserSession userSession = new UserSession(session); + URI hLocation = session.getUri(); + + // Auth + UriComponents uriComponents = UriComponentsBuilder.fromUri(hLocation).build(); + List<String> hash = uriComponents.getQueryParams().get("hash"); + if (hash != null && hash.get(0).length() == 16) { + userSession.visitor = userService.getUserByHash(hash.get(0)); + } else { + logger.debug("wrong hash for {} from {}", userSession.visitor.getUid(), userSession); + } + + if (hLocation.getPath().equals("/ws/")) { + logger.debug("user {} connected", userSession.visitor.getUid()); + } else if (hLocation.getPath().equals("/ws/_all")) { + logger.debug("user {} connected to legacy _all ({})", userSession.visitor.getUid(), hLocation.getPath()); + userSession.legacy = true; + userSession.allMessages = true; + } else if (hLocation.getPath().equals("/ws/_replies")) { + logger.debug("user {} connected to legacy _replies ({})", userSession.visitor.getUid(), hLocation.getPath()); + userSession.legacy = true; + userSession.allReplies = true; + } else if (hLocation.getPath().matches("^/ws/(\\d)+$")) { + int MID = NumberUtils.toInt(hLocation.getPath().substring(4), 0); + if (MID > 0) { + if (messagesService.canViewThread(MID, userSession.visitor.getUid())) { + logger.debug("user {} connected to legacy thread ({}) from {}", userSession.visitor.getUid(), MID, userSession); + userSession.legacy = true; + userSession.MID = MID; + } else { + throw new HttpForbiddenException(); + } + } + } else { + throw new HttpNotFoundException(); + } + clients.add(userSession); + logger.debug("{} clients connected", clients.size()); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + logger.debug("session closed with status {}: {}", status.getCode(), status.getReason()); + clients.removeIf(c -> c.getDelegate().getId().equals(session.getId())); + logger.debug("{} clients connected", clients.size()); + } + + @Scheduled(fixedRate = 30000) + public void ping() { + clients.forEach(c -> { + try { + if (c.isOpen()) { + c.sendMessage(new PingMessage()); + } + } catch (IOException e) { + logger.error("WebSocket PING exception", e); + } + }); + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { + UserSession ws = clients.stream().filter(c -> c.getDelegate().equals(session)) + .findFirst().orElseThrow(IllegalStateException::new); + if (!ws.visitor.isAnonymous()) { + String command = message.getPayload().trim(); + if (StringUtils.isNotEmpty(command)) { + CommandResult result = commandsManager.processCommand(ws.visitor, command, URI.create("")); + ws.sendMessage(new TextMessage(result.getText())); + } + } else { + ws.sendMessage(new TextMessage("Authorization required")); + } + } + + public List<UserSession> getClients() { + return clients; + } + + class UserSession extends ConcurrentWebSocketSessionDecorator { + User visitor; + int MID; + boolean allMessages; + boolean allReplies; + Instant tsConnected; + Instant tsLastData; + boolean legacy; + + UserSession(WebSocketSession session) { + super(session, 60000, 65536); + this.visitor = AnonymousUser.INSTANCE; + tsConnected = tsLastData = Instant.now(); + } + + @Nonnull + @Override + public String toString() { + HttpHeaders headers = getHandshakeHeaders(); + return headers.getOrDefault("X-Real-IP", + Collections.singletonList(getRemoteAddress().toString())).get(0); + } + } +} diff --git a/src/main/java/com/juick/server/XMPPConnection.java b/src/main/java/com/juick/server/XMPPConnection.java new file mode 100644 index 00000000..9c0c09e1 --- /dev/null +++ b/src/main/java/com/juick/server/XMPPConnection.java @@ -0,0 +1,693 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server; + +import com.juick.User; +import com.juick.formatters.PlainTextFormatter; +import com.juick.service.component.*; +import com.juick.model.CommandResult; +import com.juick.model.UserInfo; +import com.juick.server.xmpp.iq.MessageQuery; +import com.juick.server.xmpp.s2s.BasicXmppSession; +import com.juick.server.xmpp.s2s.StanzaListener; +import com.juick.service.*; +import com.juick.util.MessageUtils; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.io.FilenameUtils; +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 rocks.xmpp.addr.Jid; +import rocks.xmpp.core.XmppException; +import rocks.xmpp.core.session.XmppSession; +import rocks.xmpp.core.stanza.AbstractIQHandler; +import rocks.xmpp.core.stanza.model.*; +import rocks.xmpp.core.stanza.model.client.ClientMessage; +import rocks.xmpp.core.stanza.model.client.ClientPresence; +import rocks.xmpp.core.stanza.model.errors.Condition; +import rocks.xmpp.extensions.caps.EntityCapabilitiesManager; +import rocks.xmpp.extensions.component.accept.ExternalComponent; +import rocks.xmpp.extensions.disco.ServiceDiscoveryManager; +import rocks.xmpp.extensions.disco.model.info.Identity; +import rocks.xmpp.extensions.filetransfer.FileTransfer; +import rocks.xmpp.extensions.filetransfer.FileTransferManager; +import rocks.xmpp.extensions.nick.model.Nickname; +import rocks.xmpp.extensions.oob.model.x.OobX; +import rocks.xmpp.extensions.ping.PingManager; +import rocks.xmpp.extensions.receipts.MessageDeliveryReceiptsManager; +import rocks.xmpp.extensions.vcard.temp.model.VCard; +import rocks.xmpp.extensions.version.SoftwareVersionManager; +import rocks.xmpp.extensions.version.model.SoftwareVersion; +import rocks.xmpp.util.XmppUtils; + +import javax.annotation.Nonnull; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import javax.inject.Inject; +import javax.xml.bind.JAXBException; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamWriter; +import java.io.IOException; +import java.io.StringWriter; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; + +/** + * @author ugnich + */ +public class XMPPConnection implements StanzaListener, NotificationListener { + + private static final Logger logger = LoggerFactory.getLogger("com.juick.server.xmpp"); + + private ExternalComponent router; + @Inject + private XMPPServer xmpp; + @Inject + private CommandsManager commandsManager; + @Value("${xmppbot_jid:juick@localhost}") + private Jid jid; + @Value("${componentname:localhost}") + private String componentName; + @Value("${component_port:5347}") + private int componentPort; + @Value("${xmpp_password:secret}") + private String password; + @Value("${upload_tmp_dir:#{systemEnvironment['TEMP'] ?: '/tmp'}}") + private String tmpDir; + + @Inject + private MessagesService messagesService; + @Inject + private UserService userService; + @Inject + private SubscriptionService subscriptionService; + @Inject + private PMQueriesService pmQueriesService; + @Inject + private BasicXmppSession session; + @Inject + private ExecutorService service; + @Inject + private ApplicationEventPublisher applicationEventPublisher; + @Value("${service_user:juick}") + private String serviceUsername; + + private User serviceUser; + + @PostConstruct + public void init() { + logger.info("stream router start connecting to {}", componentPort); + xmpp.addStanzaListener(this); + router = ExternalComponent.create(componentName, password, session.getConfiguration(), "localhost", componentPort); + ServiceDiscoveryManager serviceDiscoveryManager = router.getManager(ServiceDiscoveryManager.class); + serviceDiscoveryManager.addIdentity(Identity.clientBot().withName("Juick")); + EntityCapabilitiesManager entityCapabilitiesManager = router.getManager(EntityCapabilitiesManager.class); + entityCapabilitiesManager.setNode("https://juick.com/caps"); + MessageDeliveryReceiptsManager messageDeliveryReceiptsManager = router.getManager(MessageDeliveryReceiptsManager.class); + messageDeliveryReceiptsManager.setEnabled(true); + PingManager pingManager = router.getManager(PingManager.class); + pingManager.setEnabled(true); + SoftwareVersionManager softwareVersionManager = router.getManager(SoftwareVersionManager.class); + softwareVersionManager.setSoftwareVersion(new SoftwareVersion("Juick", "2.x", + System.getProperty("os.name", "generic"))); + VCard vCard = new VCard(); + vCard.setFormattedName("Juick"); + vCard.setBirthday(LocalDate.of(2008, 10, 22)); + try { + vCard.setUrl(new URL("http://juick.com/")); + vCard.setPhoto(new VCard.Image("image/png", IOUtils.toByteArray( + getClass().getClassLoader().getResource("juick.png")))); + } catch (MalformedURLException e) { + logger.error("invalid url", e); + } catch (IOException e) { + logger.warn("invalid resource", e); + } + router.addIQHandler(MessageQuery.class, iq -> { + Message warningMessage = new Message(iq.getFrom(), Message.Type.CHAT); + warningMessage.setFrom(jid); + warningMessage.setBody("Your XMPP client constantly polls us with XMPP query which is unsupported for years, please find http://juick.com/query#messages in your client code and remove that"); + router.send(warningMessage); + return iq.createError(new StanzaError(Condition.BAD_REQUEST, "Please stop this spam")); + }); + router.addIQHandler(VCard.class, new AbstractIQHandler(IQ.Type.GET) { + @Override + protected IQ processRequest(IQ iq) { + if (iq.getTo().equals(jid) || iq.getTo().asBareJid().equals(jid.asBareJid()) + || iq.getTo().asBareJid().toEscapedString().equals(jid.getDomain())) { + return iq.createResult(vCard); + } + User user = userService.getUserByName(iq.getTo().getLocal()); + if (!user.isAnonymous()) { + UserInfo info = userService.getUserInfo(user); + VCard userVCard = new VCard(); + userVCard.setFormattedName(info.getFullName()); + userVCard.setNickname(user.getName()); + try { + userVCard.setPhoto(new VCard.Image(new URI("http://i.juick.com/a/" + user.getUid() + ".png"))); + if (info.getUrl() != null) { + userVCard.setUrl(new URL(info.getUrl())); + } + } catch (MalformedURLException | URISyntaxException e) { + logger.warn("url exception", e); + } + return iq.createResult(userVCard); + } + return iq.createError(Condition.BAD_REQUEST); + } + }); + router.addInboundMessageListener(e -> { + ClientMessage result = incomingMessage(e.getMessage()); + if (result != null) { + router.send(result); + } + }); + router.addInboundIQListener(e -> { + IQ iq = e.getIQ(); + Jid jid = iq.getTo(); + if (!jid.getDomain().equals(this.jid.getDomain())) { + router.send(iq); + } + }); + FileTransferManager fileTransferManager = router.getManager(FileTransferManager.class); + fileTransferManager.addFileTransferOfferListener(e -> { + try { + List<String> allowedTypes = new ArrayList<String>() {{ + add("png"); + add("jpg"); + }}; + String attachmentExtension = FilenameUtils.getExtension(e.getName()).toLowerCase(); + String targetFilename = String.format("%s.%s", + DigestUtils.md5Hex(String.format("%s-%s", + e.getInitiator().toString(), e.getSessionId()).getBytes()), attachmentExtension); + if (allowedTypes.contains(attachmentExtension)) { + Path filePath = Paths.get(tmpDir, targetFilename); + FileTransfer ft = e.accept(filePath).get(); + ft.addFileTransferStatusListener(st -> { + logger.debug("{}: received {} of {}", e.getName(), st.getBytesTransferred(), e.getSize()); + if (st.getStatus().equals(FileTransfer.Status.COMPLETED)) { + logger.info("transfer completed"); + try { + Jid initiator = e.getInitiator(); + ClientMessage result = incomingMessageJuick( + userService.getUserByJID(initiator.asBareJid().toEscapedString()), initiator, + e.getDescription(), URI.create(String.format("juick://%s", targetFilename))); + if (result != null) { + router.send(result); + } + } catch (Exception e1) { + logger.error("ft error", e1); + } + + } else if (st.getStatus().equals(FileTransfer.Status.FAILED)) { + logger.info("transfer failed", ft.getException()); + Message msg = new Message(); + msg.setType(Message.Type.CHAT); + msg.setFrom(jid); + msg.setTo(e.getInitiator()); + msg.setBody("File transfer failed, please report to us"); + router.sendMessage(msg); + } else if (st.getStatus().equals(FileTransfer.Status.CANCELED)) { + logger.info("transfer cancelled"); + } + }); + ft.transfer(); + logger.info("transfer started"); + } else { + e.reject(); + logger.info("transfer rejected"); + } + } catch (IOException | InterruptedException | ExecutionException e1) { + logger.error("ft error", e1); + } + }); + router.addConnectionListener(event -> { + if (event.getType().equals(rocks.xmpp.core.session.ConnectionEvent.Type.RECONNECTION_SUCCEEDED)) { + logger.info("component connected"); + } + }); + router.addSessionStatusListener(event -> { + logger.info("event: " + event.getStatus(), event.getThrowable()); + if (event.getStatus().equals(XmppSession.Status.AUTHENTICATED)) { + logger.info("Authenticated, broadcasting..."); + broadcastPresence(null); + } + }); + router.addInboundPresenceListener(event -> { + incomingPresence(event.getPresence()); + }); + service.submit(() -> { + try { + router.connect(); + } catch (XmppException e) { + logger.warn("xmpp exception", e); + } + }); + serviceUser = userService.getUserByName(serviceUsername); + } + + private String stanzaToString(Stanza stanza) throws XMLStreamException, JAXBException { + StringWriter stanzaWriter = new StringWriter(); + XMLStreamWriter xmppStreamWriter = XmppUtils.createXmppStreamWriter( + router.getConfiguration().getXmlOutputFactory().createXMLStreamWriter(stanzaWriter)); + router.createMarshaller().marshal(stanza, xmppStreamWriter); + xmppStreamWriter.flush(); + xmppStreamWriter.close(); + return stanzaWriter.toString(); + } + + private void sendJuickMessage(com.juick.Message jmsg, List<User> users) { + List<String> jids = new ArrayList<>(); + + for (User user : users) { + jids.addAll(userService.getJIDsbyUID(user.getUid())); + } + com.juick.Message fullMsg = messagesService.getMessage(jmsg.getMid()); + String txt = "@" + jmsg.getUser().getName() + ":" + MessageUtils.getTagsString(fullMsg) + "\n"; + String attachmentUrl = MessageUtils.attachmentUrl(fullMsg); + if (StringUtils.isNotEmpty(attachmentUrl)) { + txt += attachmentUrl + "\n"; + } + txt += StringUtils.defaultString(jmsg.getText()) + "\n\n"; + txt += "#" + jmsg.getMid() + " http://juick.com/m/" + jmsg.getMid(); + + Nickname nick = new Nickname("@" + jmsg.getUser().getName()); + + Message msg = new Message(); + msg.setFrom(jid); + msg.setBody(txt); + msg.setType(Message.Type.CHAT); + msg.setThread("juick-" + jmsg.getMid()); + msg.addExtension(jmsg); + msg.addExtension(nick); + if (StringUtils.isNotEmpty(attachmentUrl)) { + try { + OobX oob = new OobX(new URI(attachmentUrl)); + msg.addExtension(oob); + } catch (URISyntaxException e) { + logger.warn("uri exception", e); + } + } + for (String jid : jids) { + msg.setTo(Jid.of(jid)); + router.send(ClientMessage.from(msg)); + } + } + + public void sendJuickComment(com.juick.Message jmsg, List<User> users) { + String replyQuote; + String replyTo; + + com.juick.Message replyMessage = jmsg.getReplyto() > 0 ? messagesService.getReply(jmsg.getMid(), jmsg.getReplyto()) + : messagesService.getMessage(jmsg.getMid()); + replyTo = replyMessage.getUser().getName(); + com.juick.Message fullReply = messagesService.getReply(jmsg.getMid(), jmsg.getRid()); + replyQuote = StringUtils.defaultString(fullReply.getReplyQuote()); + + String txt = "Reply by @" + jmsg.getUser().getName() + ":\n" + replyQuote + "\n@" + replyTo + " "; + String attachmentUrl = MessageUtils.attachmentUrl(fullReply); + if (StringUtils.isNotEmpty(attachmentUrl)) { + txt += attachmentUrl + "\n"; + } + txt += StringUtils.defaultString(jmsg.getText()) + "\n\n" + "#" + jmsg.getMid() + "/" + jmsg.getRid() + " http://juick.com/m/" + jmsg.getMid() + "#" + jmsg.getRid(); + + Message msg = new Message(); + msg.setFrom(jid); + msg.setBody(txt); + msg.setType(Message.Type.CHAT); + msg.addExtension(jmsg); + for (User user : users) { + for (String jid : userService.getJIDsbyUID(user.getUid())) { + msg.setTo(Jid.of(jid)); + router.send(ClientMessage.from(msg)); + } + } + } + + @Override + public void processMessageEvent(MessageEvent event) { + com.juick.Message msg = event.getMessage(); + List<User> subscribers = event.getUsers(); + if (msg.isService()) { + return; + } + if (MessageUtils.isPM(msg)) { + userService.getJIDsbyUID(msg.getTo().getUid()) + .forEach(userJid -> { + Message mm = new Message(); + mm.setTo(Jid.of(userJid)); + mm.setType(Message.Type.CHAT); + boolean inroster = pmQueriesService.havePMinRoster(msg.getUser().getUid(), userJid); + if (inroster) { + mm.setFrom(Jid.of(msg.getUser().getName(), "juick.com", "Juick")); + mm.setBody(msg.getText()); + } else { + mm.setFrom(jid); + mm.setBody("Private message from @" + msg.getUser().getName() + ":\n" + msg.getText()); + } + router.send(ClientMessage.from(mm)); + }); + } else if (MessageUtils.isReply(msg)) { + sendJuickComment(msg, subscribers); + } + else { + sendJuickMessage(msg, subscribers); + } + } + + private ClientMessage makeReply(Jid jidTo, String txt) { + Message reply = new Message(); + reply.setFrom(jid); + reply.setTo(jidTo); + reply.setType(Message.Type.CHAT); + reply.setBody(txt); + return ClientMessage.from(reply); + } + + @Override + public void processSubscribeEvent(SubscribeEvent subscribeEvent) { + + } + + @Override + public void processLikeEvent(LikeEvent likeEvent) { + List<User> users = likeEvent.getSubscribers(); + com.juick.Message jmsg = likeEvent.getMessage(); + User liker = likeEvent.getUser(); + + if (!userService.isInBLAny(jmsg.getUser().getUid(), liker.getUid())) { + userService.getJIDsbyUID(jmsg.getUser().getUid()).forEach(authorJid -> { + Message xmppMessage = new Message(); + xmppMessage.setFrom(jid); + xmppMessage.setTo(Jid.of(authorJid)); + xmppMessage.setType(Message.Type.CHAT); + xmppMessage.addExtension(jmsg); + xmppMessage.setBody(String.format("%s recommended your post #%d. %s", + liker.getName(), jmsg.getMid(), PlainTextFormatter.formatUrl(jmsg))); + router.send(ClientMessage.from(xmppMessage)); + }); + } + + String txt = "Recommended by @" + liker.getName() + ":\n"; + txt += "@" + jmsg.getUser().getName() + ":" + MessageUtils.getTagsString(jmsg) + "\n"; + String attachmentUrl = MessageUtils.attachmentUrl(jmsg); + if (StringUtils.isNotEmpty(attachmentUrl)) { + txt += attachmentUrl + "\n"; + } + txt += StringUtils.defaultString(jmsg.getText()) + "\n\n"; + txt += "#" + jmsg.getMid(); + if (jmsg.getReplies() > 0) { + if (jmsg.getReplies() % 10 == 1 && jmsg.getReplies() % 100 != 11) { + txt += " (" + jmsg.getReplies() + " reply)"; + } else { + txt += " (" + jmsg.getReplies() + " replies)"; + } + } + txt += " http://juick.com/m/" + jmsg.getMid(); + + Nickname nick = new Nickname("@" + jmsg.getUser().getName()); + + Message msg = new Message(); + msg.setFrom(jid); + msg.setBody(txt); + msg.setType(Message.Type.CHAT); + msg.setThread("juick-" + jmsg.getMid()); + msg.addExtension(jmsg); + msg.addExtension(nick); + if (StringUtils.isNotEmpty(attachmentUrl)) { + try { + OobX oob = new OobX(new URI(attachmentUrl)); + msg.addExtension(oob); + } catch (URISyntaxException e) { + logger.warn("uri exception", e); + } + } + + for (User user : users) { + for (String jid : userService.getJIDsbyUID(user.getUid())) { + msg.setTo(Jid.of(jid)); + router.send(ClientMessage.from(msg)); + } + } + } + + @Override + public void processPingEvent(PingEvent pingEvent) { + userService.getJIDsbyUID(pingEvent.getPinger().getUid()) + .forEach(userJid -> { + Presence p = new Presence(Jid.of(userJid)); + p.setFrom(jid); + p.setPriority((byte) 10); + router.send(ClientPresence.from(p)); + }); + } + + @Override + public void processMessageReadEvent(MessageReadEvent messageReadEvent) { + + } + + @Override + public void processTopEvent(TopEvent topEvent) { + com.juick.Message message = topEvent.getMessage(); + try { + commandsManager.processCommand(serviceUser, String.format("! #%d", message.getMid()), URI.create(StringUtils.EMPTY)); + } catch (Exception e) { + logger.warn("XMPP error", e); + } + } + + private void incomingPresence(Presence p) { + final String username = p.getTo().getLocal(); + final boolean toJuick = username.equals(jid.getLocal()); + + if (p.getType() == null) { + Presence reply = new Presence(); + reply.setFrom(p.getTo().asBareJid()); + reply.setTo(p.getFrom().asBareJid()); + reply.setType(Presence.Type.UNSUBSCRIBE); + router.send(ClientPresence.from(reply)); + } else if (p.getType().equals(Presence.Type.PROBE)) { + int uid_to = 0; + if (!toJuick) { + uid_to = userService.getUIDbyName(username); + } else { + User visitor = userService.getUserByJID(p.getFrom().asBareJid().toEscapedString()); + if (visitor != null) { + userService.updateLastSeen(visitor); + } + } + + if (toJuick || uid_to > 0) { + Presence reply = new Presence(); + reply.setFrom(p.getTo().withResource(jid.getResource())); + reply.setTo(p.getFrom()); + reply.setPriority((byte)10); + if (!userService.getActiveJIDs().contains(p.getFrom().asBareJid().toEscapedString())) { + reply.setStatus("Send ON to enable notifications"); + } + router.send(ClientPresence.from(reply)); + } else { + Presence reply = new Presence(); + reply.setFrom(p.getTo()); + reply.setTo(p.getFrom()); + reply.setType(Presence.Type.ERROR); + reply.setId(p.getId()); + reply.setError(new StanzaError(StanzaError.Type.CANCEL, Condition.ITEM_NOT_FOUND)); + router.send(ClientPresence.from(reply)); + } + } else if (p.getType().equals(Presence.Type.SUBSCRIBE)) { + boolean canSubscribe = false; + if (toJuick) { + canSubscribe = true; + } else { + int uid_to = userService.getUIDbyName(username); + if (uid_to > 0) { + pmQueriesService.addPMinRoster(uid_to, p.getFrom().asBareJid().toEscapedString()); + canSubscribe = true; + } + } + if (canSubscribe) { + Presence reply = new Presence(); + reply.setFrom(p.getTo()); + reply.setTo(p.getFrom()); + reply.setType(Presence.Type.SUBSCRIBED); + router.send(ClientPresence.from(reply)); + + reply.setFrom(reply.getFrom().withResource(jid.getResource())); + reply.setPriority((byte) 10); + reply.setType(null); + router.send(ClientPresence.from(reply)); + } else { + Presence reply = new Presence(); + reply.setFrom(p.getTo()); + reply.setTo(p.getFrom()); + reply.setType(Presence.Type.ERROR); + reply.setId(p.getId()); + reply.setError(new StanzaError(StanzaError.Type.CANCEL, Condition.ITEM_NOT_FOUND)); + router.send(ClientPresence.from(reply)); + } + } else if (p.getType().equals(Presence.Type.UNSUBSCRIBE)) { + if (!toJuick) { + int uid_to = userService.getUIDbyName(username); + if (uid_to > 0) { + pmQueriesService.removePMinRoster(uid_to, p.getFrom().asBareJid().toEscapedString()); + } + } + + Presence reply = new Presence(); + reply.setFrom(p.getTo()); + reply.setTo(p.getFrom()); + reply.setType(Presence.Type.UNSUBSCRIBED); + router.send(ClientPresence.from(reply)); + } + } + + public ClientMessage incomingMessage(Message msg) { + ClientMessage result = null; + if (msg.getType() != null && msg.getType().equals(Message.Type.ERROR)) { + StanzaError error = msg.getError(); + if (error != null && error.getCondition().equals(Condition.RESOURCE_CONSTRAINT)) { + // offline query is full, deactivating this jid + if (userService.setActiveStatusForJID(msg.getFrom().toEscapedString(), UserService.ActiveStatus.Inactive)) { + logger.info("{} is inactive now", msg.getFrom()); + return null; + } + } + return null; + } + Jid to = msg.getTo(); + if (to.getDomain().equals(router.getDomain().toEscapedString()) || to.equals(this.jid)) { + User user_from = userService.getUserByJID(msg.getFrom().asBareJid().toEscapedString()); + if ((user_from == null || user_from.isAnonymous()) && !msg.getFrom().equals(jid)) { + String signuphash = userService.getSignUpHashByJID(msg.getFrom().asBareJid().toEscapedString()); + return makeReply(msg.getFrom(), "Для того, чтобы начать пользоваться сервисом, пожалуйста пройдите быструю регистрацию: http://juick.com/signup?type=xmpp&hash=" + signuphash + "\nЕсли у вас уже есть учетная запись на Juick, вы сможете присоединить этот JabberID к ней.\n\nTo start using Juick, please sign up: http://juick.com/signup?type=xmpp&hash=" + signuphash + "\nIf you already have an account on Juick, you will be proposed to attach this JabberID to your existing account."); + } + + com.juick.Message jmsg = msg.getExtension(com.juick.Message.class); + if (jmsg != null) { + if (MessageUtils.isReply(jmsg)) { + // to get quote and attachment + com.juick.Message original = messagesService.getMessage(jmsg.getMid()); + com.juick.Message reply = messagesService.getReply(jmsg.getMid(), jmsg.getRid()); + applicationEventPublisher.publishEvent(new MessageEvent(this, reply, + subscriptionService.getUsersSubscribedToComments(original, reply))); + } else if (!MessageUtils.isPM(jmsg)) { + applicationEventPublisher.publishEvent(new MessageEvent(this, + messagesService.getMessage(jmsg.getMid()), subscriptionService.getSubscribedUsers(jmsg.getUser().getUid(), jmsg))); + } + } else { + URI attachment = URI.create(StringUtils.EMPTY); + OobX oobX = msg.getExtension(OobX.class); + if (oobX != null) { + attachment = oobX.getUri(); + } + try { + if (msg.getTo().asBareJid().equals(jid.asBareJid())) { + return incomingMessageJuick(user_from, msg.getFrom(), StringUtils.defaultString(msg.getBody()), attachment); + } else { + // PM + result = incomingMessageJuick(user_from, msg.getFrom(), + String.format("@%s %s", msg.getTo().getLocal(), StringUtils.defaultString(msg.getBody())), attachment); + } + } catch (Exception e1) { + logger.warn("message exception", e1); + } + } + } else if (to.getDomain().endsWith(jid.getDomain()) && (to.getDomain().equals(jid.getDomain()) + || to.getDomain().endsWith("." + jid.getDomain()))) { + if (logger.isInfoEnabled()) { + try { + logger.info("unhandled message: {}", stanzaToString(msg)); + } catch (JAXBException | XMLStreamException ex) { + logger.error("JAXB exception", ex); + } + } + } else { + return ClientMessage.from(msg); + } + return result; + } + private ClientMessage incomingMessageJuick(User user_from, Jid from, String command, @Nonnull URI attachment) { + if (StringUtils.isBlank(command) && attachment.toString().isEmpty()) { + return null; + } + + int commandlen = command.length(); + + // COMPATIBILITY + if (commandlen > 7 && command.substring(0, 3).equalsIgnoreCase("PM ")) { + command = command.substring(3); + } + + try { + CommandResult result = commandsManager.processCommand(user_from, command.trim(), attachment); + if (StringUtils.isNotBlank(result.getText())) { + return makeReply(from, result.getText()); + } + } catch (Exception e) { + logger.warn("xmpp command exception", e); + return makeReply(from, "Error processing command"); + } + return null; + } + + @Override + public void stanzaReceived(Stanza xmlValue) { + router.send(xmlValue); + } + + private void broadcastPresence(Presence.Type type) { + Presence presence = new Presence(); + presence.setFrom(jid); + if (type != null) { + presence.setType(type); + } + userService.getActiveJIDs().forEach(j -> { + try { + presence.setTo(Jid.of(j)); + router.send(ClientPresence.from(presence)); + } catch (IllegalArgumentException ex) { + logger.warn("Invalid jid: {}", j, ex); + } + }); + } + + @PreDestroy + public void close() throws Exception { + broadcastPresence(Presence.Type.UNAVAILABLE); + if (router != null) { + router.close(); + } + } + + public ExternalComponent getRouter() { + return router; + } +} diff --git a/src/main/java/com/juick/server/XMPPServer.java b/src/main/java/com/juick/server/XMPPServer.java new file mode 100644 index 00000000..86ab6a78 --- /dev/null +++ b/src/main/java/com/juick/server/XMPPServer.java @@ -0,0 +1,429 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server; + +import com.juick.server.xmpp.router.StreamError; +import com.juick.server.xmpp.s2s.*; +import com.juick.service.UserService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.xmlpull.v1.XmlPullParserException; +import rocks.xmpp.addr.Jid; +import rocks.xmpp.core.stanza.model.Stanza; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import javax.inject.Inject; +import javax.net.ssl.*; +import javax.xml.bind.JAXBException; +import javax.xml.bind.Unmarshaller; +import java.io.IOException; +import java.io.StringReader; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.SecureRandom; +import java.security.cert.*; +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * @author ugnich + */ +public class XMPPServer implements ConnectionListener { + private static final Logger logger = LoggerFactory.getLogger("com.juick.server.xmpp"); + + private static final int TIMEOUT_MINUTES = 15; + + @Inject + public ExecutorService service; + @Value("${hostname:localhost}") + private Jid jid; + @Value("${s2s_port:5269}") + private int s2sPort; + @Value("${broken_ssl_hosts:}") + public String[] brokenSSLhosts; + @Value("${banned_hosts:}") + public String[] bannedHosts; + + private final List<ConnectionIn> inConnections = new CopyOnWriteArrayList<>(); + private final Map<ConnectionOut, Optional<Socket>> outConnections = new ConcurrentHashMap<>(); + private final List<CacheEntry> outCache = new CopyOnWriteArrayList<>(); + private final List<StanzaListener> stanzaListeners = new CopyOnWriteArrayList<>(); + private final AtomicBoolean closeFlag = new AtomicBoolean(false); + + SSLContext sc; + CertificateFactory cf; + CertPathValidator cpv; + PKIXParameters params; + private TrustManager[] trustAllCerts = new TrustManager[]{ + new X509TrustManager() { + public void checkClientTrusted(java.security.cert.X509Certificate[] certs, String authType) { + } + + public void checkServerTrusted(java.security.cert.X509Certificate[] certs, String authType) { + } + + public java.security.cert.X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + } + }; + private boolean tlsConfigured = false; + + + private ServerSocket listener; + + @Inject + private BasicXmppSession session; + @Inject + private UserService userService; + @Inject + private KeystoreManager keystoreManager; + + @PostConstruct + public void init() throws KeyStoreException { + closeFlag.set(false); + try { + sc = SSLContext.getInstance("TLSv1.2"); + sc.init(keystoreManager.getKeymanagerFactory().getKeyManagers(), trustAllCerts, new SecureRandom()); + TrustManagerFactory trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + Set<TrustAnchor> ca = new HashSet<>(); + trustManagerFactory.init((KeyStore)null); + Arrays.stream(trustManagerFactory.getTrustManagers()).forEach(t -> Arrays.stream(((X509TrustManager)t).getAcceptedIssuers()).forEach(cert -> ca.add(new TrustAnchor(cert, null)))); + params = new PKIXParameters(ca); + params.setRevocationEnabled(false); + cpv = CertPathValidator.getInstance("PKIX"); + cf = CertificateFactory.getInstance( "X.509" ); + tlsConfigured = true; + } catch (Exception e) { + logger.warn("tls unavailable"); + } + service.submit(() -> { + try { + listener = new ServerSocket(s2sPort); + logger.info("s2s listener ready"); + while (!listener.isClosed()) { + if (Thread.currentThread().isInterrupted()) break; + Socket socket = listener.accept(); + ConnectionIn client = new ConnectionIn(this, socket); + addConnectionIn(client); + service.submit(client); + } + } catch (SocketException e) { + // shutdown + } catch (IOException | XmlPullParserException e) { + logger.warn("xmpp exception", e); + } + }); + } + + public void addConnectionIn(ConnectionIn c) { + c.setListener(this); + inConnections.add(c); + } + + public void addConnectionOut(ConnectionOut c, Optional<Socket> socket) { + c.setListener(this); + outConnections.put(c, socket); + } + + public void removeConnectionIn(ConnectionIn c) { + inConnections.remove(c); + } + + public void removeConnectionOut(ConnectionOut c) { + outConnections.remove(c); + } + + public String getFromCache(Jid to) { + final String[] cache = new String[1]; + outCache.stream().filter(c -> c.hostname != null && c.hostname.equals(to)).findFirst().ifPresent(c -> { + cache[0] = c.xml; + outCache.remove(c); + }); + return cache[0]; + } + + public Optional<ConnectionOut> getConnectionOut(Jid hostname, boolean needReady) { + return outConnections.keySet().stream().filter(c -> c.to != null && + c.to.equals(hostname) && (!needReady || c.streamReady)).findFirst(); + } + + public Optional<ConnectionIn> getConnectionIn(String streamID) { + return inConnections.stream().filter(c -> c.streamID != null && c.streamID.equals(streamID)).findFirst(); + } + + public void sendOut(Jid hostname, String xml) { + boolean haveAnyConn = false; + + ConnectionOut connOut = null; + for (ConnectionOut c : outConnections.keySet()) { + if (c.to != null && c.to.equals(hostname)) { + if (c.streamReady) { + connOut = c; + break; + } else { + haveAnyConn = true; + break; + } + } + } + if (connOut != null) { + connOut.send(xml); + return; + } + + boolean haveCache = false; + for (CacheEntry c : outCache) { + if (c.hostname != null && c.hostname.equals(hostname)) { + c.xml += xml; + c.updated = Instant.now(); + haveCache = true; + break; + } + } + if (!haveCache) { + outCache.add(new CacheEntry(hostname, xml)); + } + + if (!haveAnyConn && !closeFlag.get()) { + try { + createDialbackConnection(hostname.toEscapedString(), null, null); + } catch (Exception e) { + logger.warn("dialback error", e); + } + } + } + + void createDialbackConnection(String to, String checkSID, String dbKey) throws Exception { + ConnectionOut connectionOut = new ConnectionOut(getJid(), Jid.of(to), null, null, checkSID, dbKey); + addConnectionOut(connectionOut, Optional.empty()); + service.submit(() -> { + try { + Socket socket = new Socket(); + socket.connect(DNSQueries.getServerAddress(to)); + connectionOut.setInputStream(socket.getInputStream()); + connectionOut.setOutputStream(socket.getOutputStream()); + addConnectionOut(connectionOut, Optional.of(socket)); + connectionOut.connect(); + } catch (IOException e) { + logger.info("dialback to " + to + " exception", e); + } + }); + } + + public void startDialback(Jid from, String streamId, String dbKey) throws Exception { + Optional<ConnectionOut> c = getConnectionOut(from, false); + if (c.isPresent()) { + c.get().sendDialbackVerify(streamId, dbKey); + } else { + createDialbackConnection(from.toEscapedString(), streamId, dbKey); + } + } + + public void addStanzaListener(StanzaListener listener) { + stanzaListeners.add(listener); + } + + public void onStanzaReceived(String xmlValue) { + logger.info("S2S: {}", xmlValue); + Stanza stanza = parse(xmlValue); + stanzaListeners.forEach(l -> l.stanzaReceived(stanza)); + } + + public BasicXmppSession getSession() { + return session; + } + + public List<ConnectionIn> getInConnections() { + return inConnections; + } + + public Map<ConnectionOut, Optional<Socket>> getOutConnections() { + return outConnections; + } + + @Override + public boolean isTlsAvailable() { + return tlsConfigured; + } + + @Override + public void starttls(ConnectionIn connection) { + logger.debug("stream {} securing", connection.streamID); + connection.sendStanza("<proceed xmlns=\"" + Connection.NS_TLS + "\" />"); + try { + connection.setSocket(sc.getSocketFactory().createSocket(connection.getSocket(), connection.getSocket().getInetAddress().getHostAddress(), + connection.getSocket().getPort(), false)); + SSLSocket sslSocket = (SSLSocket) connection.getSocket(); + sslSocket.addHandshakeCompletedListener(handshakeCompletedEvent -> { + try { + CertPath certPath = cf.generateCertPath(Arrays.asList(handshakeCompletedEvent.getPeerCertificates())); + cpv.validate(certPath, params); + connection.setTrusted(true); + logger.info("connection from {} is trusted", connection.from); + } catch (SSLPeerUnverifiedException | CertificateException | CertPathValidatorException | InvalidAlgorithmParameterException e) { + logger.info("connection from {} is NOT trusted, falling back to dialback", connection.from); + } + }); + sslSocket.setUseClientMode(false); + sslSocket.setNeedClientAuth(true); + sslSocket.startHandshake(); + connection.setSecured(true); + logger.debug("stream from {} secured", connection.streamID); + connection.restartParser(); + } catch (XmlPullParserException | IOException sex) { + logger.warn("stream {} ssl error {}", connection.streamID, sex); + connection.sendStanza("<failure xmlns=\"" + Connection.NS_TLS + "\" />"); + removeConnectionIn(connection); + connection.closeConnection(); + } + } + + @Override + public void proceed(ConnectionOut connection) { + try { + Socket socket = outConnections.get(connection).get(); + socket = sc.getSocketFactory().createSocket(socket, socket.getInetAddress().getHostAddress(), + socket.getPort(), false); + SSLSocket sslSocket = (SSLSocket) socket; + sslSocket.addHandshakeCompletedListener(handshakeCompletedEvent -> { + try { + CertPath certPath = cf.generateCertPath(Arrays.asList(handshakeCompletedEvent.getPeerCertificates())); + cpv.validate(certPath, params); + connection.setTrusted(true); + logger.info("connection to {} is trusted", connection.to); + } catch (SSLPeerUnverifiedException | CertificateException | CertPathValidatorException | InvalidAlgorithmParameterException e) { + logger.info("connection to {} is NOT trusted, falling back to dialback", connection.to); + } + }); + sslSocket.setNeedClientAuth(true); + sslSocket.startHandshake(); + connection.setSecured(true); + logger.debug("stream to {} secured", connection.getStreamID()); + connection.setInputStream(socket.getInputStream()); + connection.setOutputStream(socket.getOutputStream()); + connection.restartStream(); + connection.sendOpenStream(); + } catch (NoSuchElementException | XmlPullParserException | IOException sex) { + logger.error("s2s ssl error: {} {}, error {}", connection.to, connection.getStreamID(), sex); + connection.send("<failure xmlns=\"" + Connection.NS_TLS + "\" />"); + removeConnectionOut(connection); + connection.logoff(); + } + } + + @Override + public void verify(ConnectionOut connection, String from, String type, String sid) { + if (from != null && from.equals(connection.to.toEscapedString()) && sid != null && !sid.isEmpty() && type != null) { + getConnectionIn(sid).ifPresent(c -> c.sendDialbackResult(Jid.of(from), type)); + } + } + + @Override + public void dialbackError(ConnectionOut connection, StreamError error) { + logger.warn("Stream error from {}: {}", connection.getStreamID(), error.getCondition()); + removeConnectionOut(connection); + connection.logoff(); + } + + @Override + public void finished(ConnectionOut connection, boolean dirty) { + logger.warn("stream to {} {} finished, dirty={}", connection.to, connection.getStreamID(), dirty); + removeConnectionOut(connection); + connection.logoff(); + } + + @Override + public void exception(ConnectionOut connection, Exception ex) { + logger.error("s2s out exception: {} {}, exception {}", connection.to, connection.getStreamID(), ex); + removeConnectionOut(connection); + connection.logoff(); + } + + @Override + public void ready(ConnectionOut connection) { + logger.debug("stream to {} {} ready", connection.to, connection.getStreamID()); + String cache = getFromCache(connection.to); + if (cache != null) { + logger.debug("stream to {} {} sending cache", connection.to, connection.getStreamID()); + connection.send(cache); + } + } + + @Override + public boolean securing(ConnectionOut connection) { + return tlsConfigured && !Arrays.asList(brokenSSLhosts).contains(connection.to.toEscapedString()); + } + + public Stanza parse(String xml) { + try { + Unmarshaller unmarshaller = session.createUnmarshaller(); + return (Stanza)unmarshaller.unmarshal(new StringReader(xml)); + } catch (JAXBException e) { + logger.error("JAXB exception", e); + } + return null; + } + + public Jid getJid() { + return jid; + } + @Scheduled(fixedDelay = 10000) + public void cleanUp() { + Instant now = Instant.now(); + outConnections.keySet().stream().filter(c -> Duration.between(c.getUpdated(), now).toMinutes() > TIMEOUT_MINUTES) + .forEach(c -> { + logger.info("closing idle outgoing connection to {}", c.to); + c.logoff(); + outConnections.remove(c); + }); + + inConnections.stream().filter(c -> Duration.between(c.updated, now).toMinutes() > TIMEOUT_MINUTES) + .forEach(c -> { + logger.info("closing idle incoming connection from {}", c.from); + c.closeConnection(); + inConnections.remove(c); + }); + } + @PreDestroy + public void preDestroy() throws IOException { + closeFlag.set(true); + if (listener != null && !listener.isClosed()) { + listener.close(); + } + service.shutdown(); + logger.info("XMPP server destroyed"); + } + + public int getServerPort() { + return s2sPort; + } +} diff --git a/src/main/java/com/juick/server/api/ApiSocialLogin.java b/src/main/java/com/juick/server/api/ApiSocialLogin.java new file mode 100644 index 00000000..8d9f9402 --- /dev/null +++ b/src/main/java/com/juick/server/api/ApiSocialLogin.java @@ -0,0 +1,302 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ +package com.juick.server.api; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.scribejava.apis.FacebookApi; +import com.github.scribejava.apis.VkontakteApi; +import com.github.scribejava.core.builder.ServiceBuilder; +import com.github.scribejava.core.model.OAuth2AccessToken; +import com.github.scribejava.core.model.OAuthRequest; +import com.github.scribejava.core.model.Verb; +import com.github.scribejava.core.oauth.OAuth20Service; +import com.juick.model.facebook.User; +import com.juick.server.util.HttpBadRequestException; +import com.juick.service.CrosspostService; +import com.juick.service.EmailService; +import com.juick.service.TelegramService; +import com.juick.service.UserService; +import com.juick.model.vk.UsersResponse; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.util.UriComponentsBuilder; + +import javax.annotation.PostConstruct; +import javax.inject.Inject; +import java.io.IOException; +import java.util.UUID; +import java.util.concurrent.ExecutionException; + +/** + * + * @author Ugnich Anton + */ +@Controller +public class ApiSocialLogin { + + private static final Logger logger = LoggerFactory.getLogger(ApiSocialLogin.class); + + @Value("${facebook_appid:appid}") + private String FACEBOOK_APPID; + @Value("${facebook_secret:secret}") + private String FACEBOOK_SECRET; + private static final String FACEBOOK_REDIRECT = "https://api.juick.com/_fblogin"; + private static final String VK_REDIRECT = "https://api.juick.com/_vklogin"; + private static final String TWITTER_VERIFY_URL = "https://api.twitter.com/1.1/account/verify_credentials.json"; + @Inject + private ObjectMapper jsonMapper; + private ServiceBuilder facebookBuilder, twitterBuilder, vkBuilder; + + @Value("${twitter_consumer_key:appid}") + private String twitterConsumerKey; + @Value("${twitter_consumer_secret:secret}") + private String twitterConsumerSecret; + @Value("${vk_appid:appid}") + private String VK_APPID; + @Value("${vk_secret:secret}") + private String VK_SECRET; + @Value("${telegram_token:secret}") + private String telegramToken; + + @Inject + private CrosspostService crosspostService; + @Inject + private UserService userService; + @Inject + private EmailService emailService; + @Inject + private TelegramService telegramService; + + @PostConstruct + public void init() { + facebookBuilder = new ServiceBuilder(FACEBOOK_APPID); + twitterBuilder = new ServiceBuilder(twitterConsumerKey); + vkBuilder = new ServiceBuilder(VK_APPID); + } + + @GetMapping("/api/_fblogin") + protected String doFacebookLogin(@RequestParam(required = false) String code, + @RequestParam(required = false) String state) throws IOException, ExecutionException, InterruptedException { + if (StringUtils.isBlank(code)) { + String fbstate = UUID.randomUUID().toString(); + crosspostService.addFacebookState(fbstate, state); + OAuth20Service facebookAuthService = facebookBuilder + .apiSecret(FACEBOOK_SECRET) + .callback(FACEBOOK_REDIRECT) + .scope("email") + .state(fbstate) + .build(FacebookApi.instance()); + return "redirect:" + facebookAuthService.getAuthorizationUrl(); + } + + String redirectUrl = crosspostService.verifyFacebookState(state); + + if (StringUtils.isEmpty(redirectUrl)) { + logger.error("state is missing"); + throw new HttpBadRequestException(); + } + OAuth20Service facebookService = facebookBuilder + .apiKey(FACEBOOK_APPID) + .apiSecret(FACEBOOK_SECRET) + .callback(FACEBOOK_REDIRECT) + .scope("email") + .state(state) + .build(FacebookApi.instance()); + OAuth2AccessToken token = facebookService.getAccessToken(code); + final OAuthRequest meRequest = new OAuthRequest(Verb.GET, "https://graph.facebook.com/v2.10/me?fields=id,name,link,verified,email"); + facebookService.signRequest(token, meRequest); + String graph = facebookService.execute(meRequest).getBody(); + if (StringUtils.isBlank(graph)) { + logger.error("FACEBOOK GRAPH ERROR"); + throw new HttpBadRequestException(); + } + User fb = jsonMapper.readValue(graph, User.class); + long fbID = NumberUtils.toLong(fb.getId(), 0); + if (fbID == 0 || StringUtils.isBlank(fb.getName()) || StringUtils.isBlank(fb.getLink())) { + logger.error("Missing required fields, id: {}, name: {}, link: {}", fbID, fb.getName(), fb.getLink()); + throw new HttpBadRequestException(); + } + + int uid = crosspostService.getUIDbyFBID(fbID); + if (uid > 0) { + if (!crosspostService.updateFacebookUser(fbID, token.getAccessToken(), fb.getName(), fb.getLink())) { + logger.error("error updating facebook user, id: {}, token: {}", fbID, token.getAccessToken()); + throw new HttpBadRequestException(); + } + UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(redirectUrl); + uriComponentsBuilder.queryParam("hash", userService.getHashByUID(uid)); + return "redirect:" + uriComponentsBuilder.build().toUriString(); + } else if (fb.getVerified()) { + if (!crosspostService.createFacebookUser(fbID, state, token.getAccessToken(), fb.getName(), fb.getLink())) { + if (StringUtils.isNotEmpty(fb.getEmail())) { + logger.info("found {} for facebook user {}", fb.getEmail(), fb.getLink()); + Integer userId = crosspostService.getUIDbyFBID(fbID); + if (!emailService.getEmails(userId, false).contains(fb.getEmail())) { + emailService.addEmail(userId, fb.getEmail()); + } + } + logger.info("email not found for facebook user {}", fb.getLink()); + throw new HttpBadRequestException(); + } + return "redirect:/signup?type=fb&hash=" + state; + } else { + logger.error("Facebook account is not verified, id: {}", fbID); + throw new HttpBadRequestException(); + } + }/* + @GetMapping("/_twitter") + protected void doTwitterLogin(HttpServletRequest request, HttpServletResponse response) + throws IOException, ExecutionException, InterruptedException { + String hash = StringUtils.EMPTY, request_token = StringUtils.EMPTY, request_token_secret = StringUtils.EMPTY; + String verifier = request.getParameter("oauth_verifier"); + Cookie[] cookies = request.getCookies(); + for (Cookie cookie : cookies) { + if (cookie.getName().equals("hash")) { + hash = cookie.getValue(); + } + if (cookie.getName().equals("request_token")) { + request_token = cookie.getValue(); + } + if (cookie.getName().equals("request_token_secret")) { + request_token_secret = cookie.getValue(); + } + } + com.juick.User user = UserUtils.getCurrentUser(); + OAuth10aService oAuthService = twitterBuilder + .apiSecret(twitterConsumerSecret) + .callback("http://juick.com/_twitter") + .build(TwitterApi.instance()); + + if (request_token.isEmpty() && request_token_secret.isEmpty() + && (verifier == null || verifier.isEmpty())) { + OAuth1RequestToken requestToken = oAuthService.getRequestToken(); + String authUrl = oAuthService.getAuthorizationUrl(requestToken); + response.addCookie(new Cookie("request_token", requestToken.getToken())); + response.addCookie(new Cookie("request_token_secret", requestToken.getTokenSecret())); + response.setStatus(HttpServletResponse.SC_FOUND); + response.setHeader("Location", authUrl); + } else { + if (verifier != null && verifier.length() > 0) { + OAuth1RequestToken requestToken = new OAuth1RequestToken(request_token, request_token_secret); + OAuth1AccessToken accessToken = oAuthService.getAccessToken(requestToken, verifier); + OAuthRequest oAuthRequest = new OAuthRequest(Verb.GET, TWITTER_VERIFY_URL); + oAuthService.signRequest(accessToken, oAuthRequest); + com.juick.twitter.User twitterUser = jsonMapper.readValue(oAuthService.execute(oAuthRequest).getBody(), + com.juick.twitter.User.class); + if (userService.linkTwitterAccount(user, accessToken.getToken(), accessToken.getTokenSecret(), + twitterUser.getScreenName())) { + response.setStatus(HttpServletResponse.SC_FOUND); + response.setHeader("Location", "http://juick.com/settings"); + } else { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + } + } + }*/ + @GetMapping("/api/_vklogin") + protected String doVKLogin(@RequestParam(required = false) String code, + @RequestParam String state) throws IOException, ExecutionException, InterruptedException { + if (StringUtils.isBlank(code)) { + String vkstate = UUID.randomUUID().toString(); + crosspostService.addVKState(vkstate, state); + OAuth20Service vkAuthService = vkBuilder + .apiSecret(VK_SECRET) + .scope("friends,wall,offline") + .state(vkstate) + .callback(VK_REDIRECT) + .build(VkontakteApi.instance()); + return "redirect:" + vkAuthService.getAuthorizationUrl(); + } + + String redirectUrl = crosspostService.verifyVKState(state); + if (StringUtils.isBlank(redirectUrl)) { + logger.error("state is missing"); + throw new HttpBadRequestException(); + } + + OAuth20Service vkService = vkBuilder + .apiKey(VK_APPID) + .apiSecret(VK_SECRET) + .build(VkontakteApi.instance()); + OAuth2AccessToken token = vkService.getAccessToken(code); + + OAuthRequest meRequest = new OAuthRequest(Verb.GET, "https://api.vk.com/method/users.get?fields=screen_name&v=5.73"); + vkService.signRequest(token, meRequest); + String graph = vkService.execute(meRequest).getBody(); + + com.juick.model.vk.User jsonUser = jsonMapper.readValue(graph, UsersResponse.class).getUsers().get(0); + String vkName = jsonUser.getFirstName() + " " + jsonUser.getLastName(); + String vkLink = jsonUser.getScreenName(); + + if (vkName.length() == 1 || StringUtils.isBlank(vkLink)) { + logger.error("vk user error"); + throw new HttpBadRequestException(); + } + + Long vkID = NumberUtils.toLong(jsonUser.getId(), 0); + int uid = crosspostService.getUIDbyVKID(vkID); + if (uid > 0) { + UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(redirectUrl); + uriComponentsBuilder.queryParam("hash", userService.getHashByUID(uid)); + return "redirect:" + uriComponentsBuilder.build().toUriString(); + } else { + String loginhash = UUID.randomUUID().toString(); + if (!crosspostService.createVKUser(vkID, loginhash, token.getAccessToken(), vkName, vkLink)) { + logger.error("create vk user error"); + throw new HttpBadRequestException(); + } + return "redirect:/signup?type=vk&hash=" + loginhash; + } + } + /* + @GetMapping("/_tglogin") + public String doDurovLogin(HttpServletRequest request, + @RequestParam Map<String, String> params, + HttpServletResponse response) { + String dataCheckString = params.entrySet().stream() + .filter(p -> !p.getKey().equals("hash")) + .sorted(Map.Entry.comparingByKey()) + .map(p -> p.getKey() + "=" + p.getValue()) + .collect(Collectors.joining("\n")); + String hash = params.get("hash"); + byte[] secretKey = DigestUtils.sha256(telegramToken); + String resultString = new HmacUtils(HmacAlgorithms.HMAC_SHA_256, secretKey).hmacHex(dataCheckString); + if (hash.equals(resultString)) { + Long tgUser = Long.valueOf(params.get("id")); + int uid = telegramService.getUser(tgUser); + if (uid > 0) { + Cookie c = new Cookie("hash", userService.getHashByUID(uid)); + c.setMaxAge(50 * 24 * 60 * 60); + response.addCookie(c); + return Utils.getPreviousPageByRequest(request).orElse("redirect:/"); + } else { + String username = StringUtils.defaultString(params.get("username"), params.get("first_name")); + telegramService.createTelegramUser(tgUser, username); + return "redirect:/signup?type=durov&hash=" + userService.getSignUpHashByTelegramID(tgUser, username); + } + } else { + logger.warn("invalid tg hash {} for {}", resultString, hash); + } + throw new HttpBadRequestException(); + }*/ +} diff --git a/src/main/java/com/juick/server/api/Index.java b/src/main/java/com/juick/server/api/Index.java new file mode 100644 index 00000000..56f01370 --- /dev/null +++ b/src/main/java/com/juick/server/api/Index.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.api; + +import com.juick.Status; +import com.juick.server.WebsocketManager; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import springfox.documentation.annotations.ApiIgnore; + +import javax.inject.Inject; +import java.net.URI; + +/** + * Created by vitalyster on 25.07.2016. + */ +@RestController +public class Index { + @Inject + private WebsocketManager wsHandler; + + @ApiIgnore + @RequestMapping(value = { "/api/", "/ws/" }, method = RequestMethod.GET, headers = "Connection!=Upgrade") + public ResponseEntity<Void> description() { + URI redirectUri = ServletUriComponentsBuilder.fromCurrentRequestUri().path("/swagger-ui.html").build().toUri(); + return ResponseEntity.status(HttpStatus.MOVED_PERMANENTLY).location(redirectUri).build(); + } + @ApiIgnore + @RequestMapping(value = "/api/status", method = RequestMethod.GET, + produces = MediaType.APPLICATION_JSON_UTF8_VALUE, headers = "Connection!=Upgrade") + public Status status() { + return Status.getStatus(String.valueOf(wsHandler.getClients().size())); + } +} diff --git a/src/main/java/com/juick/server/api/Messages.java b/src/main/java/com/juick/server/api/Messages.java new file mode 100644 index 00000000..4f0009dd --- /dev/null +++ b/src/main/java/com/juick/server/api/Messages.java @@ -0,0 +1,201 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.api; + +import com.juick.Message; +import com.juick.Tag; +import com.juick.User; +import com.juick.server.Utils; +import com.juick.service.component.MessageReadEvent; +import com.juick.model.CommandResult; +import com.juick.server.util.HttpBadRequestException; +import com.juick.server.util.HttpNotFoundException; +import com.juick.server.util.UserUtils; +import com.juick.service.MessagesService; +import com.juick.service.TagService; +import com.juick.service.UserService; +import org.apache.commons.io.IOUtils; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import javax.inject.Inject; +import java.io.IOException; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; + +/** + * @author ugnich + */ +@RestController +@RequestMapping(produces = MediaType.APPLICATION_JSON_UTF8_VALUE) +public class Messages { + + private static final ResponseEntity<List<com.juick.Message>> NOT_FOUND = ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(Collections.emptyList()); + + private static final ResponseEntity<List<com.juick.Message>> FORBIDDEN = ResponseEntity + .status(HttpStatus.FORBIDDEN) + .body(Collections.emptyList()); + + @Inject + private MessagesService messagesService; + @Inject + private UserService userService; + @Inject + private TagService tagService; + @Inject + private ApplicationEventPublisher applicationEventPublisher; + + // TODO: serialize image urls + + @GetMapping("/api/home") + public ResponseEntity<List<com.juick.Message>> getHome( + @RequestParam(defaultValue = "0") int before_mid) { + User visitor = UserUtils.getCurrentUser(); + if (!visitor.isAnonymous()) { + int vuid = visitor.getUid(); + List<Integer> mids = messagesService.getMyFeed(vuid, before_mid, true); + return ResponseEntity.ok(messagesService.getMessages(visitor, mids)); + } + return FORBIDDEN; + } + + @GetMapping("/api/messages") + public ResponseEntity<List<com.juick.Message>> getMessages( + @RequestParam(required = false) String uname, + @RequestParam(name = "before_mid", defaultValue = "0") Integer before, + @RequestParam(required = false, defaultValue = "0") Integer daysback, + @RequestParam(required = false) String withrecommended, + @RequestParam(required = false) String popular, + @RequestParam(required = false) String search, + @RequestParam(required = false, defaultValue = "0") Integer page, + @RequestParam(required = false) String media, + @RequestParam(required = false) String tag) { + + User visitor = UserUtils.getCurrentUser(); + + List<Integer> mids; + if (!StringUtils.isEmpty(uname)) { + User user = userService.getUserByName(uname); + if (!user.isAnonymous()) { + if (!StringUtils.isEmpty(media)) { + mids = messagesService.getUserPhotos(user.getUid(), 0, before); + } else if (!StringUtils.isEmpty(tag)) { + Tag tagObject = tagService.getTag(tag, false); + if (tagObject != null) { + mids = messagesService.getUserTag(user.getUid(), tagObject.TID, 0, before); + } else { + return NOT_FOUND; + } + } else if (!StringUtils.isEmpty(withrecommended)) { + mids = messagesService.getUserBlogWithRecommendations(user.getUid(), 0, before); + } else if (daysback > 0) { + mids = messagesService.getUserBlogAtDay(user.getUid(), 0, daysback); + } else if (!StringUtils.isEmpty(search)) { + mids = messagesService.getUserSearch(visitor, user.getUid(), Utils.encodeSphinx(search), 0, page); + } else { + mids = messagesService.getUserBlog(user.getUid(), 0, before); + } + } else { + return NOT_FOUND; + } + } else { + if (!StringUtils.isEmpty(popular)) { + mids = messagesService.getPopular(visitor.getUid(), before); + } else if (!StringUtils.isEmpty(media)) { + mids = messagesService.getPhotos(visitor.getUid(), before); + } else if (!StringUtils.isEmpty(tag)) { + Tag tagObject = tagService.getTag(tag, false); + if (tagObject != null) { + mids = messagesService.getTag(tagObject.TID, visitor.getUid(), before, 20); + } else { + return NOT_FOUND; + } + } else if (!StringUtils.isEmpty(search)) { + mids = messagesService.getSearch(visitor, Utils.encodeSphinx(search), page); + } else { + mids = messagesService.getAll(visitor.getUid(), before); + } + } + return ResponseEntity.ok(messagesService.getMessages(visitor, mids)); + } + @DeleteMapping("/api/messages") + public CommandResult deleteMessage(@RequestParam int mid, @RequestParam(required = false, defaultValue = "0") int rid) { + User visitor = UserUtils.getCurrentUser(); + if (rid > 0) { + if (messagesService.deleteReply(visitor.getUid(), mid, rid)) { + return CommandResult.fromString("Reply deleted"); + } + } + if (messagesService.deleteMessage(visitor.getUid(), mid)) { + return CommandResult.fromString("Message deleted"); + } + throw new HttpBadRequestException(); + } + @GetMapping("/api/messages/discussions") + public List<Message> getDiscussions( + @RequestParam(required = false, defaultValue = "0") Long to) { + return messagesService.getMessages(UserUtils.getCurrentUser(), messagesService.getDiscussions(UserUtils.getCurrentUser().getUid(), to)); + } + @GetMapping("/api/thread") + public ResponseEntity<List<com.juick.Message>> getThread( + @RequestParam(defaultValue = "0") int mid) { + User visitor = UserUtils.getCurrentUser(); + com.juick.Message msg = messagesService.getMessage(mid); + if (msg != null) { + if (!messagesService.canViewThread(mid, visitor.getUid())) { + return FORBIDDEN; + } else { + if (userService.getUserByName(msg.getUser().getName()).isBanned()) { + throw new HttpNotFoundException(); + } + msg.setRecommendations(new HashSet<>(messagesService.getMessageRecommendations(msg.getMid()))); + List<com.juick.Message> replies = messagesService.getReplies(visitor, mid); + if (!visitor.isAnonymous()) { + userService.updateLastSeen(visitor); + applicationEventPublisher.publishEvent( + new MessageReadEvent(this, visitor, msg)); + } + replies.add(0, msg); + return ResponseEntity.ok(replies); + } + } + return NOT_FOUND; + } + @GetMapping(value = "/api/thread/mark_read/{mid}-{rid}.gif", produces = MediaType.IMAGE_GIF_VALUE) + public byte[] markThreadRead(@PathVariable int mid, @PathVariable int rid) throws IOException { + User visitor = UserUtils.getCurrentUser(); + if (!visitor.isAnonymous()) { + messagesService.setLastReadComment(visitor, mid, rid); + Message msg = messagesService.getMessage(mid); + userService.updateLastSeen(visitor); + applicationEventPublisher.publishEvent( + new MessageReadEvent(this, visitor, msg)); + return IOUtils.toByteArray( + Objects.requireNonNull(getClass().getClassLoader().getResource("Transparent.gif"))); + } + throw new HttpBadRequestException(); + } +} diff --git a/src/main/java/com/juick/server/api/Notifications.java b/src/main/java/com/juick/server/api/Notifications.java new file mode 100644 index 00000000..62275f5a --- /dev/null +++ b/src/main/java/com/juick/server/api/Notifications.java @@ -0,0 +1,221 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.api; + +import com.juick.Message; +import com.juick.Status; +import com.juick.ExternalToken; +import com.juick.User; +import com.juick.model.AnonymousUser; +import com.juick.server.util.HttpBadRequestException; +import com.juick.server.util.HttpForbiddenException; +import com.juick.service.MessagesService; +import com.juick.service.PushQueriesService; +import com.juick.service.SubscriptionService; +import com.juick.server.util.UserUtils; +import com.juick.service.UserService; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import springfox.documentation.annotations.ApiIgnore; + +import javax.inject.Inject; +import java.io.IOException; +import java.security.Principal; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Created by vitalyster on 24.10.2016. + */ +@RestController +public class Notifications { + + @Inject + private PushQueriesService pushQueriesService; + @Inject + private MessagesService messagesService; + @Inject + private SubscriptionService subscriptionService; + @Inject + private UserService userService; + + + private User collectTokens(Integer uid) { + User user = userService.getUserByUID(uid).orElse(AnonymousUser.INSTANCE); + user.setUnreadCount(messagesService.getUnread(user).size()); + pushQueriesService.getGCMRegID(uid).forEach(t -> user.getTokens().add(new ExternalToken(null, "gcm", t, null))); + pushQueriesService.getAPNSToken(uid).forEach(t -> user.getTokens().add(new ExternalToken(null, "apns", t, null))); + pushQueriesService.getMPNSURL(uid).forEach(t -> user.getTokens().add(new ExternalToken(null, "mpns", t, null))); + return user; + } + + @ApiIgnore + @RequestMapping(value = "/api/notifications", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) + public ResponseEntity<List<User>> doGet( + @RequestParam(required = false, defaultValue = "0") int uid, + @RequestParam(required = false, defaultValue = "0") int mid, + @RequestParam(required = false, defaultValue = "0") int rid) { + User visitor = UserUtils.getCurrentUser(); + if (visitor.isAnonymous() || !(visitor.getName().equals("juick"))) { + throw new HttpForbiddenException(); + } + if (uid > 0 && mid == 0) { + // PM + return ResponseEntity.ok(Collections.singletonList(collectTokens(uid))); + } else { + if (mid > 0) { + // reply + Message msg = messagesService.getMessage(mid); + if (msg != null) { + List<User> users; + if (rid > 0) { + Message op = messagesService.getMessage(mid); + Message reply = messagesService.getReply(mid, rid); + users = subscriptionService.getUsersSubscribedToComments(op, reply); + } else { + users = subscriptionService.getSubscribedUsers(msg.getUser().getUid(), msg); + } + + return ResponseEntity.ok(users.stream().map(User::getUid) + .map(this::collectTokens).collect(Collectors.toList())); + } + } else { + // read + return ResponseEntity.ok(Collections.singletonList(collectTokens(uid))); + } + } + throw new HttpBadRequestException(); + } + + @ApiIgnore + @RequestMapping(value = "/api/notifications", method = RequestMethod.DELETE, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) + public Status doDelete( + @RequestBody List<ExternalToken> list) { + User visitor = UserUtils.getCurrentUser(); + if ((visitor.isAnonymous()) || !(visitor.getName().equals("juick"))) { + throw new HttpForbiddenException(); + } + list.forEach(t -> { + switch (t.getType()) { + case "gcm": + pushQueriesService.deleteGCMToken(t.getToken()); + break; + case "apns": + pushQueriesService.deleteAPNSToken(t.getToken()); + break; + case "mpns": + pushQueriesService.deleteMPNSToken(t.getToken()); + break; + default: + throw new HttpBadRequestException(); + } + }); + + return Status.OK; + } + @ApiIgnore + @RequestMapping(value = "/api/notifications/delete", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) + public Status doDeleteTokens( + @RequestBody List<ExternalToken> list) { + User visitor = UserUtils.getCurrentUser(); + if ((visitor.isAnonymous()) || !(visitor.getName().equals("juick"))) { + throw new HttpForbiddenException(); + } + list.forEach(t -> { + switch (t.getType()) { + case "gcm": + pushQueriesService.deleteGCMToken(t.getToken()); + break; + case "apns": + pushQueriesService.deleteAPNSToken(t.getToken()); + break; + case "mpns": + pushQueriesService.deleteMPNSToken(t.getToken()); + break; + default: + throw new HttpBadRequestException(); + } + }); + + return Status.OK; + } + + @ApiIgnore + @RequestMapping(value = "/api/notifications", method = RequestMethod.PUT, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) + public Status doPut( + @RequestBody List<ExternalToken> list) throws IOException { + User visitor = UserUtils.getCurrentUser(); + if (visitor.isAnonymous()) { + throw new HttpForbiddenException(); + } + list.forEach(t -> { + switch (t.getType()) { + case "gcm": + pushQueriesService.addGCMToken(visitor.getUid(), t.getToken()); + break; + case "apns": + pushQueriesService.addAPNSToken(visitor.getUid(), t.getToken()); + break; + case "mpns": + pushQueriesService.addMPNSToken(visitor.getUid(), t.getToken()); + break; + default: + throw new HttpBadRequestException(); + } + }); + return Status.OK; + } + + @Deprecated + @RequestMapping(value = "/api/android/register", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) + public Status doAndroidRegister( + @RequestParam(name = "regid") String regId) { + User visitor = UserUtils.getCurrentUser(); + if (visitor.isAnonymous()) { + throw new HttpForbiddenException(); + } + pushQueriesService.addGCMToken(visitor.getUid(), regId); + return Status.OK; + } + + @Deprecated + @RequestMapping(value = "/api/android/unregister", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) + public Status doAndroidUnRegister(@RequestParam(name = "regid") String regId) { + pushQueriesService.deleteGCMToken(regId); + return Status.OK; + } + + @Deprecated + @RequestMapping(value = "/api/winphone/register", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) + public Status doWinphoneRegister( + Principal principal, + @RequestParam(name = "url") String regId) { + User visitor = UserUtils.getCurrentUser(); + pushQueriesService.addMPNSToken(visitor.getUid(), regId); + return Status.OK; + } + + @Deprecated + @RequestMapping(value = "/api/winphone/unregister", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) + public Status doWinphoneUnRegister(@RequestParam(name = "url") String regId) { + pushQueriesService.deleteMPNSToken(regId); + return Status.OK; + } +} diff --git a/src/main/java/com/juick/server/api/PM.java b/src/main/java/com/juick/server/api/PM.java new file mode 100644 index 00000000..0c36fe00 --- /dev/null +++ b/src/main/java/com/juick/server/api/PM.java @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.api; + +import com.juick.Chat; +import com.juick.User; +import com.juick.service.component.MessageEvent; +import com.juick.model.AnonymousUser; +import com.juick.model.PrivateChats; +import com.juick.server.util.*; +import com.juick.service.PMQueriesService; +import com.juick.service.UserService; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.inject.Inject; +import java.util.Collections; +import java.util.List; + +/** + * @author ugnich + */ +@RestController +public class PM { + @Inject + private UserService userService; + @Inject + private PMQueriesService pmQueriesService; + @Inject + private ApplicationEventPublisher applicationEventPublisher; + + @RequestMapping(value = "/api/pm", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) + public List<com.juick.Message> doGetPM( + @RequestParam(required = false) String uname) { + User visitor = UserUtils.getCurrentUser(); + if (visitor.isAnonymous()) { + throw new HttpForbiddenException(); + } + int uid = 0; + if (uname != null && uname.matches("^[a-zA-Z0-9\\-]{2,16}$")) { + uid = userService.getUIDbyName(uname); + } + + if (uid == 0) { + throw new HttpBadRequestException(); + } + + return pmQueriesService.getPMMessages(visitor.getUid(), uid); + } + + @RequestMapping(value = "/api/pm", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) + public com.juick.Message doPostPM( + @RequestParam String uname, + @RequestParam String body) { + User visitor = UserUtils.getCurrentUser(); + if (visitor.isAnonymous()) { + throw new HttpForbiddenException(); + } + User userTo = AnonymousUser.INSTANCE; + if (WebUtils.isUserName(uname)) { + userTo = userService.getUserByName(uname); + } + + if (userTo.getUid() == 0 || body == null || body.length() < 1 || body.length() > 10240) { + throw new HttpBadRequestException(); + } + + if (userService.isInBLAny(userTo.getUid(), visitor.getUid())) { + throw new HttpForbiddenException(); + } + + if (pmQueriesService.createPM(visitor.getUid(), userTo.getUid(), body)) { + com.juick.Message jmsg = new com.juick.Message(); + jmsg.setUser(visitor); + jmsg.setText(body); + jmsg.setTo(userTo); + applicationEventPublisher.publishEvent(new MessageEvent(this, jmsg, Collections.singletonList(jmsg.getTo()))); + return jmsg; + + } + throw new HttpBadRequestException(); + } + @RequestMapping(value = "/api/groups_pms", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) + public PrivateChats doGetGroupsPMs( + @RequestParam(defaultValue = "5") int cnt) { + User visitor = UserUtils.getCurrentUser(); + if (visitor.isAnonymous()) { + throw new HttpForbiddenException(); + } + // TODO: ignore cnt param for now but make sure paging param will not be cnt + + List<Chat> lastconv = pmQueriesService.getLastChats(visitor); + PrivateChats pms = new PrivateChats(); + pms.setUsers(lastconv); + return pms; + } +} diff --git a/src/main/java/com/juick/server/api/Post.java b/src/main/java/com/juick/server/api/Post.java new file mode 100644 index 00000000..303ff109 --- /dev/null +++ b/src/main/java/com/juick/server/api/Post.java @@ -0,0 +1,245 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.api; + +import com.juick.Message; +import com.juick.Reaction; +import com.juick.Status; +import com.juick.User; +import com.juick.server.CommandsManager; +import com.juick.model.CommandResult; +import com.juick.server.util.*; +import com.juick.service.MessagesService; +import com.juick.service.SubscriptionService; +import com.juick.service.UserService; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.inject.Inject; +import javax.validation.constraints.NotNull; +import java.net.URI; +import java.net.URL; +import java.util.List; + +/** + * Created by vt on 24/11/2016. + */ +@RestController +public class Post { + private static Logger logger = LoggerFactory.getLogger(Post.class); + + @Inject + private UserService userService; + @Inject + private MessagesService messagesService; + @Inject + private SubscriptionService subscriptionService; + @Value("${upload_tmp_dir:#{systemEnvironment['TEMP'] ?: '/tmp'}}") + private String tmpDir; + @Value("${img_path:#{systemEnvironment['TEMP'] ?: '/tmp'}}") + private String imgDir; + @Inject + CommandsManager commandsManager; + + @RequestMapping(value = "/api/post", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) + @ResponseStatus(value = HttpStatus.OK) + public CommandResult doPostMessage( + @RequestParam(required = false, defaultValue = StringUtils.EMPTY) String body, + @RequestParam(required = false) String img, + @RequestParam(required = false) MultipartFile attach) throws Exception { + User visitor = UserUtils.getCurrentUser(); + + if (visitor.isAnonymous()) + throw new HttpForbiddenException(); + + if (body.length() > 4096) { + throw new HttpBadRequestException(); + } + body = body.replace("\r", StringUtils.EMPTY); + + URI attachmentFName = HttpUtils.receiveMultiPartFile(attach, tmpDir); + + if (StringUtils.isBlank(attachmentFName.toString()) && img != null && img.length() > 10) { + URI juickUri = URI.create(img); + if (juickUri.getScheme().equals("juick")) { + attachmentFName = juickUri; + } else { + try { + URL imgUrl = new URL(img); + attachmentFName = HttpUtils.downloadImage(imgUrl, tmpDir); + } catch (Exception e) { + logger.error("DOWNLOAD ERROR", e); + throw new HttpBadRequestException(); + } + } + } + if (StringUtils.isBlank(body) && StringUtils.isBlank(attachmentFName.toString())) { + // Should be there for compatibility + throw new HttpBadRequestException(); + } + return commandsManager.processCommand(visitor, body, attachmentFName); + } + + @RequestMapping(value = "/api/comment", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) + public CommandResult doPostComment( + @RequestParam(defaultValue = "0") int mid, + @RequestParam(defaultValue = "0") int rid, + @RequestParam(required = false, defaultValue = StringUtils.EMPTY) String body, + @RequestParam(required = false) String img, + @RequestParam(required = false) MultipartFile attach) + throws Exception { + User visitor = UserUtils.getCurrentUser(); + int vuid = visitor.getUid(); + if (vuid == 0) { + throw new HttpForbiddenException(); + } + if (mid == 0) { + throw new HttpBadRequestException(); + } + com.juick.Message msg = messagesService.getMessage(mid); + if (msg == null) { + throw new HttpNotFoundException(); + } + + com.juick.Message reply = null; + if (rid > 0) { + reply = messagesService.getReply(mid, rid); + if (reply == null) { + throw new HttpNotFoundException(); + } + } + + if (body.length() > 4096) { + throw new HttpBadRequestException(); + } + body = body.replace("\r", StringUtils.EMPTY); + + if ((msg.ReadOnly && msg.getUser().getUid() != vuid) || userService.isInBLAny(msg.getUser().getUid(), vuid) + || (reply != null && userService.isInBLAny(reply.getUser().getUid(), vuid))) { + throw new HttpForbiddenException(); + } + + URI attachmentFName = HttpUtils.receiveMultiPartFile(attach, tmpDir); + + if (StringUtils.isBlank(attachmentFName.toString()) && img != null && img.length() > 10) { + try { + attachmentFName = HttpUtils.downloadImage(new URL(img), tmpDir); + } catch (Exception e) { + logger.error("DOWNLOAD ERROR", e); + throw new HttpBadRequestException(); + } + } + if (StringUtils.isBlank(body) && StringUtils.isBlank(attachmentFName.toString())) { + // Should be there for compatibility + throw new HttpBadRequestException(); + } + return commandsManager.processCommand(visitor, String.format("#%d/%d %s", mid, rid, body), + attachmentFName); + } + + @PostMapping("/api/like") + @ResponseStatus(value = HttpStatus.OK) + public Status doPostRecomm(@RequestParam Integer mid) throws Exception { + com.juick.User visitor = UserUtils.getCurrentUser(); + if (visitor.isAnonymous()) { + throw new HttpForbiddenException(); + } + com.juick.Message msg = messagesService.getMessage(mid); + if (msg == null) { + throw new HttpNotFoundException(); + } + if (msg.getUser().getUid() == visitor.getUid()) { + throw new HttpForbiddenException(); + } + CommandResult status = commandsManager.processCommand(visitor, String.format("! #%d", mid), + URI.create(StringUtils.EMPTY)); + return Status.getStatus(status.getText()); + } + + @PostMapping("/api/subscribe") + @ResponseStatus(value = HttpStatus.OK) + public Status doPostSubscribe(@RequestParam Integer mid) throws Exception { + com.juick.User visitor = UserUtils.getCurrentUser(); + if (visitor.isAnonymous()) { + throw new HttpForbiddenException(); + } + com.juick.Message msg = messagesService.getMessage(mid); + if (msg == null) { + throw new HttpNotFoundException(); + } + if (msg.getUser().getUid() == visitor.getUid()) { + throw new HttpForbiddenException(); + } + CommandResult status = commandsManager.processCommand(visitor, String.format("S #%d", mid), + URI.create(StringUtils.EMPTY)); + return Status.getStatus(status.getText()); + } + + @GetMapping("/api/reactions") + @ResponseStatus(value = HttpStatus.OK) + public List<Reaction> reactionsList() { + return messagesService.listReactions(); + } + + @PostMapping("/api/react") + @ResponseStatus(value = HttpStatus.OK) + public Status doPostReact(@RequestParam Integer mid,@RequestParam @NotNull int reactionId, + @RequestParam (required = false, defaultValue = "1") int count) { + + logger.info("got reaction with type: {}", reactionId); + com.juick.User visitor = UserUtils.getCurrentUser(); + if (visitor.isAnonymous()) { + throw new HttpForbiddenException(); + } + com.juick.Message msg = messagesService.getMessage(mid); + if (msg == null) { + throw new HttpNotFoundException(); + } + if (msg.getUser().getUid() == visitor.getUid()) { + throw new HttpForbiddenException(); + } + MessagesService.RecommendStatus recommendStatus = MessagesService.RecommendStatus.Error; + for (int i = 0; i < count; i++) + recommendStatus = messagesService.likeMessage(mid, visitor.getUid(), + reactionId); + + return recommendStatus == MessagesService.RecommendStatus.Error ? Status.ERROR :Status.OK; + } + + @PostMapping("/api/update") + public CommandResult updateMessage(@RequestParam Integer mid, + @RequestParam(required = false, defaultValue = "0") Integer rid, + @RequestParam String body) { + User visitor = UserUtils.getCurrentUser(); + User author = rid == 0 ? messagesService.getMessageAuthor(mid) : messagesService.getReply(mid, rid).getUser(); + if (visitor.equals(author)) { + if (messagesService.updateMessage(mid, rid, body)) { + Message result = rid == 0 ? messagesService.getMessage(mid) : messagesService.getReply(mid, rid); + return CommandResult.build(result, "Message updated", StringUtils.EMPTY); + } + throw new HttpBadRequestException(); + } + throw new HttpForbiddenException(); + } +} diff --git a/src/main/java/com/juick/server/api/Service.java b/src/main/java/com/juick/server/api/Service.java new file mode 100644 index 00000000..ed62886f --- /dev/null +++ b/src/main/java/com/juick/server/api/Service.java @@ -0,0 +1,166 @@ +package com.juick.server.api; + +import com.juick.Message; +import com.juick.User; +import com.juick.server.CommandsManager; +import com.juick.server.EmailManager; +import com.juick.server.ServerManager; +import com.juick.server.util.HttpForbiddenException; +import com.juick.server.util.UserUtils; +import com.juick.service.EmailService; +import com.juick.service.UserService; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.mail.util.MimeMessageParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.context.request.async.AsyncRequestTimeoutException; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import springfox.documentation.annotations.ApiIgnore; + +import javax.inject.Inject; +import javax.mail.Session; +import javax.mail.internet.InternetAddress; +import javax.mail.internet.MimeMessage; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; +import java.util.*; + +@Controller +public class Service { + private static Logger logger = LoggerFactory.getLogger(Service.class); + @Inject + private UserService userService; + @Inject + private EmailService emailService; + @Inject + private CommandsManager commandsManager; + @Inject + private EmailManager emailManager; + @Value("${api_user:juick}") + private String serviceUser; + @Value("${upload_tmp_dir:#{systemEnvironment['TEMP'] ?: '/tmp'}}") + private String tmpDir; + @Value("${banned_emails:}") + private String[] ignoredEmails; + @Inject + private ServerManager serverManager; + + private Session session = Session.getDefaultInstance(new Properties()); + + @ApiIgnore + @PostMapping("/api/mail") + @ResponseStatus(value = HttpStatus.OK) + public void processMail(InputStream data) throws Exception { + if (UserUtils.getCurrentUser().getName().equals(serviceUser)) { + MimeMessage msg = new MimeMessage(session, data); + String[] returnPaths = msg.getHeader("Return-Path"); + if (returnPaths != null) { + logger.info("got msg with return path {}", returnPaths[0]); + if (returnPaths[0].equals("<>")) { + return; + } + } + String from = msg.getFrom() == null || msg.getFrom().length > 1 ? ((InternetAddress) msg.getSender()).getAddress() + : ((InternetAddress) msg.getFrom()[0]).getAddress(); + + User visitor = userService.getUserByEmail(from); + if (!visitor.isAnonymous()) { + MimeMessageParser parser = new MimeMessageParser(msg); + parser.parse(); + final String[] body = {parser.getPlainContent()}; + if (body[0] == null) { + parser.getAttachmentList().stream() + .filter(a -> a.getContentType().equals("text/plain")).findFirst() + .ifPresent(a -> { + try { + body[0] = IOUtils.toString(a.getInputStream(), StandardCharsets.UTF_8); + logger.info("got text: {}", body[0]); + } catch (IOException e) { + logger.info("attachment error: {}", e); + } + }); + } + final String[] attachmentFName = new String[1]; + parser.getAttachmentList().stream().filter(a -> + a.getContentType().equals("image/jpeg") || a.getContentType().equals("image/png")) + .findFirst().ifPresent(a -> { + logger.info("got attachment: {}", a.getContentType()); + String attachmentType; + if (a.getContentType().equals("image/jpeg")) { + attachmentType = "jpg"; + } else { + attachmentType = "png"; + } + attachmentFName[0] = DigestUtils.md5Hex(UUID.randomUUID().toString()) + "." + attachmentType; + try { + logger.info("got inputstream: {}", a.getInputStream()); + FileOutputStream fos = new FileOutputStream(Paths.get(tmpDir, attachmentFName[0]).toString()); + IOUtils.copy(a.getInputStream(), fos); + fos.close(); + } catch (IOException e) { + logger.info("attachment error: {}", e); + } + }); + String[] inReplyToHeaders = msg.getHeader("In-Reply-To"); + if (inReplyToHeaders != null && inReplyToHeaders.length > 0) { + Scanner inReplyToScanner = new Scanner(inReplyToHeaders[0].trim()).useDelimiter(EmailManager.MSGID_PATTERN); + int mid = Integer.parseInt(inReplyToScanner.next()); + int rid = Integer.parseInt(inReplyToScanner.next()); + logger.info("Message is reply to #{}/{}", mid, rid); + body[0] = rid > 0 ? String.format("#%d/%d %s", mid, rid, body[0]) + : String.format("#%d %s", mid, body[0]); + } + URI attachmentUri = StringUtils.isNotEmpty(attachmentFName[0]) ? URI.create(String.format("juick://%s", attachmentFName[0])) + : URI.create(StringUtils.EMPTY); + commandsManager.processCommand(visitor, body[0], attachmentUri); + } else { + if (!Arrays.asList(ignoredEmails).contains(from)) { + String verificationCode = RandomStringUtils.randomAlphanumeric(8).toUpperCase(); + emailService.addVerificationCode(null, from, verificationCode); + String signupUrl = String.format("Follow this link to create Juick account: https://juick.com/signup?type=email&hash=%s", verificationCode); + emailManager.sendEmail(from, "Juick registration", signupUrl, StringUtils.EMPTY, Collections.emptyMap()); + } + } + } else { + throw new HttpForbiddenException(); + } + } + private void endSession(SseEmitter emitter) { + serverManager.getSessions().stream() + .filter(s -> s.getEmitter().equals(emitter)) + .forEach(session -> serverManager.getSessions().remove(session)); + } + @GetMapping("/api/events") + public SseEmitter handle() throws IOException { + User visitor = UserUtils.getCurrentUser(); + logger.info("{} connected", visitor.getName()); + if (!visitor.isAnonymous()) { + userService.updateLastSeen(visitor); + } + SseEmitter emitter = new SseEmitter(600000L); + serverManager.getSessions().add(new ServerManager.EventSession(visitor, emitter)); + + emitter.onCompletion(() -> endSession(emitter)); + emitter.onTimeout(() -> endSession(emitter)); + + return emitter; + } + @ExceptionHandler(AsyncRequestTimeoutException.class) + public void eventErrorHandler(Exception ex) { + logger.debug("SSE timeout", ex); + } +} diff --git a/src/main/java/com/juick/server/api/Tags.java b/src/main/java/com/juick/server/api/Tags.java new file mode 100644 index 00000000..7a8e572a --- /dev/null +++ b/src/main/java/com/juick/server/api/Tags.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.api; + +import com.juick.User; +import com.juick.model.TagStats; +import com.juick.server.util.UserUtils; +import com.juick.service.TagService; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.inject.Inject; +import java.util.List; + +/** + * Created by vitalyster on 29.11.2016. + */ +@RestController +public class Tags { + @Inject + private TagService tagService; + + @RequestMapping(value = "/api/tags", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) + public List<TagStats> tags( + @RequestParam(required = false, defaultValue = "0") int user_id + ) { + User visitor = UserUtils.getCurrentUser(); + if (user_id == 0) { + user_id = visitor.getUid(); + } + if (user_id > 0) { + return tagService.getUserTagStats(user_id); + } + return tagService.getTagStats(); + } +} diff --git a/src/main/java/com/juick/server/api/Users.java b/src/main/java/com/juick/server/api/Users.java new file mode 100644 index 00000000..7686d722 --- /dev/null +++ b/src/main/java/com/juick/server/api/Users.java @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.api; + +import com.juick.User; +import com.juick.model.ApplicationStatus; +import com.juick.model.UserInfo; +import com.juick.server.util.HttpForbiddenException; +import com.juick.server.util.HttpNotFoundException; +import com.juick.service.CrosspostService; +import com.juick.service.EmailService; +import com.juick.service.MessagesService; +import com.juick.service.UserService; +import com.juick.server.util.UserUtils; +import com.juick.server.util.WebUtils; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; + +import javax.inject.Inject; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * @author ugnich + */ +@RestController +public class Users { + @Inject + private UserService userService; + @Inject + private MessagesService messagesService; + @Inject + private CrosspostService crosspostService; + @Inject + private EmailService emailService; + + @RequestMapping(value = "/api/auth", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) + public String getAuthToken() { + return userService.getHashByUID(UserUtils.getCurrentUser().getUid()); + } + + @RequestMapping(value = "/api/users", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) + public List<User> doGetUsers( + @RequestParam(value = "uname", required = false) List<String> unames) { + List<com.juick.User> users = new ArrayList<>(); + + if (unames != null) { + unames.removeIf(WebUtils::isNotUserName); + + if (!unames.isEmpty() && unames.size() < 20) + users.addAll(userService.getUsersByName(unames)); + } + + if (!users.isEmpty()) + return users; + if (!UserUtils.getCurrentUser().isAnonymous()) { + return Collections.singletonList(UserUtils.getCurrentUser()); + } + + throw new HttpNotFoundException(); + } + + @GetMapping("/api/me") + public SecureUser getMe() { + User visitor = UserUtils.getCurrentUser(); + SecureUser me = new SecureUser(); + me.setUid(visitor.getUid()); + me.setName(visitor.getName()); + me.setAuthHash(getAuthToken()); + List<Integer> unread = messagesService.getUnread(visitor); + me.setUnread(unread); + me.setUnreadCount(unread.size()); + me.setRead(userService.getUserFriends(visitor.getUid())); + me.setReaders(userService.getUserReaders(visitor.getUid())); + return me; + } + + @RequestMapping(value = "/api/users/read", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) + public List<User> doGetUserRead( + @RequestParam String uname) { + User visitor = UserUtils.getCurrentUser(); + if (visitor.isAnonymous()) { + throw new HttpForbiddenException(); + } + int uid = 0; + if (uname == null) { + uid = visitor.getUid(); + } else { + if (WebUtils.isUserName(uname)) { + com.juick.User u = userService.getUserByName(uname); + if (!u.isAnonymous()) { + uid = u.getUid(); + } + } + } + + if (uid > 0) { + return userService.getUserFriends(uid); + } + throw new HttpNotFoundException(); + } + + @RequestMapping(value = "/api/users/readers", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) + public List<User> doGetUserReaders( + @RequestParam String uname) { + User visitor = UserUtils.getCurrentUser(); + if (visitor.isAnonymous()) { + throw new HttpForbiddenException(); + } + int uid = 0; + if (uname == null) { + uid = visitor.getUid(); + } else { + if (WebUtils.isUserName(uname)) { + com.juick.User u = userService.getUserByName(uname); + if (!u.isAnonymous()) { + uid = u.getUid(); + } + } + } + + if (uid > 0) { + return userService.getUserReaders(uid); + } + throw new HttpNotFoundException(); + } + + @GetMapping("/api/info/{uname}") + public UserInfo getUserInfo(@PathVariable String uname) { + User user = userService.getUserByName(uname); + if (!user.isBanned()) { + return userService.getUserInfo(user); + } + throw new HttpNotFoundException(); + } + + class SecureUser extends User { + public String getHash() { + return getAuthHash(); + } + public UserInfo getUserInfo() { + return userService.getUserInfo(this); + } + public List<String> getJIDs() { + return userService.getAllJIDs(this); + } + public List<String> getEmails() { + return userService.getEmails(this); + } + public String getActiveEmail() { + return emailService.getNotificationsEmail(this.getUid()); + } + public String getTwitterName() { + return crosspostService.getTwitterName(this.getUid()); + } + public String getTelegramName() { + return crosspostService.getTelegramName(this.getUid()); + } + public ApplicationStatus getFacebookStatus() { + return crosspostService.getFbCrossPostStatus(this.getUid()); + } + } +} diff --git a/src/main/java/com/juick/server/api/activity/Profile.java b/src/main/java/com/juick/server/api/activity/Profile.java new file mode 100644 index 00000000..10390ea1 --- /dev/null +++ b/src/main/java/com/juick/server/api/activity/Profile.java @@ -0,0 +1,379 @@ +package com.juick.server.api.activity; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.juick.Message; +import com.juick.User; +import com.juick.model.CommandResult; +import com.juick.server.ActivityPubManager; +import com.juick.server.CommandsManager; +import com.juick.server.KeystoreManager; +import com.juick.server.SignatureManager; +import com.juick.server.api.activity.model.Activity; +import com.juick.server.api.activity.model.Context; +import com.juick.server.api.activity.model.activities.Announce; +import com.juick.server.api.activity.model.activities.Create; +import com.juick.server.api.activity.model.activities.Delete; +import com.juick.server.api.activity.model.activities.Follow; +import com.juick.server.api.activity.model.activities.Undo; +import com.juick.server.api.activity.model.objects.Image; +import com.juick.server.api.activity.model.objects.Key; +import com.juick.server.api.activity.model.objects.Note; +import com.juick.server.api.activity.model.objects.OrderedCollection; +import com.juick.server.api.activity.model.objects.OrderedCollectionPage; +import com.juick.server.api.activity.model.objects.Person; +import com.juick.server.util.HttpBadRequestException; +import com.juick.server.util.HttpNotFoundException; +import com.juick.server.util.UserUtils; +import com.juick.service.MessagesService; +import com.juick.service.UserService; +import com.juick.service.activities.DeleteUserEvent; +import com.juick.service.activities.FollowEvent; +import com.juick.service.activities.UndoFollowEvent; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +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.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +import javax.inject.Inject; +import java.net.URI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@RestController +public class Profile { + private static final Logger logger = LoggerFactory.getLogger(Profile.class); + @Inject + private UserService userService; + @Inject + private MessagesService messagesService; + @Inject + private KeystoreManager keystoreManager; + @Inject + private SignatureManager signatureManager; + @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; + @Value("${img_url:http://localhost:8080/i/}") + private String baseImagesUri; + @Inject + private ObjectMapper jsonMapper; + + @GetMapping(value = "/u/{userName}", produces = {Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE}) + public Person getUser(@PathVariable String userName) { + User user = userService.getUserByName(userName); + if (!user.isAnonymous()) { + Person person = new Person(); + person.setId(activityPubManager.personUri(user)); + person.setUrl(activityPubManager.personWebUri(user)); + person.setName(userName); + person.setPreferredUsername(userName); + Key publicKey = new Key(); + publicKey.setId(person.getId() + "#main-key"); + publicKey.setOwner(person.getId()); + publicKey.setPublicKeyPem(keystoreManager.getPublicKeyPem()); + person.setPublicKey(publicKey); + person.setInbox(activityPubManager.inboxUri()); + person.setOutbox(activityPubManager.outboxUri(user)); + person.setFollowers(activityPubManager.followersUri(user)); + person.setFollowing(activityPubManager.followingUri(user)); + UriComponentsBuilder image = UriComponentsBuilder.fromUriString(baseImagesUri); + image.path(String.format("/a/%d.png", user.getUid())); + Image avatar = new Image(); + avatar.setUrl(image.toUriString()); + avatar.setMediaType("image/png"); + person.setIcon(avatar); + return (Person) Context.build(person); + } + 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(@PathVariable String userName, + @RequestParam(required = false, defaultValue = "0") int before) { + User visitor = UserUtils.getCurrentUser(); + User user = userService.getUserByName(userName); + if (!user.isAnonymous()) { + UriComponentsBuilder uri = UriComponentsBuilder.fromUriString(baseUri); + String personUri = uri.path(String.format("/u/%s", userName)).toUriString(); + List<Integer> mids = messagesService.getUserBlog(user.getUid(), 0, before); + List<Note> 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<User> followers = userService.getUserReaders(user.getUid()); + Stream<User> 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<User> following = userService.getUserFriends(user.getUid()); + Stream<User> 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) { + if (rid > 0) { + // reply + return Context.build(activityPubManager.makeNote( + messagesService.getReply(mid, rid))); + } + return Context.build(activityPubManager.makeNote( + messagesService.getMessage(mid))); + } + + @PostMapping(value = "/api/inbox", consumes = {Context.LD_JSON_MEDIA_TYPE, Context.ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE}) + public ResponseEntity<Void> processInbox(@RequestBody Activity activity, + @RequestHeader(name = "Host") String host, + @RequestHeader(name = "Date") String date, + @RequestHeader(name = "Digest", required = false) String digest, + @RequestHeader(name = "Content-Type") String contentType, + @RequestHeader(name = "User-Agent", required = false) String userAgent, + @RequestHeader(name = "Accept-Encoding", required = false) String acceptEncoding, + @RequestHeader(name = "Signature", required = false) String signature) throws Exception { + UriComponents componentsBuilder = ServletUriComponentsBuilder.fromCurrentRequestUri().build(); + Map<String, String> headers = new HashMap<>(); + headers.put("host", host.split(":", 2)[0]); + headers.put("date", date); + headers.put("digest", digest); + headers.put("content-type", contentType); + headers.put("user-agent", userAgent); + headers.put("accept-encoding", acceptEncoding); + boolean valid = signatureManager.verifySignature(signature, URI.create(activity.getActor()), "POST", + componentsBuilder.getPath(), headers); + if (valid) { + if (activity instanceof Follow) { + Follow followRequest = (Follow) activity; + String actor = followRequest.getActor(); + Person follower = (Person) signatureManager.getContext(URI.create(actor)).orElseThrow(HttpBadRequestException::new); + applicationEventPublisher.publishEvent( + new FollowEvent(this, followRequest)); + return new ResponseEntity<>(HttpStatus.ACCEPTED); + + } + if (activity instanceof Undo) { + String follower = (String) ((Map) activity.getObject()).get("object"); + applicationEventPublisher.publishEvent(new UndoFollowEvent(this, activity.getActor(), follower)); + return new ResponseEntity<>(HttpStatus.OK); + } + if (activity instanceof Delete) { + if (activity.getObject() instanceof String) { + // Delete user + applicationEventPublisher.publishEvent(new DeleteUserEvent(this, (String)activity.getObject())); + return new ResponseEntity<>(HttpStatus.OK); + } + } + if (activity instanceof Create) { + if (activity.getObject() instanceof Map) { + Map<String, Object> note = (Map<String, Object>) activity.getObject(); + if (note.get("type").equals("Note")) { + URI noteId = URI.create((String) note.get("id")); + if (messagesService.replyExists(noteId)) { + return new ResponseEntity<>(HttpStatus.OK); + } else { + String inReplyTo = (String) note.get("inReplyTo"); + if (StringUtils.isNotBlank(inReplyTo)) { + if (inReplyTo.startsWith(baseUri)) { + UriComponents uri = UriComponentsBuilder.fromUriString(inReplyTo).build(); + String postId = uri.getPath().substring(uri.getPath().lastIndexOf('/') + 1).replace("-", "/"); + User user = new User(); + user.setUri(URI.create(activity.getActor())); + String attachment = StringUtils.EMPTY; + if (note.get("attachment") != null && ((List) note.get("attachment")).size() > 0) { + Map<String, Object> attachmentObj = (Map<String, Object>) ((List<Object>) note.get("attachment")).get(0); + attachment = (String) attachmentObj.get("url"); + } + CommandResult result = commandsManager.processCommand(user, String.format("#%s %s", postId, note.get("content")), URI.create(attachment)); + logger.info(jsonMapper.writeValueAsString(result)); + if (result.getNewMessage().isPresent()) { + messagesService.updateReplyUri(result.getNewMessage().get(), noteId); + return new ResponseEntity<>(HttpStatus.OK); + } else { + return new ResponseEntity<>(HttpStatus.BAD_REQUEST); + } + } else { + Message reply = messagesService.getReplyByUri(inReplyTo); + if (reply != null) { + User user = new User(); + user.setUri(URI.create(activity.getActor())); + String attachment = StringUtils.EMPTY; + if (note.get("attachment") != null && ((List) note.get("attachment")).size() > 0) { + Map<String, Object> attachmentObj = (Map<String, Object>) ((List<Object>) note.get("attachment")).get(0); + attachment = (String) attachmentObj.get("url"); + } + CommandResult result = commandsManager.processCommand(user, String.format("#%d/%d %s", reply.getMid(), reply.getRid(), note.get("content")), URI.create(attachment)); + logger.info(jsonMapper.writeValueAsString(result)); + if (result.getNewMessage().isPresent()) { + messagesService.updateReplyUri(result.getNewMessage().get(), noteId); + return new ResponseEntity<>(HttpStatus.OK); + } else { + return new ResponseEntity<>(HttpStatus.BAD_REQUEST); + } + } + } + } + } + } + } + } + if (activity instanceof Delete) { + Map<String, Object> tombstone = (Map<String, Object>) activity.getObject(); + if (tombstone.get("type").equals("Tombstone")) { + URI actor = URI.create(activity.getActor()); + URI reply = URI.create((String)tombstone.get("id")); + messagesService.deleteReply(actor, reply); + return new ResponseEntity<>(HttpStatus.OK); + } + } + if (activity instanceof Announce) { + logger.info("Announce: {}", jsonMapper.writeValueAsString(activity)); + return new ResponseEntity<>(HttpStatus.OK); + } + logger.warn("Unknown activity: {}", jsonMapper.writeValueAsString(activity)); + return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); + } + return new ResponseEntity<>(HttpStatus.UNAUTHORIZED); + } + @PostMapping(value = "/u/", produces = MediaType.APPLICATION_JSON_UTF8_VALUE) + public User fetchUser(@RequestParam URI uri) { + Person person = (Person) signatureManager.getContext(uri).orElseThrow(HttpBadRequestException::new); + User user = new User(); + user.setUri(URI.create(person.getUrl())); + user.setName(person.getPreferredUsername()); + if (person.getIcon() != null) { + user.setAvatar(person.getIcon().getUrl()); + } + return user; + } +} diff --git a/src/main/java/com/juick/server/api/activity/model/Activity.java b/src/main/java/com/juick/server/api/activity/model/Activity.java new file mode 100644 index 00000000..ec126b88 --- /dev/null +++ b/src/main/java/com/juick/server/api/activity/model/Activity.java @@ -0,0 +1,23 @@ +package com.juick.server.api.activity.model; + +public abstract class Activity extends Context { + + private String actor; + private Object object; + + public String getActor() { + return actor; + } + + public void setActor(String actor) { + this.actor = actor; + } + + public Object getObject() { + return object; + } + + public void setObject(Object object) { + this.object = object; + } +} diff --git a/src/main/java/com/juick/server/api/activity/model/Context.java b/src/main/java/com/juick/server/api/activity/model/Context.java new file mode 100644 index 00000000..0df8f8c7 --- /dev/null +++ b/src/main/java/com/juick/server/api/activity/model/Context.java @@ -0,0 +1,123 @@ +package com.juick.server.api.activity.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.juick.server.api.activity.model.activities.*; +import com.juick.server.api.activity.model.objects.*; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property="type") +@JsonSubTypes({ + @JsonSubTypes.Type(value = Create.class, name = "Create"), + @JsonSubTypes.Type(value = Delete.class, name = "Delete"), + @JsonSubTypes.Type(value = Follow.class, name = "Follow"), + @JsonSubTypes.Type(value = Accept.class, name = "Accept"), + @JsonSubTypes.Type(value = Undo.class, name = "Undo"), + @JsonSubTypes.Type(value = Like.class, name = "Like"), + @JsonSubTypes.Type(value = Block.class, name = "Block"), + @JsonSubTypes.Type(value = Announce.class, name = "Announce"), + @JsonSubTypes.Type(value = Activity.class, name = "Activity"), + @JsonSubTypes.Type(value = Image.class, name = "Image"), + @JsonSubTypes.Type(value = Key.class, name = "Key"), + @JsonSubTypes.Type(value = Link.class, name = "Link"), + @JsonSubTypes.Type(value = Hashtag.class, name = "Hashtag"), + @JsonSubTypes.Type(value = Mention.class, name = "Mention"), + @JsonSubTypes.Type(value = Note.class, name = "Note"), + @JsonSubTypes.Type(value = OrderedCollection.class, name = "OrderedCollection"), + @JsonSubTypes.Type(value = OrderedCollectionPage.class, name = "OrderedCollectionPage"), + @JsonSubTypes.Type(value = Person.class, name = "Person") +}) +public abstract class Context { + + private List<Object> context; + + private String id; + + private String name; + + private Instant published; + + private String url; + + private List<String> to; + + private List<Context> tags; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getType() { + return getClass().getSimpleName(); + } + + @JsonProperty("@context") + public List<Object> getContext() { + return context; + } + + public final static String ACTIVITY_STREAMS_URI = "https://www.w3.org/ns/activitystreams"; + public final static String SECURITY_URI = "https://w3id.org/security/v1"; + public final static String LD_JSON_MEDIA_TYPE = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""; + public final static String ACTIVITY_MEDIA_TYPE = "application/activity+json"; + public final static String ACTIVITYSTREAMS_PROFILE_MEDIA_TYPE = ACTIVITY_MEDIA_TYPE + "; profile=\"https://www.w3.org/ns/activitystreams\""; + + public Instant getPublished() { + return published; + } + + public void setPublished(Instant published) { + this.published = published; + } + + public List<String> getTo() { + return to; + } + + public void setTo(List<String> to) { + this.to = to; + } + + public static Context build(Context response) { + response.context = new ArrayList(Arrays.asList(ACTIVITY_STREAMS_URI, SECURITY_URI)); + response.context.add(Collections.singletonMap("Hashtag", "as:Hashtag")); + return response; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + @JsonProperty("tag") + public List<Context> getTags() { + return tags; + } + + public void setTags(List<Context> tags) { + this.tags = tags; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/src/main/java/com/juick/server/api/activity/model/activities/Accept.java b/src/main/java/com/juick/server/api/activity/model/activities/Accept.java new file mode 100644 index 00000000..1e0a9968 --- /dev/null +++ b/src/main/java/com/juick/server/api/activity/model/activities/Accept.java @@ -0,0 +1,6 @@ +package com.juick.server.api.activity.model.activities; + +import com.juick.server.api.activity.model.Activity; + +public class Accept extends Activity { +} diff --git a/src/main/java/com/juick/server/api/activity/model/activities/Announce.java b/src/main/java/com/juick/server/api/activity/model/activities/Announce.java new file mode 100644 index 00000000..f2859404 --- /dev/null +++ b/src/main/java/com/juick/server/api/activity/model/activities/Announce.java @@ -0,0 +1,6 @@ +package com.juick.server.api.activity.model.activities; + +import com.juick.server.api.activity.model.Activity; + +public class Announce extends Activity { +} diff --git a/src/main/java/com/juick/server/api/activity/model/activities/Block.java b/src/main/java/com/juick/server/api/activity/model/activities/Block.java new file mode 100644 index 00000000..0e5a02d4 --- /dev/null +++ b/src/main/java/com/juick/server/api/activity/model/activities/Block.java @@ -0,0 +1,6 @@ +package com.juick.server.api.activity.model.activities; + +import com.juick.server.api.activity.model.Activity; + +public class Block extends Activity { +} diff --git a/src/main/java/com/juick/server/api/activity/model/activities/Create.java b/src/main/java/com/juick/server/api/activity/model/activities/Create.java new file mode 100644 index 00000000..52507373 --- /dev/null +++ b/src/main/java/com/juick/server/api/activity/model/activities/Create.java @@ -0,0 +1,6 @@ +package com.juick.server.api.activity.model.activities; + +import com.juick.server.api.activity.model.Activity; + +public class Create extends Activity { +} diff --git a/src/main/java/com/juick/server/api/activity/model/activities/Delete.java b/src/main/java/com/juick/server/api/activity/model/activities/Delete.java new file mode 100644 index 00000000..f4392020 --- /dev/null +++ b/src/main/java/com/juick/server/api/activity/model/activities/Delete.java @@ -0,0 +1,6 @@ +package com.juick.server.api.activity.model.activities; + +import com.juick.server.api.activity.model.Activity; + +public class Delete extends Activity { +} diff --git a/src/main/java/com/juick/server/api/activity/model/activities/Follow.java b/src/main/java/com/juick/server/api/activity/model/activities/Follow.java new file mode 100644 index 00000000..573ecc6e --- /dev/null +++ b/src/main/java/com/juick/server/api/activity/model/activities/Follow.java @@ -0,0 +1,6 @@ +package com.juick.server.api.activity.model.activities; + +import com.juick.server.api.activity.model.Activity; + +public class Follow extends Activity { +} diff --git a/src/main/java/com/juick/server/api/activity/model/activities/Like.java b/src/main/java/com/juick/server/api/activity/model/activities/Like.java new file mode 100644 index 00000000..3670293d --- /dev/null +++ b/src/main/java/com/juick/server/api/activity/model/activities/Like.java @@ -0,0 +1,6 @@ +package com.juick.server.api.activity.model.activities; + +import com.juick.server.api.activity.model.Activity; + +public class Like extends Activity { +} diff --git a/src/main/java/com/juick/server/api/activity/model/activities/Undo.java b/src/main/java/com/juick/server/api/activity/model/activities/Undo.java new file mode 100644 index 00000000..4e87e9d0 --- /dev/null +++ b/src/main/java/com/juick/server/api/activity/model/activities/Undo.java @@ -0,0 +1,6 @@ +package com.juick.server.api.activity.model.activities; + +import com.juick.server.api.activity.model.Activity; + +public class Undo extends Activity { +} diff --git a/src/main/java/com/juick/server/api/activity/model/objects/Hashtag.java b/src/main/java/com/juick/server/api/activity/model/objects/Hashtag.java new file mode 100644 index 00000000..34e73be6 --- /dev/null +++ b/src/main/java/com/juick/server/api/activity/model/objects/Hashtag.java @@ -0,0 +1,6 @@ +package com.juick.server.api.activity.model.objects; + +import com.juick.server.api.activity.model.Context; + +public class Hashtag extends Context { +} diff --git a/src/main/java/com/juick/server/api/activity/model/objects/Image.java b/src/main/java/com/juick/server/api/activity/model/objects/Image.java new file mode 100644 index 00000000..e067f729 --- /dev/null +++ b/src/main/java/com/juick/server/api/activity/model/objects/Image.java @@ -0,0 +1,15 @@ +package com.juick.server.api.activity.model.objects; + +import com.juick.server.api.activity.model.Context; + +public class Image extends Context { + private String mediaType; + + public String getMediaType() { + return mediaType; + } + + public void setMediaType(String mediaType) { + this.mediaType = mediaType; + } +} diff --git a/src/main/java/com/juick/server/api/activity/model/objects/Key.java b/src/main/java/com/juick/server/api/activity/model/objects/Key.java new file mode 100644 index 00000000..075c51dd --- /dev/null +++ b/src/main/java/com/juick/server/api/activity/model/objects/Key.java @@ -0,0 +1,24 @@ +package com.juick.server.api.activity.model.objects; + +import com.juick.server.api.activity.model.Context; + +public class Key extends Context { + private String owner; + private String publicKeyPem; + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public String getPublicKeyPem() { + return publicKeyPem; + } + + public void setPublicKeyPem(String publicKeyPem) { + this.publicKeyPem = publicKeyPem; + } +} diff --git a/src/main/java/com/juick/server/api/activity/model/objects/Link.java b/src/main/java/com/juick/server/api/activity/model/objects/Link.java new file mode 100644 index 00000000..0c4f26dc --- /dev/null +++ b/src/main/java/com/juick/server/api/activity/model/objects/Link.java @@ -0,0 +1,15 @@ +package com.juick.server.api.activity.model.objects; + +import com.juick.server.api.activity.model.Context; + +public class Link extends Context { + private String href; + + public String getHref() { + return href; + } + + public void setHref(String href) { + this.href = href; + } +} diff --git a/src/main/java/com/juick/server/api/activity/model/objects/Mention.java b/src/main/java/com/juick/server/api/activity/model/objects/Mention.java new file mode 100644 index 00000000..bcb52d37 --- /dev/null +++ b/src/main/java/com/juick/server/api/activity/model/objects/Mention.java @@ -0,0 +1,12 @@ +package com.juick.server.api.activity.model.objects; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class Mention extends Link { + @JsonCreator + public Mention(@JsonProperty("href") String href, @JsonProperty("name") String name) { + this.setHref(href); + this.setName(name); + } +} diff --git a/src/main/java/com/juick/server/api/activity/model/objects/Note.java b/src/main/java/com/juick/server/api/activity/model/objects/Note.java new file mode 100644 index 00000000..baad2d3b --- /dev/null +++ b/src/main/java/com/juick/server/api/activity/model/objects/Note.java @@ -0,0 +1,64 @@ +package com.juick.server.api.activity.model.objects; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.juick.server.api.activity.model.Context; + +import java.util.List; + +public class Note extends Context { + private String content; + private String attributedTo; + private String inReplyTo; + private List<Image> attachment; + private List<String> to; + private List<String> cc; + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public String getAttributedTo() { + return attributedTo; + } + + public void setAttributedTo(String attributedTo) { + this.attributedTo = attributedTo; + } + + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + public List<Image> getAttachment() { + return attachment; + } + + public void setAttachment(List<Image> attachment) { + this.attachment = attachment; + } + + public List<String> getTo() { + return to; + } + + public void setTo(List<String> to) { + this.to = to; + } + + public List<String> getCc() { + return cc; + } + + public void setCc(List<String> cc) { + this.cc = cc; + } + + public String getInReplyTo() { + return inReplyTo; + } + + public void setInReplyTo(String inReplyTo) { + this.inReplyTo = inReplyTo; + } +} diff --git a/src/main/java/com/juick/server/api/activity/model/objects/OrderedCollection.java b/src/main/java/com/juick/server/api/activity/model/objects/OrderedCollection.java new file mode 100644 index 00000000..426cf331 --- /dev/null +++ b/src/main/java/com/juick/server/api/activity/model/objects/OrderedCollection.java @@ -0,0 +1,25 @@ +package com.juick.server.api.activity.model.objects; + +import com.juick.server.api.activity.model.Context; + +public class OrderedCollection extends Context { + + private int totalItems; + + public int getTotalItems() { + return totalItems; + } + + public void setTotalItems(int totalItems) { + this.totalItems = totalItems; + } + private String first; + + public String getFirst() { + return first; + } + + public void setFirst(String first) { + this.first = first; + } +} diff --git a/src/main/java/com/juick/server/api/activity/model/objects/OrderedCollectionPage.java b/src/main/java/com/juick/server/api/activity/model/objects/OrderedCollectionPage.java new file mode 100644 index 00000000..601919ba --- /dev/null +++ b/src/main/java/com/juick/server/api/activity/model/objects/OrderedCollectionPage.java @@ -0,0 +1,58 @@ +package com.juick.server.api.activity.model.objects; + +import com.juick.server.api.activity.model.Context; + +import java.util.List; + +public class OrderedCollectionPage extends Context { + + private String partOf; + + private String first; + + private String next; + + private String last; + + private List<? extends Context> orderedItems; + + public String getNext() { + return next; + } + + public void setNext(String next) { + this.next = next; + } + + public List<? extends Context> getOrderedItems() { + return orderedItems; + } + + public void setOrderedItems(List<? extends Context> orderedItems) { + this.orderedItems = orderedItems; + } + + public String getPartOf() { + return partOf; + } + + public void setPartOf(String partOf) { + this.partOf = partOf; + } + + public String getFirst() { + return first; + } + + public void setFirst(String first) { + this.first = first; + } + + public String getLast() { + return last; + } + + public void setLast(String last) { + this.last = last; + } +} diff --git a/src/main/java/com/juick/server/api/activity/model/objects/Person.java b/src/main/java/com/juick/server/api/activity/model/objects/Person.java new file mode 100644 index 00000000..2d3a45d7 --- /dev/null +++ b/src/main/java/com/juick/server/api/activity/model/objects/Person.java @@ -0,0 +1,87 @@ +package com.juick.server.api.activity.model.objects; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.juick.server.api.activity.model.Context; + +public class Person extends Context { + + private String name; + private String preferredUsername; + private Image icon; + private String inbox; + private String outbox; + private String following; + private String followers; + private Key publicKey; + + @Override + public String getType() { + return "Person"; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @JsonTypeInfo(use = JsonTypeInfo.Id.NONE) + public Image getIcon() { + return icon; + } + + public void setIcon(Image icon) { + this.icon = icon; + } + + public String getOutbox() { + return outbox; + } + + public void setOutbox(String outbox) { + this.outbox = outbox; + } + + public String getInbox() { + return inbox; + } + + public void setInbox(String inbox) { + this.inbox = inbox; + } + + public String getFollowing() { + return following; + } + + public void setFollowing(String following) { + this.following = following; + } + + public String getFollowers() { + return followers; + } + + public void setFollowers(String followers) { + this.followers = followers; + } + + @JsonTypeInfo(use = JsonTypeInfo.Id.NONE) + public Key getPublicKey() { + return publicKey; + } + + public void setPublicKey(Key publicKey) { + this.publicKey = publicKey; + } + + public String getPreferredUsername() { + return preferredUsername; + } + + public void setPreferredUsername(String preferredUsername) { + this.preferredUsername = preferredUsername; + } +} diff --git a/src/main/java/com/juick/server/api/apple/AppSiteAssociation.java b/src/main/java/com/juick/server/api/apple/AppSiteAssociation.java new file mode 100644 index 00000000..81ab6960 --- /dev/null +++ b/src/main/java/com/juick/server/api/apple/AppSiteAssociation.java @@ -0,0 +1,49 @@ +package com.juick.server.api.apple; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; + +@RestController +public class AppSiteAssociation { + @Value("${ios_app_id:}") + private String appId; + + @GetMapping("/.well-known/apple-app-site-association") + @ResponseBody + public SiteAssociations appSiteAssociations() { + WebCredentials webCredentials = new WebCredentials(); + webCredentials.setApps(Collections.singletonList(appId)); + SiteAssociations siteAssociations = new SiteAssociations(); + siteAssociations.setWebcredentials(webCredentials); + return siteAssociations; + } + + private class SiteAssociations { + private WebCredentials webcredentials; + + public WebCredentials getWebcredentials() { + return webcredentials; + } + + public void setWebcredentials(WebCredentials webcredentials) { + this.webcredentials = webcredentials; + } + } + + private class WebCredentials { + private List<String> apps; + + public List<String> getApps() { + return apps; + } + + public void setApps(List<String> apps) { + this.apps = apps; + } + } +} diff --git a/src/main/java/com/juick/server/api/hostmeta/HostMeta.java b/src/main/java/com/juick/server/api/hostmeta/HostMeta.java new file mode 100644 index 00000000..fa4d2a3f --- /dev/null +++ b/src/main/java/com/juick/server/api/hostmeta/HostMeta.java @@ -0,0 +1,25 @@ +package com.juick.server.api.hostmeta; + +import com.cliqset.xrd.Link; +import com.cliqset.xrd.XRD; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; + +import static com.cliqset.xrd.XRDConstants.XRD_MEDIA_TYPE; + +@RestController +public class HostMeta { + @Value("${ap_base_uri:http://localhost:8080/}") + private String baseUri; + @GetMapping(value = "/.well-known/host-meta", produces = XRD_MEDIA_TYPE) + public XRD hostMetaResponse() { + Link webfinger = new Link(); + webfinger.setTemplate(String.format("%swebfinger?resource={uri}", baseUri)); + XRD xrd = new XRD(); + xrd.setLinks(Collections.singletonList(webfinger)); + return xrd; + } +} diff --git a/src/main/java/com/juick/server/api/rss/Feeds.java b/src/main/java/com/juick/server/api/rss/Feeds.java new file mode 100644 index 00000000..c72f3a5e --- /dev/null +++ b/src/main/java/com/juick/server/api/rss/Feeds.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.api.rss; + +import com.juick.User; +import com.juick.server.util.HttpBadRequestException; +import com.juick.server.util.UserUtils; +import com.juick.service.MessagesService; +import com.juick.service.UserService; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.ModelAndView; + +import javax.inject.Inject; +import java.util.List; + +/** + * Created by vitalyster on 13.12.2016. + */ +@Controller +public class Feeds { + + @Inject + private MessagesService messagesService; + @Inject + private UserService userService; + + @RequestMapping(value = "/rss/{userName}/blog", method = RequestMethod.GET, produces = "text/xml; charset=utf-8") + public ModelAndView getBlog(@PathVariable String userName) { + User user = userService.getUserByName(userName); + if (!user.isAnonymous()) { + List<Integer> mids = messagesService.getUserBlog(user.getUid(), 0, 0); + ModelAndView modelAndView = new ModelAndView(); + modelAndView.setViewName("messagesView"); + modelAndView.addObject("user", user); + modelAndView.addObject("messages", messagesService.getMessages(UserUtils.getCurrentUser(), mids)); + return modelAndView; + } + throw new HttpBadRequestException(); + } + + @RequestMapping(value = "/rss/", method = RequestMethod.GET, produces = "text/xml; charset=utf-8") + public ModelAndView getLast(@RequestParam(value = "hours", required = false, defaultValue = "0") Integer hours) { + List<Integer> mids = messagesService.getLastMessages(hours); + ModelAndView modelAndView = new ModelAndView(); + modelAndView.setViewName("messagesView"); + modelAndView.addObject("messages", messagesService.getMessages(UserUtils.getCurrentUser(),mids)); + return modelAndView; + } + @RequestMapping(value = "/rss/comments", method = RequestMethod.GET, produces = "text/xml; charset=utf-8") + public ModelAndView getLastReplies(@RequestParam(value = "hours", required = false, defaultValue = "0") Integer hours) { + ModelAndView modelAndView = new ModelAndView(); + modelAndView.setViewName("repliesView"); + modelAndView.addObject("messages", messagesService.getLastReplies(hours)); + return modelAndView; + } +} diff --git a/src/main/java/com/juick/server/api/rss/MessagesView.java b/src/main/java/com/juick/server/api/rss/MessagesView.java new file mode 100644 index 00000000..c0ae4a97 --- /dev/null +++ b/src/main/java/com/juick/server/api/rss/MessagesView.java @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.api.rss; + +import com.juick.Message; +import com.juick.User; +import com.juick.server.api.rss.extension.JuickModule; +import com.juick.server.api.rss.extension.JuickModuleImpl; +import com.juick.util.MessageUtils; +import com.rometools.modules.atom.modules.AtomLinkModule; +import com.rometools.modules.atom.modules.AtomLinkModuleImpl; +import com.rometools.modules.mediarss.MediaEntryModuleImpl; +import com.rometools.modules.mediarss.MediaModule; +import com.rometools.modules.mediarss.MediaModuleImpl; +import com.rometools.modules.mediarss.types.MediaContent; +import com.rometools.modules.mediarss.types.Metadata; +import com.rometools.modules.mediarss.types.Thumbnail; +import com.rometools.modules.mediarss.types.UrlReference; +import com.rometools.rome.feed.atom.Link; +import com.rometools.rome.feed.rss.*; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.servlet.view.feed.AbstractRssFeedView; + +import javax.annotation.Nonnull; +import javax.annotation.PostConstruct; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Created by vitalyster on 13.12.2016. + */ +public class MessagesView extends AbstractRssFeedView { + + private static final Logger logger = LoggerFactory.getLogger(MessagesView.class); + + @PostConstruct + public void init() { + setContentType("application/rss+xml;charset=UTF-8"); + } + + @SuppressWarnings("unchecked") + @Override + protected List<Item> buildFeedItems(@Nonnull Map<String, Object> model, + @Nonnull HttpServletRequest request, + @Nonnull HttpServletResponse response) { + List<Message> msgs = (List<Message>)model.get("messages"); + return msgs.stream().map(this::createRssItem).collect(Collectors.toList()); + } + + @Override + protected void buildFeedMetadata(Map<String, Object> model, Channel feed, HttpServletRequest request) { + Object userObj = model.get("user"); + if (userObj != null) { + User user = (User) userObj; + feed.setDescription(String.format("The latest messages by @%s at Juick", user.getName())); + String title = String.format("%s - Juick", user.getName()); + feed.setTitle(title); + String link = String.format("http://juick.com/%s/", user.getName()); + feed.setLink(link); + Image rssImage = new Image(); + rssImage.setUrl(String.format("http://juick.com/a/%d.png", user.getUid())); + rssImage.setTitle(title); + rssImage.setLink(link); + feed.setImage(rssImage); + String href = String.format("http://rss.juick.com/%s/blog", user.getName()); + AtomLinkModule atomLinkModule = new AtomLinkModuleImpl(); + Link atomLink = new Link(); + atomLink.setHref(href); + atomLink.setType("application/rss+xml"); + atomLink.setRel("self"); + atomLinkModule.setLinks(Collections.singletonList(atomLink)); + + feed.getModules().add(atomLinkModule); + } else { + feed.setDescription("The latest messages at Juick"); + feed.setLink("http://juick.com/"); + feed.setTitle("Juick"); + } + + MediaModule mediaModule = new MediaModuleImpl(); + feed.getModules().add(mediaModule); + + + } + + private Item createRssItem(Message msg) { + Item item = new Item(); + String messageUrl = String.format("http://juick.com/%s/%d", msg.getUser().getName(), msg.getMid()); + String messageTitle = String.format("@%s: %s", msg.getUser().getName(), MessageUtils.getTagsString(msg)); + boolean isCode = msg.getTags().stream().anyMatch(t -> t.getName().equals("code")); + String messageDescription = isCode ? MessageUtils.formatMessageCode(StringUtils.defaultString(msg.getText())) + : MessageUtils.formatMessage(StringUtils.defaultString(msg.getText())); + item.setLink(messageUrl); + //item.setGuid(messageUrl); + item.setTitle(messageTitle); + Description description = new Description(); + description.setType("text/html"); + description.setValue(messageDescription); + item.setDescription(description); + item.setPubDate(Date.from(msg.getTimestamp())); + item.setComments(messageUrl); + msg.getTags().stream().map(t -> { + Category category = new Category(); + category.setValue(t.getName()); + return category; + }).forEach(c -> item.getCategories().add(c)); + JuickModule juickModule = new JuickModuleImpl(); + juickModule.setUid(msg.getUser().getUid()); + item.getModules().add(juickModule); + if (StringUtils.isNotEmpty(msg.getAttachmentType())) { + String type = msg.getAttachmentType().equals("jpg") ? "image/jpeg" : "image/png"; + MediaEntryModuleImpl module = new MediaEntryModuleImpl(); + try { + UrlReference reference = new UrlReference(MessageUtils.attachmentUrl(msg)); + MediaContent mediaContent = new MediaContent(reference); + mediaContent.setType(type); + Metadata metadata = new Metadata(); + metadata.setThumbnail(new Thumbnail[]{new Thumbnail(new URI(msg.getPhoto().getThumbnail()))}); + module.setMetadata(metadata); + module.setMediaContents(new MediaContent[]{mediaContent}); + item.getModules().add(module); + } catch (URISyntaxException e) { + logger.error("Invalid URI", e); + } + + } + return item; + } +} diff --git a/src/main/java/com/juick/server/api/rss/RepliesView.java b/src/main/java/com/juick/server/api/rss/RepliesView.java new file mode 100644 index 00000000..a0ab801e --- /dev/null +++ b/src/main/java/com/juick/server/api/rss/RepliesView.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.api.rss; + +import com.juick.model.ResponseReply; +import com.juick.util.MessageUtils; +import com.rometools.modules.mediarss.MediaEntryModuleImpl; +import com.rometools.modules.mediarss.MediaModule; +import com.rometools.modules.mediarss.MediaModuleImpl; +import com.rometools.modules.mediarss.types.MediaContent; +import com.rometools.modules.mediarss.types.Metadata; +import com.rometools.modules.mediarss.types.Thumbnail; +import com.rometools.modules.mediarss.types.UrlReference; +import com.rometools.rome.feed.rss.Channel; +import com.rometools.rome.feed.rss.Description; +import com.rometools.rome.feed.rss.Item; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.servlet.view.feed.AbstractRssFeedView; + +import javax.annotation.Nonnull; +import javax.annotation.PostConstruct; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Created by vitalyster on 13.12.2016. + */ +public class RepliesView extends AbstractRssFeedView { + + private static final Logger logger = LoggerFactory.getLogger(RepliesView.class); + + @PostConstruct + public void init() { + setContentType("application/rss+xml;charset=UTF-8"); + } + + @SuppressWarnings("unchecked") + @Override + protected @Nonnull List<Item> buildFeedItems(@Nonnull Map<String, Object> model, + @Nonnull HttpServletRequest request, + @Nonnull HttpServletResponse response) { + List<ResponseReply> msgs = (List<ResponseReply>)model.get("messages"); + return msgs.stream().map(this::createRssItem).collect(Collectors.toList()); + } + + @Override + protected void buildFeedMetadata(Map<String, Object> model, Channel feed, HttpServletRequest request) { + feed.setTitle("Juick"); + feed.setLink("http://juick.com/"); + feed.setDescription("The latest comments at Juick"); + MediaModule mediaModule = new MediaModuleImpl(); + feed.getModules().add(mediaModule); + } + + private Item createRssItem(ResponseReply msg) { + Item item = new Item(); + String messageUrl = String.format("http://juick.com/m/%d#%d", msg.getMid(), msg.getRid()); + String messageTitle = String.format("@%s:", msg.getUname()); + String messageDescription = msg.isHtml() ? msg.getDescription() : MessageUtils.formatMessage(msg.getDescription()); + item.setLink(messageUrl); + //item.setGuid(messageUrl); + item.setTitle(messageTitle); + Description description = new Description(); + description.setType("text/html"); + description.setValue(messageDescription); + item.setDescription(description); + item.setPubDate(msg.getPubDate()); + if (StringUtils.isNotEmpty(msg.getAttachmentType())) { + String type = msg.getAttachmentType().equals("jpg") ? "image/jpeg" : "image/png"; + MediaEntryModuleImpl module = new MediaEntryModuleImpl(); + try { + UrlReference reference = new UrlReference( + String.format("http://i.juick.com/photos-1024/%d-%d.%s", msg.getMid(), msg.getRid(), type)); + MediaContent mediaContent = new MediaContent(reference); + mediaContent.setType(type); + Metadata metadata = new Metadata(); + metadata.setThumbnail(new Thumbnail[]{new Thumbnail( + new URI(String.format("http://i.juick.com/ps/%d-%d.%s", msg.getMid(), msg.getRid(), type)))}); + module.setMetadata(metadata); + module.setMediaContents(new MediaContent[]{mediaContent}); + item.getModules().add(module); + } catch (URISyntaxException e) { + logger.error("Invalid URI", e); + } + + } + return item; + } +} diff --git a/src/main/java/com/juick/server/api/rss/extension/JuickModule.java b/src/main/java/com/juick/server/api/rss/extension/JuickModule.java new file mode 100644 index 00000000..a4198518 --- /dev/null +++ b/src/main/java/com/juick/server/api/rss/extension/JuickModule.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.api.rss.extension; + +import com.rometools.rome.feed.module.Module; + +/** + * Created by vitalyster on 13.12.2016. + */ +public interface JuickModule extends Module { + + String URI = "http://juick.com/"; + + Integer getUid(); + + void setUid(Integer uid); + +} diff --git a/src/main/java/com/juick/server/api/rss/extension/JuickModuleGenerator.java b/src/main/java/com/juick/server/api/rss/extension/JuickModuleGenerator.java new file mode 100644 index 00000000..90dec35f --- /dev/null +++ b/src/main/java/com/juick/server/api/rss/extension/JuickModuleGenerator.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.api.rss.extension; + +import com.rometools.rome.feed.module.Module; +import com.rometools.rome.io.ModuleGenerator; +import org.jdom2.Element; +import org.jdom2.Namespace; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Created by vt on 13/12/2016. + */ +public class JuickModuleGenerator implements ModuleGenerator { + + private static final Namespace JUICK_NS = Namespace.getNamespace("juick", JuickModule.URI); + + @Override + public String getNamespaceUri() { + return JuickModule.URI; + } + + private static final Set<Namespace> NAMESPACES; + + static { + Set<Namespace> nss = new HashSet<>(); + nss.add(JUICK_NS); + NAMESPACES = Collections.unmodifiableSet(nss); + } + + @Override + public Set<Namespace> getNamespaces() { + return NAMESPACES; + } + + @Override + public void generate(Module module, Element element) { + // this is not necessary, it is done to avoid the namespace definition in every item. + Element root = element; + while (root.getParent()!=null && root.getParent() instanceof Element) { + root = element.getParentElement(); + } + root.addNamespaceDeclaration(JUICK_NS); + + JuickModule juickModule = (JuickModule) module; + if (juickModule.getUid() > 0) { + Element user = new Element("user", JUICK_NS); + user.setAttribute("uid", String.valueOf(juickModule.getUid()), JUICK_NS); + element.addContent(user); + } + } +} diff --git a/src/main/java/com/juick/server/api/rss/extension/JuickModuleImpl.java b/src/main/java/com/juick/server/api/rss/extension/JuickModuleImpl.java new file mode 100644 index 00000000..dbdd8c85 --- /dev/null +++ b/src/main/java/com/juick/server/api/rss/extension/JuickModuleImpl.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.api.rss.extension; + +import com.rometools.rome.feed.CopyFrom; +import com.rometools.rome.feed.module.ModuleImpl; + +/** + * Created by vitalyster on 13.12.2016. + */ +public class JuickModuleImpl extends ModuleImpl implements JuickModule { + + private Integer uid; + + public JuickModuleImpl() { + super(JuickModule.class, JuickModule.URI); + } + + @Override + public Integer getUid() { + return uid; + } + + @Override + public void setUid(Integer uid) { + this.uid = uid; + } + + @Override + public Class<? extends CopyFrom> getInterface() { + return JuickModule.class; + } + + @Override + public void copyFrom(CopyFrom obj) { + JuickModule juickModule = (JuickModule) obj; + setUid(juickModule.getUid()); + } +} diff --git a/src/main/java/com/juick/server/api/rss/extension/JuickModuleParser.java b/src/main/java/com/juick/server/api/rss/extension/JuickModuleParser.java new file mode 100644 index 00000000..a3d0e175 --- /dev/null +++ b/src/main/java/com/juick/server/api/rss/extension/JuickModuleParser.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.api.rss.extension; + +import com.rometools.rome.feed.module.Module; +import com.rometools.rome.io.ModuleParser; +import org.apache.commons.lang3.math.NumberUtils; +import org.jdom2.Element; + +import java.util.Locale; + +/** + * Created by vitalyster on 13.12.2016. + */ +public class JuickModuleParser implements ModuleParser { + @Override + public String getNamespaceUri() { + return JuickModule.URI; + } + + @Override + public Module parse(Element element, Locale locale) { + JuickModuleImpl juickModule = new JuickModuleImpl(); + juickModule.setUid(NumberUtils.toInt(element.getAttributeValue("uid", JuickModule.URI), 0)); + return juickModule; + } +} diff --git a/src/main/java/com/juick/server/api/webfinger/Resource.java b/src/main/java/com/juick/server/api/webfinger/Resource.java new file mode 100644 index 00000000..71a0ca31 --- /dev/null +++ b/src/main/java/com/juick/server/api/webfinger/Resource.java @@ -0,0 +1,51 @@ +package com.juick.server.api.webfinger; + +import com.juick.User; +import com.juick.server.api.webfinger.model.Account; +import com.juick.server.api.webfinger.model.Link; +import com.juick.server.util.HttpNotFoundException; +import com.juick.service.UserService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.UriComponentsBuilder; +import rocks.xmpp.addr.Jid; + +import javax.inject.Inject; +import java.util.Collections; + +import static com.juick.server.api.activity.model.Context.ACTIVITY_MEDIA_TYPE; + +@RestController +public class Resource { + @Inject + private UserService userService; + @Value("${web_domain:localhost}") + private String domain; + @Value("${ap_base_uri:http://localhost:8080/}") + private String baseUri; + + @GetMapping("/.well-known/webfinger") + public Account getWebResource(@RequestParam String resource) { + if (resource.startsWith("acct:")) { + Jid account = Jid.of(resource.substring(5)); + if (account.getDomain().equals(domain)) { + User user = userService.getUserByName(account.getLocal()); + if (!user.isAnonymous()) { + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(baseUri); + builder.path(String.format("/u/%s", user.getName())); + Link blog = new Link(); + blog.setRel("self"); + blog.setType(ACTIVITY_MEDIA_TYPE); + blog.setHref(builder.toUriString()); + Account result = new Account(); + result.setSubject(resource); + result.setLinks(Collections.singletonList(blog)); + return result; + } + } + } + throw new HttpNotFoundException(); + } +} diff --git a/src/main/java/com/juick/server/api/webfinger/model/Account.java b/src/main/java/com/juick/server/api/webfinger/model/Account.java new file mode 100644 index 00000000..892fa303 --- /dev/null +++ b/src/main/java/com/juick/server/api/webfinger/model/Account.java @@ -0,0 +1,24 @@ +package com.juick.server.api.webfinger.model; + +import java.util.List; + +public class Account { + private String subject; + private List<Link> links; + + public String getSubject() { + return subject; + } + + public void setSubject(String subject) { + this.subject = subject; + } + + public List<Link> getLinks() { + return links; + } + + public void setLinks(List<Link> links) { + this.links = links; + } +} diff --git a/src/main/java/com/juick/server/api/webfinger/model/Link.java b/src/main/java/com/juick/server/api/webfinger/model/Link.java new file mode 100644 index 00000000..48e7ab67 --- /dev/null +++ b/src/main/java/com/juick/server/api/webfinger/model/Link.java @@ -0,0 +1,31 @@ +package com.juick.server.api.webfinger.model; + +public class Link { + private String rel; + private String type; + private String href; + + public String getRel() { + return rel; + } + + public void setRel(String rel) { + this.rel = rel; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getHref() { + return href; + } + + public void setHref(String href) { + this.href = href; + } +} diff --git a/src/main/java/com/juick/server/api/webhooks/TelegramWebhook.java b/src/main/java/com/juick/server/api/webhooks/TelegramWebhook.java new file mode 100644 index 00000000..7a5cebda --- /dev/null +++ b/src/main/java/com/juick/server/api/webhooks/TelegramWebhook.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.api.webhooks; + +import com.juick.server.TelegramBotManager; +import com.pengrad.telegrambot.BotUtils; +import com.pengrad.telegrambot.model.Update; +import org.apache.commons.io.IOUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import springfox.documentation.annotations.ApiIgnore; + +import javax.inject.Inject; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +/** + * Created by vt on 24/11/2016. + */ +@ApiIgnore +@RestController +@ConditionalOnProperty({"telegram_token"}) +public class TelegramWebhook { + private static final Logger logger = LoggerFactory.getLogger(TelegramWebhook.class); + @Inject + private TelegramBotManager telegramBotManager; + + @RequestMapping(value = "/api/tlgmbtwbhk", method = RequestMethod.POST) + @ResponseStatus(value = HttpStatus.OK) + public void processUpdate(InputStream body) throws Exception { + String data = IOUtils.toString(body, StandardCharsets.UTF_8); + logger.info("Telegram update: {}", data); + Update update = BotUtils.parseUpdate(data); + telegramBotManager.processUpdate(update); + } +} diff --git a/src/main/java/com/juick/server/api/xnodeinfo2/Info.java b/src/main/java/com/juick/server/api/xnodeinfo2/Info.java new file mode 100644 index 00000000..c12df55f --- /dev/null +++ b/src/main/java/com/juick/server/api/xnodeinfo2/Info.java @@ -0,0 +1,51 @@ +package com.juick.server.api.xnodeinfo2; + +import com.juick.server.api.xnodeinfo2.model.*; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.inject.Inject; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; + +@RestController +public class Info { + @Value("${ap_base_uri:http://localhost:8080/}") + private String baseUri; + @Inject + private JdbcTemplate jdbcTemplate; + + @GetMapping("/.well-known/x-nodeinfo2") + public NodeInfo showNodeInfo() { + NodeInfo nodeInfo = new NodeInfo(); + Server server = new Server(); + server.setBaseUrl(baseUri); + server.setName("Juick"); + server.setSoftware("Juick"); + server.setVersion("2.x"); + nodeInfo.setServer(server); + nodeInfo.setProtocols(Arrays.asList("xmpp", "activitypub", "smtp")); + ServiceInfo serviceInfo = new ServiceInfo(); + serviceInfo.setInbound(Arrays.asList("jabber", "mastodon", "email", "telegram")); + serviceInfo.setOutbound(Arrays.asList("jabber", "mastodon", "telegram", "twitter", "email", "rss")); + nodeInfo.setServices(serviceInfo); + UserStats userStats = new UserStats(); + userStats.setTotal(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users WHERE banned=0", Integer.class)); + userStats.setActiveMonth(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users WHERE banned=0 AND last_seen > ?", + Integer.class, ZonedDateTime.now().minus(1, ChronoUnit.MONTHS).toInstant())); + userStats.setActiveHalfyear(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users WHERE banned=0 AND last_seen > ?", + Integer.class, ZonedDateTime.now().minus(6, ChronoUnit.MONTHS).toInstant())); + Usage usage = new Usage(); + usage.setUsers(userStats); + usage.setLocalPosts(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM messages", + Integer.class)); + usage.setLocalComments(jdbcTemplate.queryForObject("SELECT COUNT(*) FROM replies", + Integer.class)); + nodeInfo.setUsage(usage); + return nodeInfo; + } +} diff --git a/src/main/java/com/juick/server/api/xnodeinfo2/model/NodeInfo.java b/src/main/java/com/juick/server/api/xnodeinfo2/model/NodeInfo.java new file mode 100644 index 00000000..06fe354f --- /dev/null +++ b/src/main/java/com/juick/server/api/xnodeinfo2/model/NodeInfo.java @@ -0,0 +1,54 @@ +package com.juick.server.api.xnodeinfo2.model; + +import java.util.List; + +public class NodeInfo { + + private Server server; + + private List<String> protocols; + + private ServiceInfo services; + + public String getVersion() { + return "1.0"; + } + + public Server getServer() { + return server; + } + + public void setServer(Server server) { + this.server = server; + } + + public List<String> getProtocols() { + return protocols; + } + + public void setProtocols(List<String> protocols) { + this.protocols = protocols; + } + + public ServiceInfo getServices() { + return services; + } + + public void setServices(ServiceInfo services) { + this.services = services; + } + + public boolean getOpenRegistrations() { + return true; + } + + private Usage usage; + + public Usage getUsage() { + return usage; + } + + public void setUsage(Usage usage) { + this.usage = usage; + } +} diff --git a/src/main/java/com/juick/server/api/xnodeinfo2/model/Server.java b/src/main/java/com/juick/server/api/xnodeinfo2/model/Server.java new file mode 100644 index 00000000..a772d268 --- /dev/null +++ b/src/main/java/com/juick/server/api/xnodeinfo2/model/Server.java @@ -0,0 +1,40 @@ +package com.juick.server.api.xnodeinfo2.model; + +public class Server { + private String baseUrl; + private String name; + private String software; + private String version; + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getSoftware() { + return software; + } + + public void setSoftware(String software) { + this.software = software; + } + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } +} diff --git a/src/main/java/com/juick/server/api/xnodeinfo2/model/ServiceInfo.java b/src/main/java/com/juick/server/api/xnodeinfo2/model/ServiceInfo.java new file mode 100644 index 00000000..5b6d2baa --- /dev/null +++ b/src/main/java/com/juick/server/api/xnodeinfo2/model/ServiceInfo.java @@ -0,0 +1,24 @@ +package com.juick.server.api.xnodeinfo2.model; + +import java.util.List; + +public class ServiceInfo { + private List<String> inbound; + private List<String> outbound; + + public List<String> getInbound() { + return inbound; + } + + public void setInbound(List<String> inbound) { + this.inbound = inbound; + } + + public List<String> getOutbound() { + return outbound; + } + + public void setOutbound(List<String> outbound) { + this.outbound = outbound; + } +} diff --git a/src/main/java/com/juick/server/api/xnodeinfo2/model/Usage.java b/src/main/java/com/juick/server/api/xnodeinfo2/model/Usage.java new file mode 100644 index 00000000..e04ea48b --- /dev/null +++ b/src/main/java/com/juick/server/api/xnodeinfo2/model/Usage.java @@ -0,0 +1,31 @@ +package com.juick.server.api.xnodeinfo2.model; + +public class Usage { + private UserStats users; + private int localPosts; + private int localComments; + + public UserStats getUsers() { + return users; + } + + public void setUsers(UserStats users) { + this.users = users; + } + + public int getLocalPosts() { + return localPosts; + } + + public void setLocalPosts(int localPosts) { + this.localPosts = localPosts; + } + + public int getLocalComments() { + return localComments; + } + + public void setLocalComments(int localComments) { + this.localComments = localComments; + } +} diff --git a/src/main/java/com/juick/server/api/xnodeinfo2/model/UserStats.java b/src/main/java/com/juick/server/api/xnodeinfo2/model/UserStats.java new file mode 100644 index 00000000..515661e3 --- /dev/null +++ b/src/main/java/com/juick/server/api/xnodeinfo2/model/UserStats.java @@ -0,0 +1,31 @@ +package com.juick.server.api.xnodeinfo2.model; + +public class UserStats { + private int total; + private int activeHalfyear; + private int activeMonth; + + public int getTotal() { + return total; + } + + public void setTotal(int total) { + this.total = total; + } + + public int getActiveHalfyear() { + return activeHalfyear; + } + + public void setActiveHalfyear(int activeHalfyear) { + this.activeHalfyear = activeHalfyear; + } + + public int getActiveMonth() { + return activeMonth; + } + + public void setActiveMonth(int activeMonth) { + this.activeMonth = activeMonth; + } +} diff --git a/src/main/java/com/juick/server/configuration/ActivityPubClientConfig.java b/src/main/java/com/juick/server/configuration/ActivityPubClientConfig.java new file mode 100644 index 00000000..9bc1b656 --- /dev/null +++ b/src/main/java/com/juick/server/configuration/ActivityPubClientConfig.java @@ -0,0 +1,22 @@ +package com.juick.server.configuration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +import javax.inject.Inject; + +@Configuration +public class ActivityPubClientConfig { + @Inject + ActivityPubClientErrorHandler activityPubClientErrorHandler; + @Bean + public RestTemplate apClient() { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setOutputStreaming(false); + RestTemplate restTemplate = new RestTemplate(requestFactory); + restTemplate.setErrorHandler(activityPubClientErrorHandler); + return restTemplate; + } +}
\ No newline at end of file diff --git a/src/main/java/com/juick/server/configuration/ActivityPubClientErrorHandler.java b/src/main/java/com/juick/server/configuration/ActivityPubClientErrorHandler.java new file mode 100644 index 00000000..e535b3e5 --- /dev/null +++ b/src/main/java/com/juick/server/configuration/ActivityPubClientErrorHandler.java @@ -0,0 +1,35 @@ +package com.juick.server.configuration; + +import com.juick.service.activities.DeleteUserEvent; +import org.apache.commons.io.IOUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.client.DefaultResponseErrorHandler; + +import javax.annotation.Nonnull; +import javax.inject.Inject; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; + +@Component +public class ActivityPubClientErrorHandler extends DefaultResponseErrorHandler { + private static final Logger logger = LoggerFactory.getLogger(ActivityPubClientErrorHandler.class); + + @Inject + private ApplicationEventPublisher applicationEventPublisher; + @Override + public void handleError(URI contextUri, HttpMethod method, @Nonnull ClientHttpResponse response) throws IOException { + logger.warn("HTTP ERROR {} {} : {}", response.getStatusCode().value(), + response.getStatusText(), IOUtils.toString(response.getBody(), StandardCharsets.UTF_8)); + if (response.getStatusCode().equals(HttpStatus.GONE)) { + logger.warn("Server report {} is gone, deleting", contextUri.toASCIIString()); + applicationEventPublisher.publishEvent(new DeleteUserEvent(this, contextUri.toASCIIString())); + } + } +} diff --git a/src/main/java/com/juick/server/configuration/ApiAppConfiguration.java b/src/main/java/com/juick/server/configuration/ApiAppConfiguration.java new file mode 100644 index 00000000..5a5d2c7b --- /dev/null +++ b/src/main/java/com/juick/server/configuration/ApiAppConfiguration.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.configuration; + +import com.juick.server.WebsocketManager; +import com.juick.server.api.rss.MessagesView; +import com.juick.server.api.rss.RepliesView; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.view.BeanNameViewResolver; +import org.springframework.web.servlet.view.feed.AbstractRssFeedView; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.ServletWebSocketHandlerRegistry; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; +import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean; + +import javax.annotation.Nonnull; +import javax.inject.Inject; + +/** + * Created by aalexeev on 11/12/16. + */ +@Configuration +@EnableAsync(proxyTargetClass = true) +@EnableScheduling +@EnableWebSocket +public class ApiAppConfiguration implements WebMvcConfigurer, WebSocketConfigurer { + @Inject + private WebsocketManager websocketManager; + + @Override + public void registerWebSocketHandlers(@Nonnull WebSocketHandlerRegistry registry) { + ((ServletWebSocketHandlerRegistry) registry).setOrder(Ordered.HIGHEST_PRECEDENCE); + registry.addHandler(websocketManager, "/ws/**").setAllowedOrigins("*"); + } + + @Bean + public ServletServerContainerFactoryBean createWebSocketContainer() { + ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean(); + container.setMaxTextMessageBufferSize(8192); + container.setMaxBinaryMessageBufferSize(8192); + return container; + } + @Bean + public BeanNameViewResolver beanNameViewResolver() { + return new BeanNameViewResolver(); + } + @Bean + AbstractRssFeedView messagesView() { + return new MessagesView(); + } + @Bean + AbstractRssFeedView repliesView() { + return new RepliesView(); + } +} diff --git a/src/main/java/com/juick/server/configuration/BaseWebConfiguration.java b/src/main/java/com/juick/server/configuration/BaseWebConfiguration.java new file mode 100644 index 00000000..23a35384 --- /dev/null +++ b/src/main/java/com/juick/server/configuration/BaseWebConfiguration.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.configuration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; +import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.resource.ResourceUrlEncodingFilter; + +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Created by vitalyster on 28.06.2016. + */ +@Configuration +public class BaseWebConfiguration implements WebMvcConfigurer, SchedulingConfigurer { + + + @Override + public void configurePathMatch(PathMatchConfigurer configurer) { + configurer.setUseSuffixPatternMatch(false); + } + + @Bean + public ResourceUrlEncodingFilter resourceUrlEncodingFilter() { + return new ResourceUrlEncodingFilter(); + } + + @Override + public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { + taskRegistrar.setScheduler(taskExecutor()); + } + + @Bean(destroyMethod="shutdown") + public Executor taskExecutor() { + return Executors.newScheduledThreadPool(100); + } + + @Bean + public ExecutorService executorService() { + return Executors.newCachedThreadPool(); + } +} diff --git a/src/main/java/com/juick/server/configuration/SapeConfiguration.java b/src/main/java/com/juick/server/configuration/SapeConfiguration.java new file mode 100644 index 00000000..9727fbb1 --- /dev/null +++ b/src/main/java/com/juick/server/configuration/SapeConfiguration.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.configuration; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import ru.sape.Sape; + +/** + * Created by vitalyster on 29.03.2017. + */ +@Configuration +@ConditionalOnProperty("sape_user") +public class SapeConfiguration { + @Value("${sape_user:}") + private String token; + + @Bean + public Sape sape() { + return new Sape(token, "juick.com", 2000, 3600); + } +} diff --git a/src/main/java/com/juick/server/configuration/SecurityConfig.java b/src/main/java/com/juick/server/configuration/SecurityConfig.java new file mode 100644 index 00000000..f02083d5 --- /dev/null +++ b/src/main/java/com/juick/server/configuration/SecurityConfig.java @@ -0,0 +1,215 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.configuration; + +import com.juick.service.UserService; +import com.juick.service.security.HashParamAuthenticationFilter; +import com.juick.service.security.JuickUserDetailsService; +import com.juick.service.security.deprecated.RequestParamHashRememberMeServices; +import com.juick.service.security.entities.JuickUser; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.builders.WebSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.security.web.authentication.RememberMeServices; +import org.springframework.security.web.authentication.rememberme.TokenBasedRememberMeServices; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import javax.annotation.Resource; +import javax.inject.Inject; +import java.util.Arrays; +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +/** + * Created by aalexeev on 11/21/16. + */ +@EnableWebSecurity +public class SecurityConfig { + @Resource + private UserService userService; + @Value("${auth_remember_me_key:secret}") + private String rememberMeKey; + @Value("${web_domain:localhost}") + private String webDomain; + + private static final String COOKIE_NAME = "juick-remember-me"; + + @Bean + public UserDetailsService userDetailsService() { + return new JuickUserDetailsService(userService); + } + + @Configuration + @Order(1) + public static class ApiConfig extends WebSecurityConfigurerAdapter { + @Value("${auth_remember_me_key:secret}") + private String rememberMeKey; + @Value("${web_domain:localhost}") + private String webDomain; + @Resource + private UserService userService; + ApiConfig() { + super(true); + } + @Bean + RememberMeServices apiTokenServices(){ + return new RequestParamHashRememberMeServices(rememberMeKey, userService); + } + @Bean + public HashParamAuthenticationFilter apiAuthenticationFilter() { + return new HashParamAuthenticationFilter(userService, apiTokenServices()); + } + + @Override + protected void configure(HttpSecurity http) throws Exception { + http.antMatcher("/api/**") + .addFilterBefore(apiAuthenticationFilter(), BasicAuthenticationFilter.class) + .authorizeRequests() + .antMatchers(HttpMethod.OPTIONS).permitAll() + .antMatchers("/api/", "/api/messages", "/api/messages/discussions", "/api/users", "/api/thread", "/api/tags", "/api/tlgmbtwbhk", "/api/fbwbhk", + "/api/skypebotendpoint", "/api/_fblogin", "/api/_vklogin", "/api/_tglogin", "/api/inbox", "/api/u/**", "/.well-known/webfinger", "/.well-known/x-nodeinfo2", "/rss/**", "/api/events").permitAll() + .anyRequest().hasRole("USER") + .and() + .anonymous().principal(JuickUser.ANONYMOUS_USER).authorities(JuickUser.ANONYMOUS_AUTHORITY) + .and() + .httpBasic().authenticationEntryPoint(juickAuthenticationEntryPoint()) + .and().cors().configurationSource(corsConfigurationSource()) + .and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and().exceptionHandling().authenticationEntryPoint(juickAuthenticationEntryPoint()) + .and() + .rememberMe() + .alwaysRemember(true) + .tokenValiditySeconds((int) TimeUnit.DAYS.toSeconds(6 * 30)) + .rememberMeServices(apiTokenServices()) + .key(rememberMeKey) + .and() + .headers().defaultsDisabled().cacheControl(); + } + + @Bean + public AuthenticationEntryPoint juickAuthenticationEntryPoint() { + return new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.setAllowedOrigins(Collections.singletonList("*")); + configuration.setAllowedMethods(Arrays.asList("POST", "GET", "PUT", "OPTIONS", "DELETE")); + configuration.setAllowedHeaders(Collections.singletonList("*")); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", configuration); + + return source; + } + @Override + public void configure(WebSecurity web) { + web.debug(false); + web.ignoring().antMatchers("/api/v2/api-docs", "/api/configuration/ui", "/api/swagger-resources/**", + "/api/configuration/**", "/swagger-ui.html", "/webjars/**", "/h2-console/**"); + } + } + + @Configuration + public static class WebConfig extends WebSecurityConfigurerAdapter { + @Value("${auth_remember_me_key:secret}") + private String rememberMeKey; + @Value("${web_domain:localhost}") + private String webDomain; + @Resource + private UserService userService; + @Inject + private UserDetailsService userDetailsService; + @Bean + @Qualifier("www") + public HashParamAuthenticationFilter wwwAuthenticationFilter() { + return new HashParamAuthenticationFilter(userService, hashCookieServices()); + } + @Bean + @Qualifier("www") + public RememberMeServices hashCookieServices() { + TokenBasedRememberMeServices services = new TokenBasedRememberMeServices( + rememberMeKey, userDetailsService); + + services.setCookieName(COOKIE_NAME); + services.setCookieDomain(webDomain); + services.setAlwaysRemember(true); + services.setTokenValiditySeconds(6 * 30 * 24 * 3600); + services.setUseSecureCookie(false); // TODO set true if https is supports + + return services; + } + @Override + protected void configure(HttpSecurity http) throws Exception { + http + .addFilterBefore(wwwAuthenticationFilter(), BasicAuthenticationFilter.class) + .authorizeRequests() + .antMatchers("/settings", "/pm/**", "/**/bl", "/_twitter", "/post", "/post2", "/comment") + .authenticated() + .anyRequest().permitAll() + .and() + .anonymous().principal(JuickUser.ANONYMOUS_USER).authorities(JuickUser.ANONYMOUS_AUTHORITY) + .and() + .sessionManagement().invalidSessionUrl("/") + .and() + .logout() + .invalidateHttpSession(true) + .logoutUrl("/logout") + .logoutSuccessUrl("/login?logout") + .deleteCookies("hash", COOKIE_NAME) + .and() + .formLogin() + .loginPage("/login") + .permitAll() + .defaultSuccessUrl("/") + .loginProcessingUrl("/login") + .usernameParameter("username") + .passwordParameter("password") + .failureUrl("/login?error=1") + .and() + .rememberMe() + .rememberMeCookieDomain(webDomain).key(rememberMeKey) + .rememberMeServices(hashCookieServices()) + .and() + .csrf().disable() + .headers().defaultsDisabled().cacheControl(); + } + @Override + public void configure(WebSecurity web) { + web.debug(false); + web.ignoring().antMatchers("/style*.css", "/scripts*.js", "/h2-console/**", "/.well-known/**", "/ws/**"); + } + } +} diff --git a/src/main/java/com/juick/server/configuration/StorageConfiguration.java b/src/main/java/com/juick/server/configuration/StorageConfiguration.java new file mode 100644 index 00000000..4101f37d --- /dev/null +++ b/src/main/java/com/juick/server/configuration/StorageConfiguration.java @@ -0,0 +1,20 @@ +package com.juick.server.configuration; + +import com.juick.service.ImagesService; +import com.juick.service.ImagesServiceImpl; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class StorageConfiguration { + + @Value("${upload_tmp_dir:#{systemEnvironment['TEMP'] ?: '/tmp'}}") + private String tmpDir; + @Value("${img_path:#{systemEnvironment['TEMP'] ?: '/tmp'}}") + private String imgDir; + @Bean + public ImagesService imagesService() { + return new ImagesServiceImpl(imgDir, tmpDir); + } +} diff --git a/src/main/java/com/juick/server/configuration/TelegramConfig.java b/src/main/java/com/juick/server/configuration/TelegramConfig.java new file mode 100644 index 00000000..ebd1fd15 --- /dev/null +++ b/src/main/java/com/juick/server/configuration/TelegramConfig.java @@ -0,0 +1,15 @@ +package com.juick.server.configuration; + +import com.juick.server.TelegramBotManager; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConditionalOnProperty(name = "telegram_token") +public class TelegramConfig { + @Bean + public TelegramBotManager telegramBotManager() { + return new TelegramBotManager(); + } +} diff --git a/src/main/java/com/juick/server/configuration/WwwAppConfiguration.java b/src/main/java/com/juick/server/configuration/WwwAppConfiguration.java new file mode 100644 index 00000000..72889f96 --- /dev/null +++ b/src/main/java/com/juick/server/configuration/WwwAppConfiguration.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.configuration; + +import com.juick.server.www.HelpService; +import com.juick.service.TagService; +import com.juick.service.UserService; +import com.mitchellbosecke.pebble.PebbleEngine; +import com.mitchellbosecke.pebble.extension.FormatterExtension; +import com.mitchellbosecke.pebble.loader.ClasspathLoader; +import com.mitchellbosecke.pebble.loader.Loader; +import com.mitchellbosecke.pebble.spring.PebbleViewResolver; +import com.mitchellbosecke.pebble.spring.extension.SpringExtension; +import org.apache.commons.codec.CharEncoding; +import org.commonmark.ext.autolink.AutolinkExtension; +import org.commonmark.node.Link; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.ViewResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import javax.inject.Inject; +import java.util.Collections; + +/** + * Created by aalexeev on 11/22/16. + */ +@Configuration +@EnableCaching +public class WwwAppConfiguration implements WebMvcConfigurer { + @Inject + private UserService userService; + @Inject + private TagService tagService; + @Bean + public CaffeineCacheManager cacheManager() { + return new CaffeineCacheManager("help"); + } + + @Bean + public HelpService helpService() { + return new HelpService("help"); + } + + @Bean + public Parser cmParser() { + return Parser.builder().extensions(Collections.singletonList(AutolinkExtension.create())).build(); + } + @Bean + public HtmlRenderer helpRenderer() { + return HtmlRenderer.builder() + .attributeProviderFactory(context -> (node, tagName, attributes) -> { + if (node instanceof Link) { + Link link = (Link) node; + if (link.getDestination().startsWith("/")) { + String destination = "/" + helpService().getHelpPath() + link.getDestination(); + link.setDestination(destination); + attributes.put("href", destination); + } + } + }) + .build(); + } + @Bean + public Loader templateLoader() { + return new ClasspathLoader(); + } + + @Bean + public SpringExtension springExtension() { + return new SpringExtension(); + } + + @Bean + public PebbleEngine pebbleEngine() { + boolean devToolsArePresent = false; + try { + Class.forName("org.springframework.boot.devtools.livereload.Connection"); + devToolsArePresent = true; + } catch (ClassNotFoundException e) { + // release mode + } + return new PebbleEngine.Builder() + .loader(this.templateLoader()) + .cacheActive(!devToolsArePresent) + .extension(springExtension()) + .extension(new FormatterExtension()) + .strictVariables(true) + .build(); + } + + @Bean + public ViewResolver viewResolver() { + PebbleViewResolver viewResolver = new PebbleViewResolver(); + viewResolver.setPrefix("templates"); + viewResolver.setSuffix(".html"); + viewResolver.setPebbleEngine(pebbleEngine()); + viewResolver.setCharacterEncoding(CharEncoding.UTF_8); + return viewResolver; + } +} diff --git a/src/main/java/com/juick/server/configuration/XMPPConfig.java b/src/main/java/com/juick/server/configuration/XMPPConfig.java new file mode 100644 index 00000000..2feef286 --- /dev/null +++ b/src/main/java/com/juick/server/configuration/XMPPConfig.java @@ -0,0 +1,55 @@ +package com.juick.server.configuration; + +import com.juick.server.XMPPConnection; +import com.juick.server.XMPPServer; +import com.juick.server.xmpp.JidConverter; +import com.juick.server.xmpp.iq.MessageQuery; +import com.juick.server.xmpp.router.XMPPRouter; +import com.juick.server.xmpp.s2s.BasicXmppSession; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; +import org.springframework.core.convert.ConversionService; +import org.springframework.format.support.DefaultFormattingConversionService; +import rocks.xmpp.core.session.Extension; +import rocks.xmpp.core.session.XmppSessionConfiguration; +import rocks.xmpp.core.session.debug.LogbackDebugger; + +import java.time.Duration; + +@Configuration +@ConditionalOnProperty("xmppbot_jid") +public class XMPPConfig { + @Value("${hostname:localhost}") + private String hostname; + @Bean + public BasicXmppSession session() { + XmppSessionConfiguration configuration = XmppSessionConfiguration.builder() + .extensions(Extension.of(com.juick.Message.class), Extension.of(MessageQuery.class)) + .debugger(LogbackDebugger.class) + .defaultResponseTimeout(Duration.ofMillis(120000)) + .build(); + return BasicXmppSession.create(hostname, configuration); + } + @Bean + public static ConversionService conversionService() { + DefaultFormattingConversionService cs = new DefaultFormattingConversionService(); + cs.addConverter(new JidConverter()); + return cs; + } + @Bean + public XMPPServer xmppServer() { + return new XMPPServer(); + } + @Bean + public XMPPRouter xmppRouter() { + return new XMPPRouter(); + } + @Bean + @DependsOn("xmppRouter") + public XMPPConnection xmppConnection() { + return new XMPPConnection(); + } +} diff --git a/src/main/java/com/juick/server/helpers/annotation/UserCommand.java b/src/main/java/com/juick/server/helpers/annotation/UserCommand.java new file mode 100644 index 00000000..4f07001c --- /dev/null +++ b/src/main/java/com/juick/server/helpers/annotation/UserCommand.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.helpers.annotation; + +import org.apache.commons.lang3.StringUtils; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Created by oxpa on 22.03.16. + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface UserCommand { + /** + * + * @return a command pattern + */ + String pattern() default StringUtils.EMPTY; + + /** + * + * @return pattern flags + */ + int patternFlags() default 0; + + /** + * + * @return a string used in HELP command output. Basically, only 1 string + */ + String help() default StringUtils.EMPTY; +} diff --git a/src/main/java/com/juick/server/util/HttpBadRequestException.java b/src/main/java/com/juick/server/util/HttpBadRequestException.java new file mode 100644 index 00000000..242f2b09 --- /dev/null +++ b/src/main/java/com/juick/server/util/HttpBadRequestException.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.util; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * Created by vt on 24/11/2016. + */ +@ResponseStatus(value = HttpStatus.BAD_REQUEST) +public class HttpBadRequestException extends RuntimeException { + public HttpBadRequestException() { + super("the request was bad", null, false, false); + } +} diff --git a/src/main/java/com/juick/server/util/HttpForbiddenException.java b/src/main/java/com/juick/server/util/HttpForbiddenException.java new file mode 100644 index 00000000..3251ca38 --- /dev/null +++ b/src/main/java/com/juick/server/util/HttpForbiddenException.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.util; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * Created by vt on 24/11/2016. + */ +@ResponseStatus(value = HttpStatus.FORBIDDEN) +public class HttpForbiddenException extends RuntimeException { + public HttpForbiddenException() { + super(StringUtils.EMPTY, null, false, false); + } + +} diff --git a/src/main/java/com/juick/server/util/HttpNotFoundException.java b/src/main/java/com/juick/server/util/HttpNotFoundException.java new file mode 100644 index 00000000..f66ece8b --- /dev/null +++ b/src/main/java/com/juick/server/util/HttpNotFoundException.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.util; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * Created by vt on 24/11/2016. + */ +@ResponseStatus(value = HttpStatus.NOT_FOUND) +public class HttpNotFoundException extends RuntimeException { + public HttpNotFoundException() { + super(StringUtils.EMPTY, null, false, false); + } +} diff --git a/src/main/java/com/juick/server/util/HttpUtils.java b/src/main/java/com/juick/server/util/HttpUtils.java new file mode 100644 index 00000000..b70eb3ad --- /dev/null +++ b/src/main/java/com/juick/server/util/HttpUtils.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ +package com.juick.server.util; + +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.web.multipart.MultipartFile; + +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.net.URLConnection; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Iterator; +import java.util.UUID; + +/** + * + * @author Ugnich Anton + */ +public class HttpUtils { + private static final Logger logger = LoggerFactory.getLogger(HttpUtils.class); + + public static URI receiveMultiPartFile(MultipartFile attach, String tmpDir) throws IOException { + if (attach != null && !attach.isEmpty()) { + ImageInputStream iis = ImageIO.createImageInputStream(attach.getInputStream()); + Iterator<ImageReader> readers = ImageIO.getImageReaders(iis); + + String format = StringUtils.EMPTY; + while (readers.hasNext()) { + ImageReader read = readers.next(); + format = read.getFormatName(); + } + String attachmentType = attachmentTypeFromFormat(format); + if (attachmentType.equals("jpg") || attachmentType.equals("png")) { + String attachmentFName = DigestUtils.md5Hex(UUID.randomUUID().toString()) + "." + attachmentType; + try { + Files.write(Paths.get(tmpDir, attachmentFName), + attach.getBytes()); + return URI.create(String.format("juick://%s", attachmentFName)); + } catch (IOException e) { + logger.warn("file receive error", e); + } + } + logger.warn("file type is unknown: {}", attach.getOriginalFilename()); + } + return URI.create(StringUtils.EMPTY); + } + + private static String attachmentTypeFromFormat(String format) throws IOException { + if (format != null && format.equals("JPEG")) { + return "jpg"; + } else if (format != null && format.equals("png")) { + return "png"; + } else { + throw new IOException("Wrong file type: " + format); + } + } + + public static String mediaType(String attachmentType) { + return attachmentType.equals("jpg") ? MediaType.IMAGE_JPEG_VALUE : MediaType.IMAGE_PNG_VALUE; + } + + public static URI downloadImage(URL url, String tmpDir) throws IOException { + ImageInputStream iis = ImageIO.createImageInputStream(url.openStream()); + Iterator<ImageReader> readers = ImageIO.getImageReaders(iis); + + String format = StringUtils.EMPTY; + while (readers.hasNext()) { + ImageReader read = readers.next(); + format = read.getFormatName(); + } + URLConnection urlConn; + try { + urlConn = url.openConnection(); + } catch (IOException e) { + logger.error(String.format("Failed open url: %s", url.toString())); + throw e; + } + + try (InputStream is = new BufferedInputStream(urlConn.getInputStream())) { + String attachmentType = attachmentTypeFromFormat(format); + + String attachmentFName = DigestUtils.md5Hex(UUID.randomUUID().toString()) + "." + attachmentType; + Files.copy(is, Paths.get(tmpDir, attachmentFName)); + return URI.create(String.format("juick://%s", attachmentFName)); + } catch (IOException e) { + logger.error(String.format("Failed download image by url: %s", url.toString()), e); + throw e; + } + } +} diff --git a/src/main/java/com/juick/server/util/ImageUtils.java b/src/main/java/com/juick/server/util/ImageUtils.java new file mode 100644 index 00000000..d16faf8f --- /dev/null +++ b/src/main/java/com/juick/server/util/ImageUtils.java @@ -0,0 +1,175 @@ + +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.util; + +import com.juick.Attachment; +import org.apache.commons.imaging.ImageReadException; +import org.apache.commons.imaging.Imaging; +import org.apache.commons.imaging.common.ImageMetadata; +import org.apache.commons.imaging.formats.jpeg.JpegImageMetadata; +import org.apache.commons.imaging.formats.tiff.TiffField; +import org.apache.commons.imaging.formats.tiff.constants.TiffTagConstants; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.FilenameUtils; +import org.imgscalr.Scalr; +import org.imgscalr.Scalr.Rotation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.FileImageInputStream; +import javax.imageio.stream.ImageInputStream; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.Iterator; + +public class ImageUtils { + private static final Logger logger = LoggerFactory.getLogger(ImageUtils.class); + + private String imgDir; + private String tmpDir; + + public ImageUtils(String imgDir, String tmpDir) { + this.imgDir = imgDir; + this.tmpDir = tmpDir; + } +/** + * Returns <code>BufferedImage</code>, same as <code>ImageIO.read()</code> does. + * + * <p>JPEG images with EXIF metadata are rotated according to Orientation tag. + * + * @param imageFile a <code>File</code> to read from. + */ + private static BufferedImage readImageWithOrientation(File imageFile) + throws IOException { + + BufferedImage image = ImageIO.read(imageFile); + if (!FilenameUtils.getExtension(imageFile.getName()).equals("jpg")) { + return image; + } + + try { + ImageMetadata metadata = Imaging.getMetadata(imageFile); + + if (metadata instanceof JpegImageMetadata) { + JpegImageMetadata jpegMetadata = (JpegImageMetadata) metadata; + TiffField orientationField = jpegMetadata.findEXIFValue(TiffTagConstants.TIFF_TAG_ORIENTATION); + + if (orientationField != null) { + int orientation = orientationField.getIntValue(); + switch (orientation) { + case TiffTagConstants.ORIENTATION_VALUE_ROTATE_90_CW: + image = Scalr.rotate(image, Rotation.CW_90); + break; + case TiffTagConstants.ORIENTATION_VALUE_ROTATE_180: + image = Scalr.rotate(image, Rotation.CW_180); + break; + case TiffTagConstants.ORIENTATION_VALUE_ROTATE_270_CW: + image = Scalr.rotate(image, Rotation.CW_270); + break; + case TiffTagConstants.ORIENTATION_VALUE_MIRROR_HORIZONTAL: + image = Scalr.rotate(image, Rotation.FLIP_HORZ); + break; + case TiffTagConstants.ORIENTATION_VALUE_MIRROR_VERTICAL: + image = Scalr.rotate(image, Rotation.FLIP_VERT); + break; + case TiffTagConstants.ORIENTATION_VALUE_MIRROR_HORIZONTAL_AND_ROTATE_90_CW: + image = Scalr.rotate(Scalr.rotate(image, Rotation.FLIP_HORZ), Rotation.CW_90); + break; + case TiffTagConstants.ORIENTATION_VALUE_MIRROR_HORIZONTAL_AND_ROTATE_270_CW: + image = Scalr.rotate(Scalr.rotate(image, Rotation.FLIP_HORZ), Rotation.CW_270); + break; + case TiffTagConstants.ORIENTATION_VALUE_HORIZONTAL_NORMAL: + default: + // do nothing + break; + } + } + } + } catch (ImageReadException e) { + // failed to read metadata. + // nothing to do here, return image as is. + } + + return image; + } + + public void saveImageWithPreviews(String tempFilename, String outputFilename) + throws IOException { + String ext = FilenameUtils.getExtension(outputFilename); + + Path outputImagePath = Paths.get(imgDir, "p", outputFilename); + // this throws strange exceptions + // Files.move(Paths.get(tmpDir, tempFilename), outputImagePath); + FileUtils.moveFile(Paths.get(tmpDir, tempFilename).toFile(), outputImagePath.toFile()); + BufferedImage originalImage = readImageWithOrientation(outputImagePath.toFile()); + + int width = originalImage.getWidth(); + int height = originalImage.getHeight(); + int maxDimension = (width > height) ? width : height; + BufferedImage image1024 = (maxDimension > 1024) ? Scalr.resize(originalImage, 1024) : originalImage; + BufferedImage image0512 = (maxDimension > 512) ? Scalr.resize(originalImage, 512) : originalImage; + BufferedImage image0160 = (maxDimension > 160) ? Scalr.resize(originalImage, 160) : originalImage; + ImageIO.write(image1024, ext, Paths.get(imgDir, "photos-1024", outputFilename).toFile()); + ImageIO.write(image0512, ext, Paths.get(imgDir, "photos-512", outputFilename).toFile()); + ImageIO.write(image0160, ext, Paths.get(imgDir, "ps", outputFilename).toFile()); + } + + public void saveAvatar(String tempFilename, int uid) + throws IOException { + String ext = FilenameUtils.getExtension(tempFilename); + String originalName = String.format("%s.%s", uid, ext); + Path originalPath = Paths.get(imgDir, "ao", originalName); + Files.move(Paths.get(tmpDir, tempFilename), originalPath, StandardCopyOption.REPLACE_EXISTING); + BufferedImage originalImage = ImageIO.read(originalPath.toFile()); + + String targetExt = "png"; + String targetName = String.format("%s.%s", uid, targetExt); + ImageIO.write(Scalr.resize(originalImage, 96), targetExt, Paths.get(imgDir, "a", targetName).toFile()); + ImageIO.write(Scalr.resize(originalImage, 32), targetExt, Paths.get(imgDir, "as", targetName).toFile()); + } + public Attachment getAttachment(File imgFile) throws IOException { + Attachment attachment = new Attachment(); + try (ImageInputStream stream = ImageIO.createImageInputStream(imgFile)) { + Iterator<ImageReader> iter = ImageIO.getImageReaders(stream); + while (iter.hasNext()) { + ImageReader reader = iter.next(); + try { + reader.setInput(stream); + attachment.setWidth(reader.getWidth(reader.getMinIndex())); + attachment.setHeight(reader.getHeight(reader.getMinIndex())); + return attachment; + } catch (Exception e) { + logger.debug("Error reading {}, trying next reader", imgFile.getAbsolutePath()); + } finally { + reader.dispose(); + } + } + } + + logger.warn("Not a known image file {}", imgFile.getAbsolutePath()); + return attachment; + } +}
\ No newline at end of file diff --git a/src/main/java/com/juick/server/util/TagUtils.java b/src/main/java/com/juick/server/util/TagUtils.java new file mode 100644 index 00000000..cb828933 --- /dev/null +++ b/src/main/java/com/juick/server/util/TagUtils.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.util; + +import com.juick.Tag; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Created by aalexeev on 11/13/16. + */ +public class TagUtils { + private TagUtils() { + throw new IllegalStateException(); + } + + public static String toString(final List<Tag> tags) { + if (CollectionUtils.isEmpty(tags)) + return StringUtils.EMPTY; + + return tags.stream().map(t -> "*" + t.getName()) + .collect(Collectors.joining(" ")); + } +} diff --git a/src/main/java/com/juick/server/util/UserUtils.java b/src/main/java/com/juick/server/util/UserUtils.java new file mode 100644 index 00000000..1adc85ab --- /dev/null +++ b/src/main/java/com/juick/server/util/UserUtils.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.util; + +import com.juick.User; +import com.juick.model.AnonymousUser; +import com.juick.service.security.entities.JuickUser; +import javax.annotation.Nonnull; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * Created by aalexeev on 11/14/16. + */ +public class UserUtils { + private UserUtils() { + throw new IllegalStateException(); + } + + public static Authentication getAuthentication() { + return SecurityContextHolder.getContext().getAuthentication(); + } + + public static Object getPrincipal(final Authentication authentication) { + return authentication == null ? null : authentication.getPrincipal(); + } + + @Nonnull + public static User getCurrentUser() { + Object principal = getPrincipal(getAuthentication()); + + if (principal instanceof JuickUser) + return ((JuickUser) principal).getUser(); + + if (principal instanceof User) + return (User) principal; + + return AnonymousUser.INSTANCE; + } +} diff --git a/src/main/java/com/juick/server/util/WebUtils.java b/src/main/java/com/juick/server/util/WebUtils.java new file mode 100644 index 00000000..9dd628ee --- /dev/null +++ b/src/main/java/com/juick/server/util/WebUtils.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.util; + +import java.util.regex.Pattern; + +/** + * Created by aalexeev on 11/28/16. + */ +public class WebUtils { + private WebUtils() { + throw new IllegalStateException(); + } + + private static final Pattern USER_NAME_PATTERN = Pattern.compile("[a-zA-Z-_\\d]{2,16}"); + + private static final Pattern POST_NUMBER_PATTERN = Pattern.compile("-?\\d+"); + + private static final Pattern JID_PATTERN = Pattern.compile("^[a-zA-Z0-9\\\\-\\\\_\\\\@\\\\.]{6,64}$"); + + + public static boolean isPostNumber(final String aString) { + return aString != null && POST_NUMBER_PATTERN.matcher(aString).matches(); + } + + public static boolean isNotPostNumber(final String aString) { + return !isPostNumber(aString); + } + + public static boolean isUserName(final String aString) { + return aString != null && USER_NAME_PATTERN.matcher(aString).matches(); + } + + public static boolean isNotUserName(final String aString) { + return !isUserName(aString); + } + + public static boolean isJid(final String aString) { + return aString != null && JID_PATTERN.matcher(aString).matches(); + } + + public static boolean isNotJid(final String aString) { + return !isJid(aString); + } + + +} diff --git a/src/main/java/com/juick/server/www/HelpService.java b/src/main/java/com/juick/server/www/HelpService.java new file mode 100644 index 00000000..25727962 --- /dev/null +++ b/src/main/java/com/juick/server/www/HelpService.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.www; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.cache.annotation.Cacheable; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.regex.Pattern; + +/** + * Created by aalexeev on 12/11/16. + */ +public class HelpService { + private static final Pattern LANG_PATTERN = Pattern.compile("[a-z]{2}"); + + private static final Pattern PAGE_PATTERN = Pattern.compile("[a-zA-Z0-9\\-_]+"); + + private final String helpPath; + + + public HelpService(String helpPath) { + this.helpPath = helpPath; + } + + @Cacheable("help") + public String getHelp(final String page, final String lang) { + if (canBePage(page) && canBeLang(lang)) { + String path = StringUtils.joinWith("/", helpPath, lang, page + ".md"); + + try (InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(path)) { + if (is != null) + return IOUtils.toString(is, StandardCharsets.UTF_8); + } catch (IOException e) { + } + } + return null; + } + + public boolean canBePage(final String anything) { + return anything != null && PAGE_PATTERN.matcher(anything).matches(); + } + + public boolean canBeLang(final String anything) { + return anything != null && LANG_PATTERN.matcher(anything).matches(); + } + + public String getHelpPath() { + return helpPath; + } +} diff --git a/src/main/java/com/juick/server/www/WebApp.java b/src/main/java/com/juick/server/www/WebApp.java new file mode 100644 index 00000000..98327a5d --- /dev/null +++ b/src/main/java/com/juick/server/www/WebApp.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ +package com.juick.server.www; + +import com.juick.Tag; +import com.juick.service.TagService; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.resource.ResourceUrlProvider; + +import javax.annotation.PostConstruct; +import javax.inject.Inject; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; + +/** + * + * @author Ugnich Anton + */ +@Component +public class WebApp { + @Inject + private TagService tagService; + @Inject + private ResourceUrlProvider resourceUrlProvider; + + public List<Tag> parseTags(String tagsStr) { + List<Tag> tags = new ArrayList<>(); + if (tagsStr != null && !tagsStr.isEmpty()) { + Stream<String> tagsList = Arrays.stream(tagsStr.split("[ \\,]")) + .distinct().map( t -> { + if (t.startsWith("*")) { + t = t.substring(1); + } + if (t.length() > 64) { + t = t.substring(0, 64); + } + return t; + }); + tags = tagService.getTags(tagsList, true); + while (tags.size() > 5) { + tags.remove(5); + } + } + return tags; + } + + public String getStyleUrl() { + return resourceUrlProvider.getForLookupPath("/style.css"); + } + + public String getScriptsUrl() { + return resourceUrlProvider.getForLookupPath("/scripts.js"); + } +} diff --git a/src/main/java/com/juick/server/www/controllers/AnythingFilter.java b/src/main/java/com/juick/server/www/controllers/AnythingFilter.java new file mode 100644 index 00000000..cdbeafc0 --- /dev/null +++ b/src/main/java/com/juick/server/www/controllers/AnythingFilter.java @@ -0,0 +1,69 @@ +package com.juick.server.www.controllers; + +import com.juick.server.util.WebUtils; +import com.juick.service.MessagesService; +import com.juick.service.UserService; +import org.apache.commons.lang3.math.NumberUtils; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import org.springframework.web.util.UriComponents; + +import javax.annotation.Nonnull; +import javax.inject.Inject; +import javax.servlet.*; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Component +public class AnythingFilter extends OncePerRequestFilter { + @Inject + private MessagesService messagesService; + @Inject + private UserService userService; + + @Override + public void doFilterInternal(@Nonnull HttpServletRequest servletRequest, + @Nonnull HttpServletResponse servletResponse, + @Nonnull FilterChain filterChain) throws IOException, ServletException { + String upgrade = servletRequest.getHeader("Connection"); + if (upgrade != null && upgrade.equals("Upgrade")) { + filterChain.doFilter(servletRequest, servletResponse); + return; + } + UriComponents components = ServletUriComponentsBuilder.fromCurrentRequestUri().build(); + String anything = components.getPath().substring(1); + int before = NumberUtils.toInt(components.getQueryParams().getFirst("before"), 0); + if (before == 0) { + boolean isPostNumber = WebUtils.isPostNumber(anything); + int messageId = isPostNumber ? + NumberUtils.toInt(anything) : 0; + + if (isPostNumber && anything.equals(Integer.toString(messageId))) { + if (messageId > 0) { + com.juick.User author = messagesService.getMessageAuthor(messageId); + + if (author != null) { + servletResponse.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY); + servletResponse.setHeader("Location", "/" + author.getName() + "/" + anything); + return; + } + } + } + com.juick.User user = userService.getUserByName(anything); + if (user.getUid() > 0) { + ((HttpServletResponse)servletResponse).sendRedirect("/" + user.getName() + "/"); + } else { + filterChain.doFilter(servletRequest, servletResponse); + } + } else { + com.juick.User user = userService.getUserByName(anything); + if (!user.isAnonymous()) { + ((HttpServletResponse) servletResponse).sendRedirect("/" + user.getName() + "/?before=" + before); + } else { + filterChain.doFilter(servletRequest, servletResponse); + } + } + } +} diff --git a/src/main/java/com/juick/server/www/controllers/Help.java b/src/main/java/com/juick/server/www/controllers/Help.java new file mode 100644 index 00000000..61b58a9d --- /dev/null +++ b/src/main/java/com/juick/server/www/controllers/Help.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.www.controllers; + +import com.juick.server.util.HttpNotFoundException; +import com.juick.server.util.UserUtils; +import com.juick.service.MessagesService; +import com.juick.server.www.HelpService; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import javax.inject.Inject; +import java.io.IOException; +import java.net.URISyntaxException; +import java.util.Locale; +import java.util.Objects; + +/** + * Created by aalexeev on 11/21/16. + */ +@Controller +public class Help { + @Inject + private HelpService helpService; + @Inject + private MessagesService messagesService; + @Inject + private Parser cmParser; + @Inject + private HtmlRenderer helpRenderer; + + @GetMapping({"/help/", "/help", "/help/{langOrPage}", "/help/{lang}/{page}"}) + public String showHelp( + Locale locale, + @PathVariable(required = false, name = "lang") String lang, + @PathVariable(required = false, name = "page") String page, + @PathVariable(required = false, name = "langOrPage") String langOrPage, + Model model) throws IOException, URISyntaxException { + com.juick.User visitor = UserUtils.getCurrentUser(); + + String navigation = null; + + if (langOrPage != null) { + if (helpService.canBeLang(langOrPage)) { + navigation = helpService.getHelp("navigation", langOrPage); + if (navigation != null) + lang = langOrPage; + } + + if (navigation == null && helpService.canBePage(langOrPage)) + page = langOrPage; + } + + if (lang == null) { + lang = locale.getLanguage(); + } + + String content = helpService.getHelp(page, lang); + if (content == null && !Objects.equals("tos", page)) + content = helpService.getHelp("tos", lang); + + if (navigation == null) + navigation = helpService.getHelp("navigation", lang); + + if (content == null || navigation == null) + throw new HttpNotFoundException(); + + model.addAttribute("navigation", helpRenderer.render(cmParser.parse(navigation))); + model.addAttribute("content", helpRenderer.render(cmParser.parse(content))); + model.addAttribute("visitor", visitor); + + return "views/help"; + } +} diff --git a/src/main/java/com/juick/server/www/controllers/Login.java b/src/main/java/com/juick/server/www/controllers/Login.java new file mode 100644 index 00000000..d933934e --- /dev/null +++ b/src/main/java/com/juick/server/www/controllers/Login.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ +package com.juick.server.www.controllers; + +import com.juick.server.util.UserUtils; +import com.juick.service.UserService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import javax.inject.Inject; + +/** + * @author Ugnich Anton + */ +@Controller +public class Login { + @Inject + private UserService userService; + + @GetMapping("/login") + public String getloginForm(@RequestParam(required = false, defaultValue = "true") boolean redirect) { + com.juick.User visitor = UserUtils.getCurrentUser(); + + if (!visitor.isAnonymous()) { + return redirect ? "redirect:/" : "redirect:/login/success"; + } + return "views/login"; + } + @GetMapping("/login/success") + public String getSuccessLogin(ModelMap model) { + model.addAttribute("hash", userService.getHashByUID(UserUtils.getCurrentUser().getUid())); + return "views/login_success"; + } +} diff --git a/src/main/java/com/juick/server/www/controllers/MessagesWWW.java b/src/main/java/com/juick/server/www/controllers/MessagesWWW.java new file mode 100644 index 00000000..0708e27f --- /dev/null +++ b/src/main/java/com/juick/server/www/controllers/MessagesWWW.java @@ -0,0 +1,593 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ +package com.juick.server.www.controllers; + +import com.juick.Message; +import com.juick.Tag; +import com.juick.formatters.PlainTextFormatter; +import com.juick.server.Utils; +import com.juick.server.util.HttpForbiddenException; +import com.juick.server.util.HttpNotFoundException; +import com.juick.server.util.UserUtils; +import com.juick.server.util.WebUtils; +import com.juick.service.*; +import com.juick.util.MessageUtils; +import org.apache.commons.codec.CharEncoding; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.text.StringEscapeUtils; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import org.springframework.web.util.UriComponents; +import ru.sape.Sape; + +import javax.inject.Inject; +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * + * @author Ugnich Anton + */ +@Controller +public class MessagesWWW { + @Inject + private UserService userService; + @Inject + private TagService tagService; + @Inject + private MessagesService messagesService; + @Inject + private Optional<Sape> sape; + @Inject + private PMQueriesService pmQueriesService; + @Inject + private CrosspostService crosspostService; + @Inject + private ApplicationEventPublisher applicationEventPublisher; + + void fillUserModel(ModelMap model, com.juick.User user, com.juick.User visitor) { + model.addAttribute("user", user); + model.addAttribute("isSubscribed", userService.isSubscribed(visitor.getUid(), user.getUid())); + model.addAttribute("isInBL", userService.isInBL(visitor.getUid(), user.getUid())); + model.addAttribute("isInBLAny", userService.isInBLAny(user.getUid(), visitor.getUid())); + model.addAttribute("statsIRead", userService.getUserFriends(user.getUid()).size()); + model.addAttribute("statsMyReaders", userService.getStatsMyReaders(user.getUid())); + model.addAttribute("statsMyBL", userService.getUserBLUsers(user.getUid()).size()); + model.addAttribute("statsMessages", userService.getStatsMessages(user.getUid())); + model.addAttribute("statsReplies", userService.getStatsReplies(user.getUid())); + model.addAttribute("iread", userService.getUserReadLeastPopular(user.getUid(), 8)); + model.addAttribute("tagStats", tagService.getUserTagStats(user.getUid()) + .stream().sorted((e1, e2) -> Integer.compare(e2.getUsageCount(), e1.getUsageCount())).limit(20).map(t -> t.getTag().getName()).collect(Collectors.toList())); + } + + @GetMapping("/") + protected String doGet( + @RequestParam(required = false) String tag, + @RequestParam(name = "show", required = false) String paramShow, + @RequestParam(name = "search", required = false) String paramSearch, + @RequestParam(name = "before", required = false, defaultValue = "0") Integer paramBefore, + @RequestParam(name = "to", required = false, defaultValue = "0") Long paramTo, + @RequestParam(name = "page", required = false, defaultValue = "0") Integer page, + @CookieValue(name = "sape_cookie", required = false, defaultValue = StringUtils.EMPTY) String sapeCookie, + ModelMap model) throws IOException { + if (tag != null) { + return "redirect:/tag/" + URLEncoder.encode(tag, CharEncoding.UTF_8); + } + com.juick.User visitor = UserUtils.getCurrentUser(); + + if (paramSearch != null && paramSearch.length() > 64) { + paramSearch = null; + } + + model.addAttribute("discover", false); + + String title; + List<Integer> mids; + + if (paramSearch != null) { + title = "Поиск: " + StringEscapeUtils.escapeHtml4(paramSearch); + mids = messagesService.getSearch(visitor, Utils.encodeSphinx(paramSearch), page); + } else if (paramShow == null) { + title = "Обсуждения"; + mids = messagesService.getDiscussions(visitor.getUid(), paramTo); + } else if (paramShow.equals("top")) { + title = "Популярные"; + mids = messagesService.getPopular(visitor.getUid(), paramBefore); + model.addAttribute("discover", true); + } else if (paramShow.equals("my") && !visitor.isAnonymous()) { + title = "Моя лента"; + mids = messagesService.getMyFeed(visitor.getUid(), paramBefore, true); + } else if (paramShow.equals("private") && !visitor.isAnonymous()) { + title = "Приватные"; + mids = messagesService.getPrivate(visitor.getUid(), paramBefore); + } else if (paramShow.equals("discuss")) { + return "redirect:/"; + } else if (paramShow.equals("recommended") && !visitor.isAnonymous()) { + title = "Рекомендации"; + mids = messagesService.getRecommended(visitor.getUid(), paramBefore); + } else if (paramShow.equals("photos")) { + title = "Фотографии"; + mids = messagesService.getPhotos(visitor.getUid(), paramBefore); + model.addAttribute("discover", true); + } else if (paramShow.equals("all")) { + title = "Все сообщения"; + mids = messagesService.getAll(visitor.getUid(), paramBefore); + model.addAttribute("discover", true); + } else { + throw new HttpNotFoundException(); + } + + String head = "<meta name=\"Description\" content=\"" + title + "\" />\n";; + + if (paramBefore > 0 || paramShow != null) { + head = "<meta name=\"robots\" content=\"noindex\"/>"; + } + model.addAttribute("title", title); + model.addAttribute("headers", head); + model.addAttribute("visitor", visitor); + model.addAttribute("noindex", !(paramShow == null && paramBefore == 0)); + List<com.juick.Message> msgs = messagesService.getMessages(visitor, mids); + + if (!visitor.isAnonymous()) { + fillUserModel(model, visitor, visitor); + List<Integer> unread = messagesService.getUnread(visitor); + visitor.setUnreadCount(unread.size()); + List<Integer> blUIDs = userService.checkBL(visitor.getUid(), + msgs.stream().map(m -> m.getUser().getUid()).collect(Collectors.toList())); + msgs.forEach(m -> { + m.ReadOnly |= blUIDs.contains(m.getUser().getUid()); + m.setUnread(unread.contains(m.getMid())); + }); + } + model.addAttribute("msgs", msgs); + model.addAttribute("tags", tagService.getPopularTags()); + model.addAttribute("headers", head); + model.addAttribute("showAdv", + paramShow == null && paramBefore == 0 && paramSearch == null && visitor.isAnonymous()); + if (mids.size() >= 20) { + String nextpage = (paramShow == null) ? "?to=" + msgs.get(msgs.size() - 1).getUpdated().toEpochMilli() : paramSearch != null ? String.format("?page=%d", page + 1) : "?before=" + mids.get(mids.size() - 1); + if (paramShow != null) { + nextpage += "&show=" + paramShow; + } + if (paramSearch != null) { + nextpage += "&search=" + URLEncoder.encode(paramSearch, CharEncoding.UTF_8); + } + model.addAttribute("nextpage", nextpage); + } + UriComponents builder = ServletUriComponentsBuilder.fromCurrentRequestUri().build(); + String queryString = builder.getQuery(); + String requestURI = builder.toUri().getPath(); + if (sape.isPresent() && visitor.isAnonymous() && queryString == null) { + String links = sape.get().getPageLinks(requestURI, sapeCookie).render(); + model.addAttribute("links", links); + } + return "views/index"; + } + + @GetMapping(path = "/{uname}/", headers = "Connection!=Upgrade") + protected String doGetBlog( + @RequestParam(required = false, name = "show") String paramShow, + @RequestParam(required = false, name = "tag") String paramTagStr, + @RequestParam(required = false, name = "search") String paramSearch, + @RequestParam(required = false, name = "page", defaultValue = "0") Integer page, + @PathVariable String uname, + @RequestParam(required = false, defaultValue = "0") Integer before, + @CookieValue(name = "sape_cookie", required = false, defaultValue = StringUtils.EMPTY) String sapeCookie, + ModelMap model) throws IOException { + com.juick.User user = userService.getUserByName(uname); + com.juick.User visitor = UserUtils.getCurrentUser(); + if (user.isBanned() || user.isAnonymous()) { + throw new HttpNotFoundException(); + } + + List<Integer> mids; + + com.juick.Tag paramTag = null; + if (paramTagStr != null) { + if (paramTagStr.length() < 64) { + paramTag = tagService.getTag(paramTagStr, false); + } + if (paramTag == null) { + throw new HttpNotFoundException(); + } else if (!paramTag.getName().equals(paramTagStr)) { + String url = user.getName() + "/?tag=" + URLEncoder.encode(paramTag.getName(), CharEncoding.UTF_8); + return "redirect:/" + url; + } + } + if (paramSearch != null && paramSearch.length() > 64) { + paramSearch = null; + } + + int privacy = 0; + if (!visitor.isAnonymous()) { + if (user.getUid() == visitor.getUid() || visitor.getUid() == 1) { + privacy = -3; + } else if (userService.isInWL(user.getUid(), visitor.getUid())) { + privacy = -2; + } + } + + String title; + if (paramShow == null) { + if (paramTag != null) { + title = "Блог " + user.getName() + ": *" + StringEscapeUtils.escapeHtml4(paramTag.getName()); + mids = messagesService.getUserTag(user.getUid(), paramTag.TID, privacy, before); + } else if (paramSearch != null) { + title = "Блог " + user.getName() + ": " + StringEscapeUtils.escapeHtml4(paramSearch); + mids = messagesService.getUserSearch(visitor, user.getUid(), Utils.encodeSphinx(paramSearch), privacy, page); + } else { + title = "Блог " + user.getName(); + mids = messagesService.getUserBlog(user.getUid(), privacy, before); + } + } else if (paramShow.equals("recomm")) { + title = "Рекомендации " + user.getName(); + mids = messagesService.getUserRecommendations(user.getUid(), before); + } else if (paramShow.equals("photos")) { + title = "Фотографии " + user.getName(); + mids = messagesService.getUserPhotos(user.getUid(), privacy, before); + } else { + throw new HttpNotFoundException(); + } + + String head = "<link rel=\"alternate\" type=\"application/rss+xml\" title=\"@" + + user.getName() + "\" href=\"//rss.juick.com/" + user.getName() + "/blog\"/>"; + head += "<meta name=\"Description\" content=\"" + title + "\" />\n"; + if (paramTag != null && tagService.getTagNoIndex(paramTag.TID)) { + head += "<meta name=\"robots\" content=\"noindex,nofollow\"/>"; + } else if (before > 0 || paramShow != null) { + head += "<meta name=\"robots\" content=\"noindex\"/>"; + } + model.addAttribute("pageUrl", "http://juick.com/" + user.getName()); + model.addAttribute("title", title); + model.addAttribute("headers", head); + model.addAttribute("visitor", visitor); + model.addAttribute("noindex", paramShow == null && before == 0); + fillUserModel(model, user, visitor); + model.addAttribute("paramTag", paramTag); + List<com.juick.Message> msgs = messagesService.getMessages(visitor, mids); + + if (!visitor.isAnonymous()) { + List<Integer> unread = messagesService.getUnread(visitor); + visitor.setUnreadCount(unread.size()); + List<Integer> blUIDs = userService.checkBL(visitor.getUid(), + msgs.stream().map(m -> m.getUser().getUid()).collect(Collectors.toList())); + msgs.forEach(m -> { + m.ReadOnly |= blUIDs.contains(m.getUser().getUid()); + m.setUnread(unread.contains(m.getMid())); + }); + } + model.addAttribute("msgs", msgs); + model.addAttribute("headers", head); + model.addAttribute("showAdv", + paramShow == null && before == 0 && paramSearch == null && visitor.getUid() == 0); + if (mids.size() >= 20) { + String nextpage = paramSearch != null ? String.format("?page=%d", page + 1) : "?before=" + mids.get(mids.size() - 1); + if (paramShow != null) { + nextpage += "&show=" + paramShow; + } + if (paramSearch != null) { + nextpage += "&search=" + URLEncoder.encode(paramSearch, CharEncoding.UTF_8); + } + if (paramTag != null) { + nextpage += "&tag=" + URLEncoder.encode(paramTag.getName(), CharEncoding.UTF_8); + } + model.addAttribute("nextpage", nextpage); + } + UriComponents builder = ServletUriComponentsBuilder.fromCurrentRequestUri().build(); + String queryString = builder.getQuery(); + String requestURI = builder.toUri().getPath(); + if (sape.isPresent() && visitor.isAnonymous() && queryString == null) { + String links = sape.get().getPageLinks(requestURI, sapeCookie).render(); + model.addAttribute("links", links); + } + return "views/blog"; + } + + @GetMapping("/{uname}/tags") + protected String doGetTags(@PathVariable String uname, ModelMap model) throws IOException { + com.juick.User user = userService.getUserByName(uname); + com.juick.User visitor = UserUtils.getCurrentUser(); + if (visitor.isBanned()) { + throw new HttpNotFoundException(); + } + + model.addAttribute("title", "Теги " + user.getName()); + model.addAttribute("headers", "<meta name=\"robots\" content=\"noindex,nofollow\"/>"); + model.addAttribute("visitor", visitor); + fillUserModel(model, user, visitor); + model.addAttribute("tags", tagService.getUserTagStats(user.getUid()).stream() + .sorted((e1, e2) -> Integer.compare(e2.getUsageCount(), e1.getUsageCount())).map(t -> t.getTag().getName()).collect(Collectors.toList())); + + return "views/blog_tags"; + } + + @GetMapping("/{uname}/friends") + protected String doGetFriends(@PathVariable String uname, ModelMap model) throws IOException { + com.juick.User user = userService.getUserByName(uname); + com.juick.User visitor = UserUtils.getCurrentUser(); + if (visitor.isBanned()) { + throw new HttpNotFoundException(); + } + model.addAttribute("title", "Подписки " + user.getName()); + model.addAttribute("headers", "<meta name=\"robots\" content=\"noindex\"/>"); + model.addAttribute("visitor", visitor); + fillUserModel(model, user, visitor); + model.addAttribute("users", userService.getUserFriends(user.getUid())); + + return "views/users"; + } + + @GetMapping("/{uname}/readers") + protected String doGetReaders(@PathVariable String uname, ModelMap model) throws IOException { + com.juick.User user = userService.getUserByName(uname); + com.juick.User visitor = UserUtils.getCurrentUser(); + if (visitor.isBanned()) { + throw new HttpForbiddenException(); + } + model.addAttribute("title", "Читатели " + user.getName()); + model.addAttribute("headers", "<meta name=\"robots\" content=\"noindex\"/>"); + model.addAttribute("visitor", visitor); + fillUserModel(model, user, visitor); + model.addAttribute("users", userService.getUserReaders(user.getUid())); + + return "views/users"; + } + + @GetMapping("/{uname}/bl") + protected String doGetBL(@PathVariable String uname, ModelMap model) throws IOException { + com.juick.User user = userService.getUserByName(uname); + com.juick.User visitor = UserUtils.getCurrentUser(); + if (visitor.isBanned() || visitor.getUid() != user.getUid()) { + throw new HttpForbiddenException(); + } + model.addAttribute("title", "Черный список " + user.getName()); + model.addAttribute("headers", "<meta name=\"robots\" content=\"noindex\"/>"); + model.addAttribute("visitor", visitor); + fillUserModel(model, user, visitor); + model.addAttribute("users", userService.getUserBLUsers(user.getUid())); + + return "views/users"; + } + @GetMapping("/tag/{tagName}") + protected String tagAction(HttpServletRequest request, + @PathVariable String tagName, + @CookieValue(name = "sape_cookie", required = false, defaultValue = StringUtils.EMPTY) String sapeCookie, + @RequestParam(required = false, defaultValue = "0") int before, + ModelMap model) throws IOException { + com.juick.User visitor = UserUtils.getCurrentUser(); + + String paramTagStr = StringEscapeUtils.unescapeHtml4(tagName); + com.juick.Tag paramTag = tagService.getTag(paramTagStr, false); + if (paramTag == null) { + throw new HttpNotFoundException(); + } else if (paramTag.SynonymID > 0 && paramTag.TID != paramTag.SynonymID) { + com.juick.Tag synTag = tagService.getTag(paramTag.SynonymID); + String url = "/tag/" + URLEncoder.encode(StringEscapeUtils.escapeHtml4(synTag.getName()), CharEncoding.UTF_8); + if (request.getQueryString() != null) { + url += "?" + request.getQueryString(); + } + return "redirect:" + url; + } else if (!paramTag.getName().equals(paramTagStr)) { + String url = "/tag/" + URLEncoder.encode(StringEscapeUtils.escapeHtml4(paramTag.getName()), CharEncoding.UTF_8); + if (request.getQueryString() != null) { + url += "?" + request.getQueryString(); + } + return "redirect:" + url; + } + + String title = "*" + StringEscapeUtils.escapeHtml4(paramTag.getName()); + model.addAttribute("title", title); + List<Integer> mids = messagesService.getTag(paramTag.TID, visitor.getUid(), before, (visitor.isAnonymous()) ? 40 : 20); + List<com.juick.Message> msgs = messagesService.getMessages(visitor, mids); + if (!visitor.isAnonymous()) { + List<Integer> unread = messagesService.getUnread(visitor); + visitor.setUnreadCount(unread.size()); + List<Integer> blUIDs = userService.checkBL( + visitor.getUid(), + msgs.stream().map(m -> m.getUser().getUid()).collect(Collectors.toList()) + ); + msgs.forEach(m -> { + m.ReadOnly |= blUIDs.contains(m.getUser().getUid()); + m.setUnread(unread.contains(m.getMid())); + }); + fillUserModel(model, visitor, visitor); + } + + String head = StringUtils.EMPTY; + if (tagService.getTagNoIndex(paramTag.TID)) { + head = "<meta name=\"robots\" content=\"noindex,nofollow\"/>"; + } else if (before > 0 || mids.size() < 5) { + head = "<meta name=\"robots\" content=\"noindex\"/>"; + } + model.addAttribute("headers", head); + model.addAttribute("visitor", visitor); + model.addAttribute("tag", paramTag); + model.addAttribute("title", title); + model.addAttribute("msgs", msgs); + model.addAttribute("tags", tagService.getPopularTags()); + model.addAttribute("noindex", before > 0); + model.addAttribute("showAdv", before == 0 && visitor.isAnonymous()); + model.addAttribute("isSubscribed", tagService.isSubscribed(visitor, paramTag)); + model.addAttribute("isInBL", tagService.isInBL(visitor, paramTag)); + if (mids.size() >= 20) { + String nextpage = "/tag/" + URLEncoder.encode(paramTag.getName(), CharEncoding.UTF_8) + "?before=" + mids.get(mids.size() - 1); + model.addAttribute("nextpage", nextpage); + } + UriComponents builder = ServletUriComponentsBuilder.fromCurrentRequestUri().build(); + String queryString = builder.getQuery(); + String requestURI = builder.toUri().getPath(); + if (sape.isPresent() && visitor.isAnonymous() && queryString == null) { + String links = sape.get().getPageLinks(requestURI, sapeCookie).render(); + model.addAttribute("links", links); + } + return "views/index"; + } + @GetMapping("/pm/inbox") + protected String doGetInbox(ModelMap model) { + com.juick.User visitor = UserUtils.getCurrentUser(); + if (visitor.isAnonymous()) { + return "redirect:/login"; + } + String title = "PM: Inbox"; + List<com.juick.Message> msgs = pmQueriesService.getLastPMInbox(visitor.getUid()); + fillUserModel(model, visitor, visitor); + model.addAttribute("title", title); + model.addAttribute("visitor", visitor); + model.addAttribute("msgs", msgs); + model.addAttribute("tags", tagService.getPopularTags()); + return "views/pm_inbox"; + } + + @GetMapping("/pm/sent") + protected String doGetSent(@RequestParam(required = false) String uname, + ModelMap model) { + com.juick.User visitor = UserUtils.getCurrentUser(); + if (visitor.isAnonymous()) { + return "redirect:/login"; + } + String title = "PM: Sent"; + List<com.juick.Message> msgs = pmQueriesService.getLastPMSent(visitor.getUid()); + + if (WebUtils.isNotUserName(uname)) { + uname = StringUtils.EMPTY; + } + fillUserModel(model, visitor, visitor); + model.addAttribute("title", title); + model.addAttribute("visitor", visitor); + model.addAttribute("msgs", msgs); + model.addAttribute("tags", tagService.getPopularTags()); + model.addAttribute("uname", uname); + return "views/pm_sent"; + } + @GetMapping(value = "/{uname}/{mid}", produces = MediaType.TEXT_HTML_VALUE) + protected String threadAction(ModelMap model, + @PathVariable String uname, + @PathVariable int mid, + @CookieValue(name = "sape_cookie", + required = false, defaultValue = StringUtils.EMPTY) String sapeCookie) { + com.juick.User visitor = UserUtils.getCurrentUser(); + + if (!messagesService.canViewThread(mid, visitor.getUid())) { + throw new HttpForbiddenException(); + } + + com.juick.Message msg = messagesService.getMessage(mid); + + if (msg == null || msg.getUser().isBanned()) { + throw new HttpNotFoundException(); + } + + com.juick.User user = userService.getUserByName(uname); + if (user.isAnonymous() || !msg.getUser().equals(user)) { + return String.format("redirect:/%s/%d", msg.getUser().getName(), mid); + } + msg.VisitorCanComment = !visitor.isAnonymous(); + List<com.juick.Message> replies = messagesService.getReplies(visitor, msg.getMid()); + // this should be after getReplies to mark thread as read + fillUserModel(model, user, visitor); + if (!visitor.isAnonymous()) { + List<Integer> unread = messagesService.getUnread(visitor); + visitor.setUnreadCount(unread.size()); + boolean isMsgAuthor = visitor.getUid() == msg.getUser().getUid(); + boolean isInBL = userService.isInBLAny(msg.getUser().getUid(), visitor.getUid()); + msg.VisitorCanComment = isMsgAuthor || !(msg.ReadOnly || isInBL); + } + model.addAttribute("msg", msg); + + String title = msg.getUser().getName() + ": " + MessageUtils.getTagsString(msg); + + model.addAttribute("title", title); + model.addAttribute("visitor", visitor); + String headers = "<link rel=\"alternate\" type=\"application/rss+xml\" title=\"@" + msg.getUser().getName() + "\" href=\"//rss.juick.com/" + msg.getUser().getName() + "/blog\"/>"; + String pageUrl = "https://juick.com/" + msg.getUser().getName() + "/" + msg.getMid(); + if (msg.Hidden) { + headers += "<meta name=\"robots\" content=\"noindex\"/>"; + } + String cardType = StringUtils.isNotEmpty(msg.getAttachmentType()) ? "summary_large_image" : "summary"; + if (StringUtils.isNotEmpty(msg.getAttachmentType())) { + // additional check in case of broken images + if (msg.getAttachment() != null) { + String msgImage = msg.getAttachment().getMedium().getUrl(); + headers += "<meta property=\"og:image\" content=\"" + msgImage + "\" />"; + } + } else { + String msgImage ="https://i.juick.com/a/" + msg.getUser().getUid() + ".png"; + headers += "<meta property=\"og:image\" content=\"" + msgImage + "\" />"; + } + model.addAttribute("ogtype", "article"); + String cardDescription = StringEscapeUtils.escapeHtml4(PlainTextFormatter.formatTwitterCard(msg)); + headers += "<meta name=\"twitter:card\" content=\"" + cardType + "\" />\n" + + "<meta name=\"twitter:site\" content=\"@juick\" />\n" + + "<meta property=\"og:url\" content=\"" + pageUrl + "\" />\n" + + "<meta property=\"og:title\" content=\"" + msg.getUser().getName() + " at Juick\" />\n" + + "<meta property=\"og:description\" content=\"" + cardDescription + "\" />\n" + + "<meta name=\"Description\" content=\"" + cardDescription + "\" />\n"; + String twitterName = crosspostService.getTwitterName(msg.getUser().getUid()); + if (StringUtils.isNotEmpty(twitterName)) { + headers += "<meta name=\"twitter:creator\" content=\"@" + twitterName + "\" />\n"; + } + if (msg.getTags().size() > 0) { + headers += "<meta name=\"Keywords\" content=\"" + msg.getTags().stream().map(Tag::getName) + .collect(Collectors.joining(", ")) + "\" />\n"; + } + model.addAttribute("headers", headers); + model.addAttribute("visitorSubscribed", messagesService.isSubscribed(visitor.getUid(), msg.getMid())); + model.addAttribute("visitorInBL", userService.isInBL(msg.getUser().getUid(), visitor.getUid())); + model.addAttribute("recomm", messagesService.getMessageRecommendations(msg.getMid())); + List<Integer> blUIDs = new ArrayList<>(); + for (Message reply : replies) { + if (reply.getUser().getUid() != msg.getUser().getUid() + && !blUIDs.contains(reply.getUser().getUid())) { + blUIDs.add(reply.getUser().getUid()); + } + reply.VisitorCanComment = !visitor.isAnonymous(); + if (!visitor.isAnonymous()) { + boolean isMsgAuthor = visitor.getUid() == msg.getUser().getUid(); + boolean isReplyAuthor = visitor.getUid() == reply.getUser().getUid(); + reply.VisitorCanComment = isMsgAuthor || (!msg.ReadOnly + && msg.VisitorCanComment && (isReplyAuthor || !userService.isInBLAny(visitor.getUid(), reply.getUser().getUid()))); + } + } + model.addAttribute("replies", replies); + model.addAttribute("showAdv", visitor.isAnonymous()); + UriComponents builder = ServletUriComponentsBuilder.fromCurrentRequestUri().build(); + String queryString = builder.getQuery(); + String requestURI = builder.toUri().getPath(); + if (sape.isPresent() && visitor.isAnonymous() && queryString == null) { + String links = sape.get().getPageLinks(requestURI, sapeCookie).render(); + model.addAttribute("links", links); + } + return "views/thread"; + } + + // when message id is not fit to int + @ExceptionHandler(NumberFormatException.class) + public ResponseEntity<String> notFoundAction() { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } +} diff --git a/src/main/java/com/juick/server/www/controllers/NewMessage.java b/src/main/java/com/juick/server/www/controllers/NewMessage.java new file mode 100644 index 00000000..6b5938a5 --- /dev/null +++ b/src/main/java/com/juick/server/www/controllers/NewMessage.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ +package com.juick.server.www.controllers; + +import com.juick.server.util.UserUtils; +import com.juick.service.TagService; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.text.StringEscapeUtils; +import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import javax.inject.Inject; +import java.util.stream.Collectors; + +/** + * @author Ugnich Anton + */ +@Controller +public class NewMessage { + + @Inject + private TagService tagService; + @GetMapping("/post") + protected String postAction(@RequestParam(required = false) String body, ModelMap model) { + com.juick.User visitor = UserUtils.getCurrentUser(); + model.addAttribute("title", "Написать"); + model.addAttribute("headers", ""); + model.addAttribute("visitor", visitor); + if (body == null) { + body = StringUtils.EMPTY; + } else { + if (body.length() > 4096) { + body = body.substring(0, 4096); + } + body = StringEscapeUtils.escapeHtml4(body); + } + model.addAttribute("body", body); + model.addAttribute("visitor", visitor); + model.addAttribute("tags", tagService.getUserTagStats(visitor.getUid()).stream() + .sorted((e1, e2) -> Integer.compare(e2.getUsageCount(), e1.getUsageCount())).map(t -> t.getTag().getName()).collect(Collectors.toList())); + return "views/post"; + } +} diff --git a/src/main/java/com/juick/server/www/controllers/Settings.java b/src/main/java/com/juick/server/www/controllers/Settings.java new file mode 100644 index 00000000..cc8f43eb --- /dev/null +++ b/src/main/java/com/juick/server/www/controllers/Settings.java @@ -0,0 +1,278 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ +package com.juick.server.www.controllers; + +import com.juick.User; +import com.juick.model.NotifyOpts; +import com.juick.model.UserInfo; +import com.juick.server.util.HttpBadRequestException; +import com.juick.server.util.HttpUtils; +import com.juick.server.util.UserUtils; +import com.juick.service.*; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; + +import javax.inject.Inject; +import javax.mail.Message; +import javax.mail.MessagingException; +import javax.mail.Session; +import javax.mail.Transport; +import javax.mail.internet.InternetAddress; +import javax.mail.internet.MimeMessage; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/** + * + * @author Ugnich Anton + */ +@Controller +public class Settings { + private static final Logger logger = LoggerFactory.getLogger(Settings.class); + + @Value("${img_path:#{systemEnvironment['TEMP'] ?: '/tmp'}}") + private String imgDir; + @Value("${upload_tmp_dir:#{systemEnvironment['TEMP'] ?: '/tmp'}}") + private String tmpDir; + @Inject + private TagService tagService; + @Inject + private UserService userService; + @Inject + private CrosspostService crosspostService; + @Inject + private SubscriptionService subscriptionService; + @Inject + private EmailService emailService; + @Inject + private TelegramService telegramService; + @Inject + private ImagesService imagesService; + + @GetMapping("/settings") + protected String doGet(HttpServletRequest request, HttpServletResponse response, ModelMap model) throws IOException { + com.juick.User visitor = UserUtils.getCurrentUser(); + if (visitor.isAnonymous()) { + response.sendRedirect("/login"); + } + List<String> pages = Arrays.asList("main", "password", "about", "auth-email", "privacy"); + String page = request.getParameter("page"); + if (StringUtils.isEmpty(page) || !pages.contains(page)) { + page = "main"; + } + + model.addAttribute("title", "Настройки"); + model.addAttribute("visitor", visitor); + model.addAttribute("tags", tagService.getPopularTags()); + model.addAttribute("auths", userService.getAuthCodes(visitor)); + model.addAttribute("email_active", emailService.getNotificationsEmail(visitor.getUid())); + model.addAttribute("ehash", userService.getEmailHash(visitor)); + model.addAttribute("emails", userService.getEmails(visitor)); + model.addAttribute("jids", userService.getAllJIDs(visitor)); + List<String> hours = IntStream.rangeClosed(0, 23).boxed() + .map(i -> StringUtils.leftPad(String.format("%d", i), 2, "0")).collect(Collectors.toList()); + model.addAttribute("hours", hours); + model.addAttribute("fbstatus", crosspostService.getFbCrossPostStatus(visitor.getUid())); + model.addAttribute("twitter_name", crosspostService.getTwitterName(visitor.getUid())); + model.addAttribute("telegram_name", crosspostService.getTelegramName(visitor.getUid())); + model.addAttribute("notify_options", subscriptionService.getNotifyOptions(visitor)); + model.addAttribute("userinfo", userService.getUserInfo(visitor)); + if (page.equals("auth-email")) { + if (emailService.verifyAddressByCode(visitor.getUid(), request.getParameter("code"))) { + ; + model.addAttribute("result", "OK!"); + } else { + model.addAttribute("result", "Sorry, code unknown."); + } + } + return String.format("views/settings_%s", page); + } + + @PostMapping("/settings") + protected String doPost(HttpServletRequest request, HttpServletResponse response, + @RequestParam(required = false) MultipartFile avatar, + ModelMap model) + throws IOException { + com.juick.User visitor = UserUtils.getCurrentUser(); + if (visitor.isAnonymous()) { + throw new HttpBadRequestException(); + } + List<String> pages = Arrays.asList("main", "password", "about", "email", "email-add", "email-del", + "email-subscr", "auth-email", "privacy", "jid-del", "twitter-del", "telegram-del", "facebook-disable", + "facebook-enable", "vk-del"); + String page = request.getParameter("page"); + if (StringUtils.isEmpty(page) || !pages.contains(page)) { + throw new HttpBadRequestException(); + } + String result = StringUtils.EMPTY; + switch (page) { + case "password": + if (userService.updatePassword(visitor, request.getParameter("password"))) { + result = "<p>Password has been changed.</p>"; + String hash = userService.getHashByUID(visitor.getUid()); + Cookie c = new Cookie("hash", hash); + c.setMaxAge(365 * 24 * 60 * 60); + response.addCookie(c); + } + break; + case "main": + NotifyOpts opts = new NotifyOpts(); + opts.setRepliesEnabled(StringUtils.isNotEmpty(request.getParameter("jnotify"))); + opts.setSubscriptionsEnabled(StringUtils.isNotEmpty(request.getParameter("subscr_notify"))); + opts.setRecommendationsEnabled(StringUtils.isNotEmpty(request.getParameter("recomm"))); + if (subscriptionService.setNotifyOptions(visitor, opts)) { + result = "<p>Notification options has been updated</p>"; + } + break; + case "about": + UserInfo info = new UserInfo(); + info.setFullName(request.getParameter("fullname")); + info.setCountry(request.getParameter("country")); + info.setUrl(request.getParameter("url")); + info.setDescription(request.getParameter("descr")); + String avatarTmpPath = HttpUtils.receiveMultiPartFile(avatar, tmpDir).getHost(); + if (StringUtils.isNotEmpty(avatarTmpPath)) { + imagesService.saveAvatar(avatarTmpPath, visitor.getUid()); + } + if (userService.updateUserInfo(visitor, info)) { + result = String.format("<p>Your info is updated.</p><p><a href='/%s/'>Back to blog</a>.</p>", visitor.getName()); + } + break; + case "jid-del": + // FIXME: stop using ugnich-csv in parameters + String[] params = request.getParameter("delete").split(";", 2); + boolean res = false; + if (params[0].equals("xmpp")) { + res = userService.deleteJID(visitor.getUid(), params[1]); + } else if (params[0].equals("xmpp-unauth")) { + res = userService.unauthJID(visitor.getUid(), params[1]); + } + if (res) { + result = "<p>Deleted. <a href=\"/settings\">Back</a>.</p>"; + } else { + result = "<p>Error</p>"; + } + break; + case "email-add": + if (!emailService.verifyAddressByCode(visitor.getUid(), request.getParameter("account"))) { + String authCode = RandomStringUtils.randomAlphanumeric(8).toUpperCase(); + if (emailService.addVerificationCode(visitor.getUid(), request.getParameter("account"), authCode)) { + Session session = Session.getDefaultInstance(System.getProperties()); + try { + MimeMessage message = new MimeMessage(session); + message.setFrom(new InternetAddress("noreply@mail.juick.com")); + message.addRecipient(Message.RecipientType.TO, new InternetAddress(request.getParameter("account"))); + message.setSubject("Juick authorization link"); + message.setText(String.format("Follow link to attach this email to Juick account:\n" + + "http://juick.com/settings?page=auth-email&code=%s\n\n" + + "If you don't know, what this mean - just ignore this mail.\n", authCode)); + Transport.send(message); + result = "<p>Authorization link has been sent to your email. Follow it to proceed.</p>" + + "<p><a href=\"/settings\">Back</a></p>"; + + } catch (MessagingException ex) { + logger.error("mail exception", ex); + throw new HttpBadRequestException(); + } + } + } + break; + case "email-del": + if (emailService.deleteEmail(visitor.getUid(), request.getParameter("account"))) { + result = "<p>Deleted. <a href=\"/settings\">Back</a>.</p>"; + } else { + result = "<p>An error occured while deleting.</p>"; + } + break; + case "email-subscr": + if (emailService.setNotificationsEmail(visitor.getUid(), request.getParameter("account"))) { + result = String.format("<p>Saved! Will send notifications to <strong>%s</strong>." + + "</p><p><a href=\"/settings\">Back</a></p>", request.getParameter("account")); + } else { + result = "<p>Disabled.</p><p><a href=\"/settings\">Back</a></p>"; + } + break; + case "twitter-del": + crosspostService.deleteTwitterToken(visitor.getUid()); + for (Cookie cookie : request.getCookies()) { + if (cookie.getName().equals("request_token")) { + cookie.setMaxAge(0); + response.addCookie(cookie); + } + if (cookie.getName().equals("request_token_secret")) { + cookie.setMaxAge(0); + response.addCookie(cookie); + } + } + result = "<p><a href=\"/settings\">Back</a></p>"; + break; + case "telegram-del": + telegramService.deleteTelegramUser(visitor.getUid()); + result = "<p><a href=\"/settings\">Back</a></p>"; + break; + case "facebook-disable": + crosspostService.disableFBCrosspost(visitor.getUid()); + result = "<p><a href=\"/settings\">Back</a></p>"; + break; + case "facebook-enable": + crosspostService.enableFBCrosspost(visitor.getUid()); + result = "<p><a href=\"/settings\">Back</a></p>"; + break; + case "vk-del": + crosspostService.deleteVKUser(visitor.getUid()); + result = "<p><a href=\"/settings\">Back</a></p>"; + break; + default: + throw new HttpBadRequestException(); + } + + model.addAttribute("title", "Настройки"); + model.addAttribute("visitor", visitor); + model.addAttribute("result", result); + return "views/settings_result"; + } + @PostMapping("/settings/unsubscribe") + public String unsubscribeOneClick(@RequestParam(name = "List-Unsubscribe") String unsubscribe, + ModelMap model) { + User user = UserUtils.getCurrentUser(); + if (!user.isAnonymous()) { + if (unsubscribe.equals("One-Click")) { + emailService.setNotificationsEmail(user.getUid(), StringUtils.EMPTY); + model.addAttribute("title", "Настройки"); + model.addAttribute("visitor", user); + model.addAttribute("result", "Unsubscribed"); + return "views/settings_result"; + } + } + throw new HttpBadRequestException(); + } +} diff --git a/src/main/java/com/juick/server/www/controllers/SignUp.java b/src/main/java/com/juick/server/www/controllers/SignUp.java new file mode 100644 index 00000000..6a4fe063 --- /dev/null +++ b/src/main/java/com/juick/server/www/controllers/SignUp.java @@ -0,0 +1,172 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ +package com.juick.server.www.controllers; + +import com.juick.server.util.HttpBadRequestException; +import com.juick.server.util.HttpForbiddenException; +import com.juick.server.util.UserUtils; +import com.juick.service.CrosspostService; +import com.juick.service.EmailService; +import com.juick.service.MessengerService; +import com.juick.service.UserService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.ModelMap; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import javax.inject.Inject; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletResponse; + +/** + * + * @author Ugnich Anton + */ +@Controller +public class SignUp { + + @Inject + private UserService userService; + @Inject + private CrosspostService crosspostService; + @Inject + private MessengerService messengerService; + @Inject + private EmailService emailService; + + + @GetMapping("/signup") + protected String doGet(@RequestParam String type, @RequestParam String hash, ModelMap model) { + com.juick.User visitor = UserUtils.getCurrentUser(); + + if (hash.length() > 36 || !type.matches("^[a-zA-Z0-9\\-]+$") + || !hash.matches("^[a-zA-Z0-9\\-]+$")) { + throw new HttpBadRequestException(); + } + + String account = null; + switch (type) { + case "fb": + account = crosspostService.getFacebookNameByHash(hash); + break; + case "vk": + account = crosspostService.getVKNameByHash(hash); + break; + case "xmpp": + account = crosspostService.getJIDByHash(hash); + break; + case "durov": + account = crosspostService.getTelegramNameByHash(hash); + break; + case "messenger": + account = messengerService.getDisplayName(hash); + break; + case "email": + account = emailService.getEmailByAuthCode(hash); + } + if (account == null) { + throw new HttpBadRequestException(); + } + + model.addAttribute("title", "Новый пользователь"); + model.addAttribute("visitor", visitor); + model.addAttribute("account", account); + model.addAttribute("type", type); + model.addAttribute("hash", hash); + return "views/signup"; + } + + @PostMapping("/signup") + protected String doPost( + HttpServletResponse response, + @RequestParam String type, + @RequestParam String hash, + @RequestParam String action, + @RequestParam(required = false) String username, + @RequestParam(required = false) String password) { + com.juick.User visitor = UserUtils.getCurrentUser(); + int uid = 0; + + if (hash.length() > 36 || !type.matches("^[a-zA-Z0-9\\-]+$") || !hash.matches("^[a-zA-Z0-9\\-]+$")) { + throw new HttpBadRequestException(); + } + + if (action.charAt(0) == 'l') { + + if (visitor.isAnonymous()) { + if (username.length() > 32) { + throw new HttpBadRequestException(); + } + uid = userService.checkPassword(username, password); + } else { + uid = visitor.getUid(); + } + + if (uid <= 0) { + throw new HttpForbiddenException(); + } + + if (!(type.charAt(0) == 'f' && crosspostService.setFacebookUser(hash, uid)) + && !(type.charAt(0) == 'v' && crosspostService.setVKUser(hash, uid)) + && !(type.charAt(0) == 'd' && crosspostService.setTelegramUser(hash, uid)) + && !(type.charAt(0) == 'x' && userService.getAllJIDs(visitor).size() > 0 && crosspostService.setJIDUser(hash, uid)) + && !(type.charAt(0) == 'm' && messengerService.linkMessengerUser(hash, uid))) { + if (type.equals("email")) { + String email = emailService.getEmailByAuthCode(hash); + emailService.addEmail(uid, email); + emailService.deleteAuthCode(hash); + } else { + throw new HttpBadRequestException(); + } + } + + } else { // Create new account + if (username.length() < 2 || username.length() > 16 || !username.matches("^[a-zA-Z0-9\\-]+$") || password.length() < 6 || password.length() > 32) { + throw new HttpBadRequestException(); + } + + // CHECK USERNAME + + uid = userService.createUser(username, password); + if (uid <= 0) { + throw new HttpBadRequestException(); + } + + if (!(type.charAt(0) == 'f' && crosspostService.setFacebookUser(hash, uid)) + && !(type.charAt(0) == 'v' && crosspostService.setVKUser(hash, uid)) + && !(type.charAt(0) == 'd' && crosspostService.setTelegramUser(hash, uid)) + && !(type.charAt(0) == 'm' && messengerService.linkMessengerUser(hash, uid))) { + if (type.equals("email")) { + String email = emailService.getEmailByAuthCode(hash); + emailService.addEmail(uid, email); + emailService.deleteAuthCode(hash); + } else { + throw new HttpBadRequestException(); + } + } + } + + if (visitor.isAnonymous()) { + hash = userService.getHashByUID(uid); + Cookie c = new Cookie("hash", hash); + c.setMaxAge(365 * 24 * 60 * 60); + response.addCookie(c); + } + return "redirect:/"; + } +} diff --git a/src/main/java/com/juick/server/www/controllers/SocialLogin.java b/src/main/java/com/juick/server/www/controllers/SocialLogin.java new file mode 100644 index 00000000..bc631a1a --- /dev/null +++ b/src/main/java/com/juick/server/www/controllers/SocialLogin.java @@ -0,0 +1,329 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ +package com.juick.server.www.controllers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.scribejava.apis.FacebookApi; +import com.github.scribejava.apis.TwitterApi; +import com.github.scribejava.apis.VkontakteApi; +import com.github.scribejava.core.builder.ServiceBuilder; +import com.github.scribejava.core.model.*; +import com.github.scribejava.core.oauth.OAuth10aService; +import com.github.scribejava.core.oauth.OAuth20Service; +import com.juick.model.facebook.User; +import com.juick.server.Utils; +import com.juick.server.util.HttpBadRequestException; +import com.juick.server.util.UserUtils; +import com.juick.service.CrosspostService; +import com.juick.service.EmailService; +import com.juick.service.TelegramService; +import com.juick.service.UserService; +import com.juick.model.vk.UsersResponse; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.codec.digest.HmacAlgorithms; +import org.apache.commons.codec.digest.HmacUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.util.UriComponentsBuilder; + +import javax.annotation.PostConstruct; +import javax.inject.Inject; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; + +/** + * + * @author Ugnich Anton + */ +@Controller +public class SocialLogin { + + private static final Logger logger = LoggerFactory.getLogger(SocialLogin.class); + + @Value("${facebook_appid:appid}") + private String FACEBOOK_APPID; + @Value("${facebook_secret:secret}") + private String FACEBOOK_SECRET; + @Value("${ap_base_uri:http://localhost:8080/}") + private String baseUri; + private String facebookRedirectUri; + private static final String VK_REDIRECT = "http://juick.com/_vklogin"; + private static final String TWITTER_VERIFY_URL = "https://api.twitter.com/1.1/account/verify_credentials.json"; + @Inject + private ObjectMapper jsonMapper; + private ServiceBuilder facebookBuilder, twitterBuilder, vkBuilder; + + @Value("${twitter_consumer_key:appid}") + private String twitterConsumerKey; + @Value("${twitter_consumer_secret:secret}") + private String twitterConsumerSecret; + @Value("${vk_appid:appid}") + private String VK_APPID; + @Value("${vk_secret:secret}") + private String VK_SECRET; + @Value("${telegram_token:secret}") + private String telegramToken; + + @Inject + private CrosspostService crosspostService; + @Inject + private UserService userService; + @Inject + private EmailService emailService; + @Inject + private TelegramService telegramService; + + @PostConstruct + public void init() { + facebookBuilder = new ServiceBuilder(FACEBOOK_APPID); + twitterBuilder = new ServiceBuilder(twitterConsumerKey); + vkBuilder = new ServiceBuilder(VK_APPID); + UriComponentsBuilder facebookRedirectBuilder = UriComponentsBuilder.fromUriString(baseUri); + facebookRedirectUri = facebookRedirectBuilder.replacePath("/_fblogin").build().toUriString(); + } + + @GetMapping("/_fblogin") + protected String doFacebookLogin(HttpServletRequest request, + @RequestParam(required = false) String code, + @RequestParam(required = false) String state, + HttpServletResponse response) throws IOException, ExecutionException, InterruptedException { + if (StringUtils.isBlank(code)) { + String fbstate = UUID.randomUUID().toString(); + if (StringUtils.isBlank(state)) { + state = Utils.getPreviousPageByRequest(request).orElse("https://juick.com/"); + } + crosspostService.addFacebookState(fbstate, state); + OAuth20Service facebookAuthService = facebookBuilder + .apiSecret(FACEBOOK_SECRET) + .callback(facebookRedirectUri) + .scope("email") + .state(fbstate) + .build(FacebookApi.instance()); + return "redirect:" + facebookAuthService.getAuthorizationUrl(); + } + + String redirectUrl = crosspostService.verifyFacebookState(state); + if (StringUtils.isEmpty(redirectUrl)) { + logger.error("state is missing"); + throw new HttpBadRequestException(); + } + OAuth20Service facebookService = facebookBuilder + .apiKey(FACEBOOK_APPID) + .apiSecret(FACEBOOK_SECRET) + .callback(facebookRedirectUri) + .scope("email") + .state(state) + .build(FacebookApi.instance()); + OAuth2AccessToken token = facebookService.getAccessToken(code); + final OAuthRequest meRequest = new OAuthRequest(Verb.GET, "https://graph.facebook.com/v2.10/me?fields=id,name,link,verified,email"); + facebookService.signRequest(token, meRequest); + String graph = facebookService.execute(meRequest).getBody(); + if (StringUtils.isBlank(graph)) { + logger.error("FACEBOOK GRAPH ERROR"); + throw new HttpBadRequestException(); + } + User fb = jsonMapper.readValue(graph, User.class); + long fbID = NumberUtils.toLong(fb.getId(), 0); + if (fbID == 0 || StringUtils.isBlank(fb.getName()) || StringUtils.isBlank(fb.getLink())) { + logger.error("Missing required fields, id: {}, name: {}, link: {}", fbID, fb.getName(), fb.getLink()); + throw new HttpBadRequestException(); + } + + int uid = crosspostService.getUIDbyFBID(fbID); + if (uid > 0) { + if (!crosspostService.updateFacebookUser(fbID, token.getAccessToken(), fb.getName(), fb.getLink())) { + logger.error("error updating facebook user, id: {}, token: {}", fbID, token.getAccessToken()); + throw new HttpBadRequestException(); + } + Cookie c = new Cookie("hash", userService.getHashByUID(uid)); + c.setMaxAge(50 * 24 * 60 * 60); + response.addCookie(c); + return "redirect:" + redirectUrl; + } else if (fb.getVerified()) { + if (!crosspostService.createFacebookUser(fbID, state, token.getAccessToken(), fb.getName(), fb.getLink())) { + if (StringUtils.isNotEmpty(fb.getEmail())) { + logger.info("found {} for facebook user {}", fb.getEmail(), fb.getLink()); + Integer userId = crosspostService.getUIDbyFBID(fbID); + if (!emailService.getEmails(userId, false).contains(fb.getEmail())) { + emailService.addEmail(userId, fb.getEmail()); + } + } + logger.info("email not found for facebook user {}", fb.getLink()); + throw new HttpBadRequestException(); + } + return "redirect:/signup?type=fb&hash=" + state; + } else { + logger.error("Facebook account is not verified, id: {}", fbID); + throw new HttpBadRequestException(); + } + } + @GetMapping("/_twitter") + protected void doTwitterLogin(HttpServletRequest request, HttpServletResponse response) + throws IOException, ExecutionException, InterruptedException { + String hash = StringUtils.EMPTY, request_token = StringUtils.EMPTY, request_token_secret = StringUtils.EMPTY; + String verifier = request.getParameter("oauth_verifier"); + Cookie[] cookies = request.getCookies(); + for (Cookie cookie : cookies) { + if (cookie.getName().equals("hash")) { + hash = cookie.getValue(); + } + if (cookie.getName().equals("request_token")) { + request_token = cookie.getValue(); + } + if (cookie.getName().equals("request_token_secret")) { + request_token_secret = cookie.getValue(); + } + } + com.juick.User user = UserUtils.getCurrentUser(); + OAuth10aService oAuthService = twitterBuilder + .apiSecret(twitterConsumerSecret) + .callback("https://juick.com/_twitter") + .build(TwitterApi.instance()); + + if (request_token.isEmpty() && request_token_secret.isEmpty() + && (verifier == null || verifier.isEmpty())) { + OAuth1RequestToken requestToken = oAuthService.getRequestToken(); + String authUrl = oAuthService.getAuthorizationUrl(requestToken); + response.addCookie(new Cookie("request_token", requestToken.getToken())); + response.addCookie(new Cookie("request_token_secret", requestToken.getTokenSecret())); + response.setStatus(HttpServletResponse.SC_FOUND); + response.setHeader("Location", authUrl); + } else { + if (verifier != null && verifier.length() > 0) { + OAuth1RequestToken requestToken = new OAuth1RequestToken(request_token, request_token_secret); + OAuth1AccessToken accessToken = oAuthService.getAccessToken(requestToken, verifier); + OAuthRequest oAuthRequest = new OAuthRequest(Verb.GET, TWITTER_VERIFY_URL); + oAuthService.signRequest(accessToken, oAuthRequest); + com.juick.model.twitter.User twitterUser = jsonMapper.readValue(oAuthService.execute(oAuthRequest).getBody(), + com.juick.model.twitter.User.class); + if (userService.linkTwitterAccount(user, accessToken.getToken(), accessToken.getTokenSecret(), + twitterUser.getScreenName())) { + response.setStatus(HttpServletResponse.SC_FOUND); + response.setHeader("Location", "http://juick.com/settings"); + } else { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + } + } + } + @GetMapping("/_vklogin") + protected String doVKLogin(HttpServletRequest request, + @RequestParam(required = false) String code, + @RequestParam(required = false) String state, + @CookieValue(required = false) String vkstate, + HttpServletResponse response) throws IOException, ExecutionException, InterruptedException { + if (StringUtils.isBlank(code)) { + vkstate = UUID.randomUUID().toString(); + Cookie c = new Cookie("vkstate", vkstate); + response.addCookie(c); + OAuth20Service vkAuthService = vkBuilder + .apiSecret(VK_SECRET) + .scope("friends,wall,offline") + .state(vkstate) + .callback(VK_REDIRECT) + .build(VkontakteApi.instance()); + return "redirect:" + vkAuthService.getAuthorizationUrl(); + } + + if (StringUtils.isBlank(vkstate) || !vkstate.equals(state)) { + throw new HttpBadRequestException(); + } else { + Cookie c = new Cookie("vkstate", "-"); + c.setMaxAge(0); + response.addCookie(c); + } + + OAuth20Service vkService = vkBuilder + .apiKey(VK_APPID) + .apiSecret(VK_SECRET) + .build(VkontakteApi.instance()); + OAuth2AccessToken token = vkService.getAccessToken(code); + + OAuthRequest meRequest = new OAuthRequest(Verb.GET, "https://api.vk.com/method/users.get?fields=screen_name&v=5.73"); + vkService.signRequest(token, meRequest); + String graph = vkService.execute(meRequest).getBody(); + + com.juick.model.vk.User jsonUser = jsonMapper.readValue(graph, UsersResponse.class).getUsers().get(0); + String vkName = jsonUser.getFirstName() + " " + jsonUser.getLastName(); + String vkLink = jsonUser.getScreenName(); + + if (vkName.length() == 1 || StringUtils.isBlank(vkLink)) { + logger.error("vk user error"); + throw new HttpBadRequestException(); + } + + Long vkID = NumberUtils.toLong(jsonUser.getId(), 0); + int uid = crosspostService.getUIDbyVKID(vkID); + if (uid > 0) { + Cookie c = new Cookie("hash", userService.getHashByUID(uid)); + c.setMaxAge(50 * 24 * 60 * 60); + response.addCookie(c); + return "redirect:/" + Utils.getPreviousPageByRequest(request).orElse(StringUtils.EMPTY); + } else { + String loginhash = UUID.randomUUID().toString(); + if (!crosspostService.createVKUser(vkID, loginhash, token.getAccessToken(), vkName, vkLink)) { + logger.error("create vk user error"); + throw new HttpBadRequestException(); + } + return "redirect:/signup?type=vk&hash=" + loginhash; + } + } + + @GetMapping("/_tglogin") + public String doDurovLogin(HttpServletRequest request, + @RequestParam Map<String, String> params, + HttpServletResponse response) { + String dataCheckString = params.entrySet().stream() + .filter(p -> !p.getKey().equals("hash")) + .sorted(Map.Entry.comparingByKey()) + .map(p -> p.getKey() + "=" + p.getValue()) + .collect(Collectors.joining("\n")); + String hash = params.get("hash"); + byte[] secretKey = DigestUtils.sha256(telegramToken); + String resultString = new HmacUtils(HmacAlgorithms.HMAC_SHA_256, secretKey).hmacHex(dataCheckString); + if (hash.equals(resultString)) { + Long tgUser = Long.valueOf(params.get("id")); + int uid = telegramService.getUser(tgUser); + if (uid > 0) { + Cookie c = new Cookie("hash", userService.getHashByUID(uid)); + c.setMaxAge(50 * 24 * 60 * 60); + response.addCookie(c); + return "redirect:/" + Utils.getPreviousPageByRequest(request).orElse(StringUtils.EMPTY); + } else { + String username = StringUtils.defaultString(params.get("username"), params.get("first_name")); + telegramService.createTelegramUser(tgUser, username); + return "redirect:/signup?type=durov&hash=" + userService.getSignUpHashByTelegramID(tgUser, username); + } + } else { + logger.warn("invalid tg hash {} for {}", resultString, hash); + } + throw new HttpBadRequestException(); + } +} diff --git a/src/main/java/com/juick/server/xmpp/JidConverter.java b/src/main/java/com/juick/server/xmpp/JidConverter.java new file mode 100644 index 00000000..e9a9707e --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/JidConverter.java @@ -0,0 +1,13 @@ +package com.juick.server.xmpp; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.lang.Nullable; +import rocks.xmpp.addr.Jid; + +public class JidConverter implements Converter<String, Jid> { + @Nullable + @Override + public Jid convert(String jidStr) { + return Jid.of(jidStr); + } +} diff --git a/src/main/java/com/juick/server/xmpp/XMPPStatusPage.java b/src/main/java/com/juick/server/xmpp/XMPPStatusPage.java new file mode 100644 index 00000000..231696ec --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/XMPPStatusPage.java @@ -0,0 +1,32 @@ +package com.juick.server.xmpp; + +import com.juick.server.XMPPServer; +import com.juick.server.xmpp.helpers.XMPPStatus; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; +import rocks.xmpp.addr.Jid; +import springfox.documentation.annotations.ApiIgnore; + +import javax.inject.Inject; +import java.util.stream.Collectors; + +@RestController +@ConditionalOnProperty("xmppbot_jid") +public class XMPPStatusPage { + @Inject + private XMPPServer xmpp; + @ApiIgnore + @RequestMapping(method = RequestMethod.GET, value = "/api/xmpp-status", produces = MediaType.APPLICATION_JSON_UTF8_VALUE) + public XMPPStatus xmppStatus() { + XMPPStatus status = new XMPPStatus(); + if (xmpp != null) { + status.setInbound(xmpp.getInConnections().stream().map(c -> c.from).flatMap(j -> j.stream().map(Jid::getDomain)).collect(Collectors.toList())); + status.setOutbound(xmpp.getOutConnections().keySet().stream() + .map(c -> c.to).map(Jid::getDomain).collect(Collectors.toList())); + } + return status; + } +} diff --git a/src/main/java/com/juick/server/xmpp/helpers/XMPPStatus.java b/src/main/java/com/juick/server/xmpp/helpers/XMPPStatus.java new file mode 100644 index 00000000..99d89866 --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/helpers/XMPPStatus.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.xmpp.helpers; + +import com.juick.server.xmpp.s2s.ConnectionIn; +import com.juick.server.xmpp.s2s.ConnectionOut; + +import java.util.List; +import java.util.Set; + +/** + * Created by vitalyster on 16.02.2017. + */ +public class XMPPStatus { + private List<String> inbound; + private List<String> outbound; + + public List<String> getInbound() { + return inbound; + } + + public void setInbound(List<String> inbound) { + this.inbound = inbound; + } + + public List<String> getOutbound() { + return outbound; + } + + public void setOutbound(List<String> outbound) { + this.outbound = outbound; + } +} diff --git a/src/main/java/com/juick/server/xmpp/iq/MessageQuery.java b/src/main/java/com/juick/server/xmpp/iq/MessageQuery.java new file mode 100644 index 00000000..7500cbf8 --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/iq/MessageQuery.java @@ -0,0 +1,10 @@ +package com.juick.server.xmpp.iq; + +import javax.xml.bind.annotation.XmlRootElement; + +@XmlRootElement(name = "query") +public class MessageQuery { + private MessageQuery() { + + } +} diff --git a/src/main/java/com/juick/server/xmpp/iq/package-info.java b/src/main/java/com/juick/server/xmpp/iq/package-info.java new file mode 100644 index 00000000..dada8289 --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/iq/package-info.java @@ -0,0 +1,8 @@ +@XmlAccessorType(XmlAccessType.FIELD) +@XmlSchema(namespace = "http://juick.com/query#messages", elementFormDefault = XmlNsForm.QUALIFIED) +package com.juick.server.xmpp.iq; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlNsForm; +import javax.xml.bind.annotation.XmlSchema;
\ No newline at end of file diff --git a/src/main/java/com/juick/server/xmpp/router/Handshake.java b/src/main/java/com/juick/server/xmpp/router/Handshake.java new file mode 100644 index 00000000..0bc501dd --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/router/Handshake.java @@ -0,0 +1,39 @@ +package com.juick.server.xmpp.router; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; + +/** + * Created by vitalyster on 30.01.2017. + */ +public class Handshake { + private String value; + + public static Handshake parse(XmlPullParser parser) throws IOException, XmlPullParserException { + parser.next(); + Handshake handshake = new Handshake(); + handshake.setValue(XmlUtils.getTagText(parser)); + return handshake; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public String toString() { + StringBuilder str = new StringBuilder("<handshake"); + if (getValue() != null) { + str.append(">").append(getValue()).append("</handshake>"); + } else { + str.append("/>"); + } + return str.toString(); + } +} diff --git a/src/main/java/com/juick/server/xmpp/router/Stream.java b/src/main/java/com/juick/server/xmpp/router/Stream.java new file mode 100644 index 00000000..2154edf6 --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/router/Stream.java @@ -0,0 +1,202 @@ +/* + * Juick + * Copyright (C) 2008-2011, Ugnich Anton + * + * 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 <http://www.gnu.org/licenses/>. + */ +package com.juick.server.xmpp.router; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlPullParserFactory; +import rocks.xmpp.addr.Jid; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.UUID; + +/** + * + * @author Ugnich Anton + */ +public abstract class Stream { + + public boolean isLoggedIn() { + return loggedIn; + } + + public void setLoggedIn(boolean loggedIn) { + this.loggedIn = loggedIn; + } + + public Jid from; + public Jid to; + private InputStream is; + private OutputStream os; + private XmlPullParserFactory factory; + protected XmlPullParser parser; + private OutputStreamWriter writer; + StreamHandler streamHandler; + private boolean loggedIn; + private Instant created; + private Instant updated; + String streamId; + private boolean secured; + + public Stream(final Jid from, final Jid to, final InputStream is, final OutputStream os) throws XmlPullParserException { + this.from = from; + this.to = to; + this.is = is; + this.os = os; + factory = XmlPullParserFactory.newInstance(); + created = updated = Instant.now(); + streamId = UUID.randomUUID().toString(); + } + + public void restartStream() throws XmlPullParserException { + parser = factory.newPullParser(); + parser.setInput(new InputStreamReader(is, StandardCharsets.UTF_8)); + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true); + writer = new OutputStreamWriter(os, StandardCharsets.UTF_8); + } + + public void connect() { + try { + restartStream(); + handshake(); + parse(); + } catch (XmlPullParserException e) { + StreamError invalidXmlError = new StreamError("invalid-xml"); + send(invalidXmlError.toString()); + connectionFailed(new Exception(invalidXmlError.getCondition())); + } catch (IOException e) { + connectionFailed(e); + } + } + + public void setHandler(final StreamHandler streamHandler) { + this.streamHandler = streamHandler; + } + + public abstract void handshake() throws XmlPullParserException, IOException; + + public void logoff() { + setLoggedIn(false); + try { + writer.flush(); + writer.close(); + //TODO close parser + } catch (final Exception e) { + connectionFailed(e); + } + } + + public void send(final String str) { + try { + updated = Instant.now(); + writer.write(str); + writer.flush(); + } catch (final Exception e) { + connectionFailed(e); + } + } + + private void parse() throws IOException, XmlPullParserException { + while (parser.next() != XmlPullParser.END_DOCUMENT) { + if (parser.getEventType() == XmlPullParser.IGNORABLE_WHITESPACE) { + setUpdated(); + } + if (parser.getEventType() != XmlPullParser.START_TAG) { + continue; + } + setUpdated(); + final String tag = parser.getName(); + switch (tag) { + case "message": + case "presence": + case "iq": + streamHandler.stanzaReceived(XmlUtils.parseToString(parser, false)); + break; + case "error": + StreamError error = StreamError.parse(parser); + connectionFailed(new Exception(error.getCondition())); + return; + default: + XmlUtils.skip(parser); + break; + } + } + } + + /** + * This method is used to be called on a parser or a connection error. + * It tries to close the XML-Reader and XML-Writer one last time. + */ + private void connectionFailed(final Exception ex) { + if (isLoggedIn()) { + try { + writer.close(); + //TODO close parser + } catch (Exception e) { + } + } + if (streamHandler != null) { + streamHandler.fail(ex); + } + } + + public Instant getCreated() { + return created; + } + + public Instant getUpdated() { + return updated; + } + public String getStreamId() { + return streamId; + } + + public boolean isSecured() { + return secured; + } + + public void setSecured(boolean secured) { + this.secured = secured; + } + + public void setUpdated() { + this.updated = Instant.now(); + } + + public InputStream getInputStream() { + return is; + } + + public void setInputStream(InputStream is) { + this.is = is; + } + + public OutputStream getOutputStream() { + return os; + } + + public void setOutputStream(OutputStream os) { + this.os = os; + } +} diff --git a/src/main/java/com/juick/server/xmpp/router/StreamComponentServer.java b/src/main/java/com/juick/server/xmpp/router/StreamComponentServer.java new file mode 100644 index 00000000..a58adfc5 --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/router/StreamComponentServer.java @@ -0,0 +1,57 @@ +package com.juick.server.xmpp.router; + +import org.apache.commons.codec.digest.DigestUtils; +import org.xmlpull.v1.XmlPullParserException; +import rocks.xmpp.addr.Jid; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.UUID; + +/** + * Created by vitalyster on 30.01.2017. + */ +public class StreamComponentServer extends Stream { + + private String streamId, secret; + + public String getStreamId() { + return streamId; + } + + + public StreamComponentServer(InputStream is, OutputStream os, String password) throws XmlPullParserException { + super(null, null, is, os); + secret = password; + streamId = UUID.randomUUID().toString(); + } + @Override + public void handshake() throws XmlPullParserException, IOException { + parser.next(); + if (!parser.getName().equals("stream") + || !parser.getNamespace(null).equals(StreamNamespaces.NS_COMPONENT_ACCEPT) + || !parser.getNamespace("stream").equals(StreamNamespaces.NS_STREAM)) { + throw new IOException("invalid stream"); + } + Jid domain = Jid.of(parser.getAttributeValue(null, "to")); + if (streamHandler.filter(null, domain)) { + send(new XMPPError(XMPPError.Type.cancel, "forbidden").toString()); + throw new IOException("invalid domain"); + } + from = domain; + to = domain; + send(String.format("<stream:stream xmlns:stream='%s' " + + "xmlns='%s' from='%s' id='%s'>", StreamNamespaces.NS_STREAM, StreamNamespaces.NS_COMPONENT_ACCEPT, from.asBareJid().toEscapedString(), streamId)); + Handshake handshake = Handshake.parse(parser); + boolean authenticated = handshake.getValue().equals(DigestUtils.sha1Hex(streamId + secret)); + setLoggedIn(authenticated); + if (!authenticated) { + send(new XMPPError(XMPPError.Type.cancel, "not-authorized").toString()); + streamHandler.fail(new IOException("stream:stream, failed authentication")); + return; + } + send(new Handshake().toString()); + streamHandler.ready(this); + } +} diff --git a/src/main/java/com/juick/server/xmpp/router/StreamError.java b/src/main/java/com/juick/server/xmpp/router/StreamError.java new file mode 100644 index 00000000..f731f039 --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/router/StreamError.java @@ -0,0 +1,57 @@ +package com.juick.server.xmpp.router; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; + + +/** + * Created by vitalyster on 03.02.2017. + */ +public class StreamError { + + private String condition; + private String text; + + public StreamError() {} + + public StreamError(String condition) { + this.condition = condition; + } + + public static StreamError parse(XmlPullParser parser) throws IOException, XmlPullParserException { + StreamError streamError = new StreamError(); + final int initial = parser.getDepth(); + while (true) { + int eventType = parser.next(); + if (eventType == XmlPullParser.START_TAG && parser.getDepth() == initial + 1) { + final String tag = parser.getName(); + final String xmlns = parser.getNamespace(); + if (tag.equals("text") && xmlns.equals(StreamNamespaces.NS_XMPP_STREAMS)) { + streamError.text = XmlUtils.getTagText(parser); + } else if (xmlns.equals(StreamNamespaces.NS_XMPP_STREAMS)) { + streamError.condition = tag; + } else { + XmlUtils.skip(parser); + } + } else if (eventType == XmlPullParser.END_TAG && parser.getDepth() == initial) { + break; + } + } + return streamError; + } + + public String getCondition() { + return condition; + } + + @Override + public String toString() { + return String.format("<stream:error><%s xmlns='%s'/></stream:error>", condition, StreamNamespaces.NS_XMPP_STREAMS); + } + + public String getText() { + return text; + } +} diff --git a/src/main/java/com/juick/server/xmpp/router/StreamFeatures.java b/src/main/java/com/juick/server/xmpp/router/StreamFeatures.java new file mode 100644 index 00000000..e8fc324f --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/router/StreamFeatures.java @@ -0,0 +1,95 @@ +/* + * Juick + * Copyright (C) 2008-2013, Ugnich Anton + * + * 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 <http://www.gnu.org/licenses/>. + */ +package com.juick.server.xmpp.router; + +import java.io.IOException; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +/** + * + * @author Ugnich Anton + */ +public class StreamFeatures { + + public static final int NOTAVAILABLE = -1; + public static final int AVAILABLE = 0; + public static final int REQUIRED = 1; + public int STARTTLS = NOTAVAILABLE; + public int ZLIB = NOTAVAILABLE; + public int PLAIN = NOTAVAILABLE; + public int DIGEST_MD5 = NOTAVAILABLE; + public int REGISTER = NOTAVAILABLE; + public int EXTERNAL = NOTAVAILABLE; + + public static StreamFeatures parse(final XmlPullParser parser) throws XmlPullParserException, IOException { + StreamFeatures features = new StreamFeatures(); + final int initial = parser.getDepth(); + while (true) { + int eventType = parser.next(); + if (eventType == XmlPullParser.START_TAG && parser.getDepth() == initial + 1) { + final String tag = parser.getName(); + final String xmlns = parser.getNamespace(); + if (tag.equals("starttls") && xmlns != null && xmlns.equals("urn:ietf:params:xml:ns:xmpp-tls")) { + features.STARTTLS = AVAILABLE; + while (parser.next() == XmlPullParser.START_TAG) { + if (parser.getName().equals("required")) { + features.STARTTLS = REQUIRED; + } else { + XmlUtils.skip(parser); + } + } + } else if (tag.equals("compression") && xmlns != null && xmlns.equals("http://jabber.org/features/compress")) { + while (parser.next() == XmlPullParser.START_TAG) { + if (parser.getName().equals("method")) { + final String method = XmlUtils.getTagText(parser).toUpperCase(); + if (method.equals("ZLIB")) { + features.ZLIB = AVAILABLE; + } + } else { + XmlUtils.skip(parser); + } + } + } else if (tag.equals("mechanisms") && xmlns != null && xmlns.equals("urn:ietf:params:xml:ns:xmpp-sasl")) { + while (parser.next() == XmlPullParser.START_TAG) { + if (parser.getName().equals("mechanism")) { + final String mechanism = XmlUtils.getTagText(parser).toUpperCase(); + if (mechanism.equals("PLAIN")) { + features.PLAIN = AVAILABLE; + } else if (mechanism.equals("DIGEST-MD5")) { + features.DIGEST_MD5 = AVAILABLE; + } else if (mechanism.equals("EXTERNAL")) { + features.EXTERNAL = AVAILABLE; + } + } else { + XmlUtils.skip(parser); + } + } + } else if (tag.equals("register") && xmlns != null && xmlns.equals("http://jabber.org/features/iq-register")) { + features.REGISTER = AVAILABLE; + XmlUtils.skip(parser); + } else { + XmlUtils.skip(parser); + } + } else if (eventType == XmlPullParser.END_TAG && parser.getDepth() == initial) { + break; + } + } + return features; + } +} diff --git a/src/main/java/com/juick/server/xmpp/router/StreamHandler.java b/src/main/java/com/juick/server/xmpp/router/StreamHandler.java new file mode 100644 index 00000000..048c61ec --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/router/StreamHandler.java @@ -0,0 +1,13 @@ +package com.juick.server.xmpp.router; + +import rocks.xmpp.addr.Jid; + +/** + * Created by vitalyster on 01.02.2017. + */ +public interface StreamHandler { + void ready(StreamComponentServer componentServer); + void fail(final Exception ex); + boolean filter(Jid from, Jid to); + void stanzaReceived(String stanza); +} diff --git a/src/main/java/com/juick/server/xmpp/router/StreamNamespaces.java b/src/main/java/com/juick/server/xmpp/router/StreamNamespaces.java new file mode 100644 index 00000000..1b9b1965 --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/router/StreamNamespaces.java @@ -0,0 +1,10 @@ +package com.juick.server.xmpp.router; + +public class StreamNamespaces { + public static final String NS_STREAM = "http://etherx.jabber.org/streams"; + public static final String NS_TLS = "urn:ietf:params:xml:ns:xmpp-tls"; + public static final String NS_DB = "jabber:server:dialback"; + public static final String NS_SERVER = "jabber:server"; + public static final String NS_COMPONENT_ACCEPT = "jabber:component:accept"; + public static final String NS_XMPP_STREAMS = "urn:ietf:params:xml:ns:xmpp-streams"; +} diff --git a/src/main/java/com/juick/server/xmpp/router/XMPPError.java b/src/main/java/com/juick/server/xmpp/router/XMPPError.java new file mode 100644 index 00000000..0cf9a3bc --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/router/XMPPError.java @@ -0,0 +1,73 @@ +/* + * Juick + * Copyright (C) 2008-2013, ugnich + * + * 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 <http://www.gnu.org/licenses/>. + */ +package com.juick.server.xmpp.router; + +import org.apache.commons.text.StringEscapeUtils; + +/** + * + * @author ugnich + */ +public class XMPPError { + + public static final class Type { + + public static final String auth = "auth"; + public static final String cancel = "cancel"; + public static final String continue_ = "continue"; + public static final String modify = "modify"; + public static final String wait = "wait"; + } + private final static String TagName = "error"; + public String by = null; + private String type; + private String condition; + private String text = null; + + public XMPPError(String type, String condition) { + this.type = type; + this.condition = condition; + } + + @Override + public String toString() { + StringBuilder str = new StringBuilder("<").append(TagName).append(""); + if (by != null) { + str.append(" by=\"").append(StringEscapeUtils.escapeXml10(by)).append("\""); + } + if (type != null) { + str.append(" type=\"").append(StringEscapeUtils.escapeXml10(type)).append("\""); + } + + if (condition != null) { + str.append(">"); + str.append("<").append(StringEscapeUtils.escapeXml10(condition)).append(" xmlns=\"urn:ietf:params:xml:ns:xmpp-stanzas\""); + if (text != null) { + str.append(">").append(StringEscapeUtils.escapeXml10(text)).append("</").append(StringEscapeUtils.escapeXml10(condition)) + .append(">"); + } else { + str.append("/>"); + } + str.append("</").append(TagName).append(">"); + } else { + str.append("/>"); + } + + return str.toString(); + } +} diff --git a/src/main/java/com/juick/server/xmpp/router/XMPPRouter.java b/src/main/java/com/juick/server/xmpp/router/XMPPRouter.java new file mode 100644 index 00000000..6d67fa9c --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/router/XMPPRouter.java @@ -0,0 +1,220 @@ +package com.juick.server.xmpp.router; + +import com.juick.server.XMPPServer; +import com.juick.server.xmpp.s2s.BasicXmppSession; +import com.juick.server.xmpp.s2s.CacheEntry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.xmlpull.v1.XmlPullParserException; +import rocks.xmpp.addr.Jid; +import rocks.xmpp.core.stanza.model.IQ; +import rocks.xmpp.core.stanza.model.Message; +import rocks.xmpp.core.stanza.model.Presence; +import rocks.xmpp.core.stanza.model.Stanza; +import rocks.xmpp.core.stanza.model.server.ServerIQ; +import rocks.xmpp.core.stanza.model.server.ServerMessage; +import rocks.xmpp.core.stanza.model.server.ServerPresence; +import rocks.xmpp.util.XmppUtils; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import javax.inject.Inject; +import javax.xml.bind.JAXBException; +import javax.xml.bind.Unmarshaller; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamWriter; +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; + +public class XMPPRouter implements StreamHandler { + private static final Logger logger = LoggerFactory.getLogger("com.juick.server.xmpp"); + + @Inject + private ExecutorService service; + + private final List<StreamComponentServer> connections = Collections.synchronizedList(new ArrayList<>()); + private final List<CacheEntry> outCache = new CopyOnWriteArrayList<>(); + + private ServerSocket listener; + + @Inject + private BasicXmppSession session; + + @Value("${router_port:5347}") + private int routerPort; + + @Inject + private XMPPServer xmppServer; + + @PostConstruct + public void init() { + logger.info("component router initialized"); + service.submit(() -> { + try { + listener = new ServerSocket(routerPort); + logger.info("component router listening on {}", routerPort); + while (!listener.isClosed()) { + if (Thread.currentThread().isInterrupted()) break; + Socket socket = listener.accept(); + service.submit(() -> { + try { + StreamComponentServer client = new StreamComponentServer(socket.getInputStream(), socket.getOutputStream(), "secret"); + addConnectionIn(client); + client.setHandler(this); + client.connect(); + } catch (IOException e) { + logger.error("component error", e); + } catch (XmlPullParserException e) { + e.printStackTrace(); + } + }); + } + } catch (SocketException e) { + // shutdown + } catch (IOException e) { + logger.warn("io exception", e); + } + }); + } + + @PreDestroy + public void close() throws Exception { + if (!listener.isClosed()) { + listener.close(); + } + synchronized (getConnections()) { + for (Iterator<StreamComponentServer> i = getConnections().iterator(); i.hasNext(); ) { + StreamComponentServer c = i.next(); + c.logoff(); + i.remove(); + } + } + service.shutdown(); + logger.info("XMPP router destroyed"); + } + + private void addConnectionIn(StreamComponentServer c) { + synchronized (getConnections()) { + getConnections().add(c); + } + } + + private void sendOut(Stanza s) { + try { + StringWriter stanzaWriter = new StringWriter(); + XMLStreamWriter xmppStreamWriter = XmppUtils.createXmppStreamWriter( + session.getConfiguration().getXmlOutputFactory().createXMLStreamWriter(stanzaWriter)); + session.createMarshaller().marshal(s, xmppStreamWriter); + xmppStreamWriter.flush(); + xmppStreamWriter.close(); + String xml = stanzaWriter.toString(); + logger.info("XMPPRouter (out): {}", xml); + sendOut(s.getTo().getDomain(), xml); + } catch (XMLStreamException | JAXBException e1) { + logger.info("jaxb exception", e1); + } + } + + private void sendOut(String hostname, String xml) { + boolean haveAnyConn = false; + + StreamComponentServer connOut = null; + synchronized (getConnections()) { + for (StreamComponentServer c : getConnections()) { + if (c.to != null && c.to.getDomain().equals(hostname)) { + if (c.isLoggedIn()) { + connOut = c; + break; + } else { + logger.info("bouncing stanza to {} component until it will be ready", hostname); + boolean haveCache = false; + for (CacheEntry entry : outCache) { + if (entry.hostname != null && entry.hostname.equals(hostname)) { + entry.xml += xml; + entry.updated = Instant.now(); + haveCache = true; + break; + } + } + if (!haveCache) { + outCache.add(new CacheEntry(Jid.of(hostname), xml)); + } + } + } + } + } + if (connOut != null) { + connOut.send(xml); + return; + } + xmppServer.sendOut(Jid.of(hostname), xml); + + } + + public List<StreamComponentServer> getConnections() { + return connections; + } + + private Stanza parse(String xml) { + try { + Unmarshaller unmarshaller = session.createUnmarshaller(); + return (Stanza)unmarshaller.unmarshal(new StringReader(xml)); + } catch (JAXBException e) { + logger.error("JAXB exception", e); + } + return null; + } + @Override + public void stanzaReceived(String stanza) { + Stanza input = parse(stanza); + if (input instanceof Message) { + sendOut(ServerMessage.from((Message)input)); + } else if (input instanceof IQ) { + sendOut(ServerIQ.from((IQ)input)); + } else { + sendOut(ServerPresence.from((Presence) input)); + } + } + + public String getFromCache(Jid to) { + final String[] cache = new String[1]; + outCache.stream().filter(c -> c.hostname != null && c.hostname.equals(to)).findFirst().ifPresent(c -> { + cache[0] = c.xml; + outCache.remove(c); + }); + return cache[0]; + } + + @Override + public void ready(StreamComponentServer componentServer) { + logger.info("component {} ready", componentServer.to); + String cache = getFromCache(componentServer.to); + if (cache != null) { + logger.debug("sending cache to {}", componentServer.to); + componentServer.send(cache); + } + } + + @Override + public void fail(Exception e) { + + } + + @Override + public boolean filter(Jid jid, Jid jid1) { + return false; + } +}
\ No newline at end of file diff --git a/src/main/java/com/juick/server/xmpp/router/XmlUtils.java b/src/main/java/com/juick/server/xmpp/router/XmlUtils.java new file mode 100644 index 00000000..7579489f --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/router/XmlUtils.java @@ -0,0 +1,88 @@ +/* + * Juick + * Copyright (C) 2008-2011, Ugnich Anton + * + * 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 <http://www.gnu.org/licenses/>. + */ +package com.juick.server.xmpp.router; + +import java.io.IOException; + +import org.apache.commons.text.StringEscapeUtils; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +/** + * + * @author Ugnich Anton + */ +public class XmlUtils { + + public static void skip(XmlPullParser parser) throws XmlPullParserException, IOException { + String tag = parser.getName(); + while (parser.getName() != null && !(parser.next() == XmlPullParser.END_TAG && parser.getName().equals(tag))) { + } + } + + public static String getTagText(XmlPullParser parser) throws XmlPullParserException, IOException { + String ret = ""; + String tag = parser.getName(); + + if (parser.next() == XmlPullParser.TEXT) { + ret = parser.getText(); + } + + while (!(parser.getEventType() == XmlPullParser.END_TAG && parser.getName().equals(tag))) { + parser.next(); + } + + return ret; + } + + public static String parseToString(XmlPullParser parser, boolean skipXMLNS) throws XmlPullParserException, IOException { + String tag = parser.getName(); + StringBuilder ret = new StringBuilder("<").append(tag); + + // skipXMLNS for xmlns="jabber:client" + + String ns = parser.getNamespace(); + if (!skipXMLNS && ns != null && !ns.isEmpty()) { + ret.append(" xmlns=\"").append(ns).append("\""); + } + + for (int i = 0; i < parser.getAttributeCount(); i++) { + String attr = parser.getAttributeName(i); + if ((!skipXMLNS || !attr.equals("xmlns")) && !attr.contains(":")) { + ret.append(" ").append(attr).append("=\"").append(StringEscapeUtils.escapeXml10(parser.getAttributeValue(i))).append("\""); + } + } + ret.append(">"); + + while (!(parser.next() == XmlPullParser.END_TAG && parser.getName().equals(tag))) { + int event = parser.getEventType(); + if (event == XmlPullParser.START_TAG) { + if (!parser.getName().contains(":")) { + ret.append(parseToString(parser, false)); + } else { + skip(parser); + } + } else if (event == XmlPullParser.TEXT) { + ret.append(StringEscapeUtils.escapeXml10(parser.getText())); + } + } + + ret.append("</").append(tag).append(">"); + return ret.toString(); + } +} diff --git a/src/main/java/com/juick/server/xmpp/s2s/BasicXmppSession.java b/src/main/java/com/juick/server/xmpp/s2s/BasicXmppSession.java new file mode 100644 index 00000000..ae28f827 --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/s2s/BasicXmppSession.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.xmpp.s2s; + +import rocks.xmpp.addr.Jid; +import rocks.xmpp.core.XmppException; +import rocks.xmpp.core.session.XmppSession; +import rocks.xmpp.core.session.XmppSessionConfiguration; +import rocks.xmpp.core.stanza.model.IQ; +import rocks.xmpp.core.stanza.model.Message; +import rocks.xmpp.core.stanza.model.Presence; +import rocks.xmpp.core.stanza.model.server.ServerIQ; +import rocks.xmpp.core.stanza.model.server.ServerMessage; +import rocks.xmpp.core.stanza.model.server.ServerPresence; +import rocks.xmpp.core.stream.model.StreamElement; + +/** + * Created by vitalyster on 06.02.2017. + */ +public class BasicXmppSession extends XmppSession { + protected BasicXmppSession(String xmppServiceDomain, XmppSessionConfiguration configuration) { + super(xmppServiceDomain, configuration); + } + + public static BasicXmppSession create(String xmppServiceDomain, XmppSessionConfiguration configuration) { + BasicXmppSession session = new BasicXmppSession(xmppServiceDomain, configuration); + notifyCreationListeners(session); + return session; + } + + @Override + public void connect(Jid from) throws XmppException { + + } + + @Override + public Jid getConnectedResource() { + return null; + } + + @Override + protected StreamElement prepareElement(StreamElement element) { + if (element instanceof Message) { + element = ServerMessage.from((Message) element); + } else if (element instanceof Presence) { + element = ServerPresence.from((Presence) element); + } else if (element instanceof IQ) { + element = ServerIQ.from((IQ) element); + } + + return element; + } +} diff --git a/src/main/java/com/juick/server/xmpp/s2s/CacheEntry.java b/src/main/java/com/juick/server/xmpp/s2s/CacheEntry.java new file mode 100644 index 00000000..33e875bd --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/s2s/CacheEntry.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.xmpp.s2s; + +import rocks.xmpp.addr.Jid; + +import java.time.Instant; + +/** + * + * @author ugnich + */ +public class CacheEntry { + + public Jid hostname; + public Instant created; + public Instant updated; + public String xml; + + public CacheEntry(Jid hostname, String xml) { + this.hostname = hostname; + this.created = this.updated =Instant.now(); + this.xml = xml; + } +} diff --git a/src/main/java/com/juick/server/xmpp/s2s/Connection.java b/src/main/java/com/juick/server/xmpp/s2s/Connection.java new file mode 100644 index 00000000..4fa8e741 --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/s2s/Connection.java @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.xmpp.s2s; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.juick.server.XMPPServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlPullParserFactory; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.UUID; + +/** + * + * @author ugnich + */ +public class Connection { + + protected static final Logger logger = LoggerFactory.getLogger(Connection.class); + + public String streamID; + public Instant created; + public Instant updated; + public long bytesLocal = 0; + public long packetsLocal = 0; + XMPPServer xmpp; + private Socket socket; + public static final String NS_DB = "jabber:server:dialback"; + public static final String NS_TLS = "urn:ietf:params:xml:ns:xmpp-tls"; + public static final String NS_SASL = "urn:ietf:params:xml:ns:xmpp-sasl"; + public static final String NS_STREAM = "http://etherx.jabber.org/streams"; + XmlPullParserFactory factory = XmlPullParserFactory.newInstance(); + XmlPullParser parser = factory.newPullParser(); + OutputStreamWriter writer; + private boolean secured = false; + private boolean authenticated = false; + private boolean trusted = false; + + + + public Connection(XMPPServer xmpp) throws XmlPullParserException { + this.xmpp = xmpp; + created = updated = Instant.now(); + } + + public void logParser() { + if (streamID == null) { + return; + } + String tag = "IN: <" + parser.getName(); + for (int i = 0; i < parser.getAttributeCount(); i++) { + tag += " " + parser.getAttributeName(i) + "=\"" + parser.getAttributeValue(i) + "\""; + } + tag += ">...</" + parser.getName() + ">\n"; + logger.trace(tag); + } + + public void sendStanza(String xml) { + if (streamID != null) { + logger.trace("OUT: {}\n", xml); + } + try { + writer.write(xml); + writer.flush(); + } catch (IOException e) { + logger.error("send stanza failed", e); + } + + updated = Instant.now(); + bytesLocal += xml.length(); + packetsLocal++; + } + + public void closeConnection() { + if (streamID != null) { + logger.debug("closing stream {}", streamID); + } + + try { + writer.write("</stream:stream>"); + } catch (Exception e) { + } + + try { + writer.close(); + } catch (Exception e) { + } + + try { + socket.close(); + } catch (Exception e) { + } + } + + public boolean isSecured() { + return secured; + } + + public void setSecured(boolean secured) { + this.secured = secured; + } + + public void restartParser() throws XmlPullParserException, IOException { + streamID = UUID.randomUUID().toString(); + parser = factory.newPullParser(); + parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true); + parser.setInput(new InputStreamReader(socket.getInputStream())); + writer = new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8); + } + + @JsonIgnore + public Socket getSocket() { + return socket; + } + + public void setSocket(Socket socket) { + this.socket = socket; + } + + public boolean isAuthenticated() { + return authenticated; + } + + public void setAuthenticated(boolean authenticated) { + this.authenticated = authenticated; + } + + public boolean isTrusted() { + return trusted; + } + + public void setTrusted(boolean trusted) { + this.trusted = trusted; + } +} diff --git a/src/main/java/com/juick/server/xmpp/s2s/ConnectionIn.java b/src/main/java/com/juick/server/xmpp/s2s/ConnectionIn.java new file mode 100644 index 00000000..72c3ba8d --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/s2s/ConnectionIn.java @@ -0,0 +1,231 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.xmpp.s2s; + +import com.juick.server.XMPPServer; +import com.juick.server.xmpp.router.StreamError; +import com.juick.server.xmpp.router.XmlUtils; +import org.apache.commons.lang3.StringUtils; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import rocks.xmpp.addr.Jid; + +import java.io.EOFException; +import java.io.IOException; +import java.net.Socket; +import java.net.SocketException; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; + +/** + * @author ugnich + */ +public class ConnectionIn extends Connection implements Runnable { + + final public List<Jid> from = new CopyOnWriteArrayList<>(); + public Instant received; + public long packetsRemote = 0; + ConnectionListener listener; + + public ConnectionIn(XMPPServer xmpp, Socket socket) throws XmlPullParserException, IOException { + super(xmpp); + this.setSocket(socket); + restartParser(); + } + + @Override + public void run() { + try { + parser.next(); // stream:stream + updateTsRemoteData(); + if (!parser.getName().equals("stream") + || !parser.getNamespace("stream").equals(NS_STREAM)) { +// || !parser.getAttributeValue(null, "version").equals("1.0") +// || !parser.getAttributeValue(null, "to").equals(Main.HOSTNAME)) { + throw new Exception(String.format("stream from %s invalid", getSocket().getRemoteSocketAddress())); + } + streamID = parser.getAttributeValue(null, "id"); + if (streamID == null) { + streamID = UUID.randomUUID().toString(); + } + boolean xmppversionnew = parser.getAttributeValue(null, "version") != null; + String from = parser.getAttributeValue(null, "from"); + + if (Arrays.asList(xmpp.bannedHosts).contains(from)) { + closeConnection(); + return; + } + sendOpenStream(from, xmppversionnew); + + while (parser.next() != XmlPullParser.END_DOCUMENT) { + updateTsRemoteData(); + if (parser.getEventType() != XmlPullParser.START_TAG) { + continue; + } + logParser(); + + packetsRemote++; + + String tag = parser.getName(); + if (tag.equals("result") && parser.getNamespace().equals(NS_DB)) { + String dfrom = parser.getAttributeValue(null, "from"); + String to = parser.getAttributeValue(null, "to"); + logger.debug("stream from {} to {} {} asking for dialback", dfrom, to, streamID); + if (dfrom.endsWith(xmpp.getJid().toEscapedString()) && (dfrom.equals(xmpp.getJid().toEscapedString()) + || dfrom.endsWith("." + xmpp.getJid()))) { + logger.warn("stream from {} is invalid", dfrom); + break; + } + if (to != null && to.equals(xmpp.getJid().toEscapedString())) { + String dbKey = XmlUtils.getTagText(parser); + updateTsRemoteData(); + xmpp.startDialback(Jid.of(dfrom), streamID, dbKey); + } else { + logger.warn("stream from " + dfrom + " " + streamID + " invalid to " + to); + break; + } + } else if (tag.equals("verify") && parser.getNamespace().equals(NS_DB)) { + String vfrom = parser.getAttributeValue(null, "from"); + String vto = parser.getAttributeValue(null, "to"); + String vid = parser.getAttributeValue(null, "id"); + String vkey = XmlUtils.getTagText(parser); + updateTsRemoteData(); + final boolean[] valid = {false}; + if (vfrom != null && vto != null && vid != null && vkey != null) { + xmpp.getConnectionOut(Jid.of(vfrom), false).ifPresent(c -> { + String dialbackKey = c.dbKey; + valid[0] = vkey.equals(dialbackKey); + }); + } + if (valid[0]) { + sendStanza("<db:verify from='" + vto + "' to='" + vfrom + "' id='" + vid + "' type='valid'/>"); + logger.debug("stream from {} {} dialback verify valid", vfrom, streamID); + setAuthenticated(true); + } else { + sendStanza("<db:verify from='" + vto + "' to='" + vfrom + "' id='" + vid + "' type='invalid'/>"); + logger.warn("stream from {} {} dialback verify invalid", vfrom, streamID); + } + } else if (tag.equals("presence") && checkFromTo(parser) && isAuthenticated()) { + String xml = XmlUtils.parseToString(parser, false); + logger.debug("stream {} presence: {}", streamID, xml); + xmpp.onStanzaReceived(xml); + } else if (tag.equals("message") && checkFromTo(parser)) { + updateTsRemoteData(); + String xml = XmlUtils.parseToString(parser, false); + logger.debug("stream {} message: {}", streamID, xml); + xmpp.onStanzaReceived(xml); + + } else if (tag.equals("iq") && checkFromTo(parser) && isAuthenticated()) { + updateTsRemoteData(); + String type = parser.getAttributeValue(null, "type"); + String xml = XmlUtils.parseToString(parser, false); + if (type == null || !type.equals("error")) { + logger.debug("stream {} iq: {}", streamID, xml); + xmpp.onStanzaReceived(xml); + } + } else if (!isSecured() && tag.equals("starttls") && !isAuthenticated()) { + listener.starttls(this); + } else if (isSecured() && tag.equals("stream") && parser.getNamespace().equals(NS_STREAM)) { + sendOpenStream(null, true); + } else if (isSecured() && tag.equals("auth") && parser.getNamespace().equals(NS_SASL) + && parser.getAttributeValue(null, "mechanism").equals("EXTERNAL") + && !isAuthenticated() && isTrusted()) { + sendStanza("<success xmlns='urn:ietf:params:xml:ns:xmpp-sasl'/>"); + logger.info("stream {} authenticated externally", streamID); + this.from.add(Jid.of(from)); + setAuthenticated(true); + restartParser(); + } else if (tag.equals("error")) { + StreamError streamError = StreamError.parse(parser); + logger.debug("Stream error {} from {}: {}", streamError.getCondition(), streamID, streamError.getText()); + xmpp.removeConnectionIn(this); + closeConnection(); + } else { + String unhandledStanza = XmlUtils.parseToString(parser, true); + logger.warn("Unhandled stanza from {}: {}", streamID, unhandledStanza); + } + } + logger.warn("stream {} finished", streamID); + xmpp.removeConnectionIn(this); + closeConnection(); + } catch (EOFException | SocketException ex) { + logger.debug("stream {} closed (dirty)", streamID); + xmpp.removeConnectionIn(this); + closeConnection(); + } catch (Exception e) { + logger.debug("stream {} error {}", streamID, e); + xmpp.removeConnectionIn(this); + closeConnection(); + } + } + + void updateTsRemoteData() { + received = Instant.now(); + } + + void sendOpenStream(String from, boolean xmppversionnew) throws IOException { + String openStream = "<?xml version='1.0'?><stream:stream xmlns='jabber:server' " + + "xmlns:stream='http://etherx.jabber.org/streams' xmlns:db='jabber:server:dialback' from='" + + xmpp.getJid().toEscapedString() + "' id='" + streamID + "' version='1.0'>"; + if (xmppversionnew) { + openStream += "<stream:features>"; + if (listener != null && listener.isTlsAvailable() && !Arrays.asList(xmpp.brokenSSLhosts).contains(from)) { + if (!isSecured()) { + openStream += "<starttls xmlns='" + NS_TLS + "'><optional/></starttls>"; + } else if (!isAuthenticated() && isTrusted()) { + openStream += "<mechanisms xmlns='urn:ietf:params:xml:ns:xmpp-sasl'>" + + "<mechanism>EXTERNAL</mechanism>" + + "</mechanisms>"; + } + } + openStream += "</stream:features>"; + } + sendStanza(openStream); + } + + public void sendDialbackResult(Jid sfrom, String type) { + sendStanza("<db:result from='" + xmpp.getJid().toEscapedString() + "' to='" + sfrom + "' type='" + type + "'/>"); + if (type.equals("valid")) { + from.add(sfrom); + logger.debug("stream from {} {} ready", sfrom, streamID); + setAuthenticated(true); + } + } + + boolean checkFromTo(XmlPullParser parser) throws Exception { + String cfrom = parser.getAttributeValue(null, "from"); + String cto = parser.getAttributeValue(null, "to"); + if (StringUtils.isNotEmpty(cfrom) && StringUtils.isNotEmpty(cto)) { + Jid jidfrom = Jid.of(cfrom); + for (Jid aFrom : from) { + if (aFrom.equals(Jid.of(jidfrom.getDomain()))) { + return true; + } + } + } + logger.warn("rejected from {}, to {}, stream {}", cfrom, cto, from.stream().collect(Collectors.joining(","))); + return false; + } + public void setListener(ConnectionListener listener) { + this.listener = listener; + } +} diff --git a/src/main/java/com/juick/server/xmpp/s2s/ConnectionListener.java b/src/main/java/com/juick/server/xmpp/s2s/ConnectionListener.java new file mode 100644 index 00000000..4c32b9ae --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/s2s/ConnectionListener.java @@ -0,0 +1,16 @@ +package com.juick.server.xmpp.s2s; + + +import com.juick.server.xmpp.router.StreamError; + +public interface ConnectionListener { + boolean isTlsAvailable(); + void starttls(ConnectionIn connection); + void proceed(ConnectionOut connection); + void verify(ConnectionOut connection, String from, String type, String sid); + void dialbackError(ConnectionOut connection, StreamError error); + void finished(ConnectionOut connection, boolean dirty); + void exception(ConnectionOut connection, Exception ex); + void ready(ConnectionOut connection); + boolean securing(ConnectionOut connection); +} diff --git a/src/main/java/com/juick/server/xmpp/s2s/ConnectionOut.java b/src/main/java/com/juick/server/xmpp/s2s/ConnectionOut.java new file mode 100644 index 00000000..be485ab1 --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/s2s/ConnectionOut.java @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.xmpp.s2s; + +import com.juick.server.xmpp.router.Stream; +import com.juick.server.xmpp.router.StreamError; +import com.juick.server.xmpp.router.StreamFeatures; +import com.juick.server.xmpp.router.XmlUtils; +import com.juick.server.xmpp.s2s.util.DialbackUtils; +import org.apache.commons.codec.Charsets; +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.text.RandomStringGenerator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.xmlpull.v1.XmlPullParser; +import rocks.xmpp.addr.Jid; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.SocketException; +import java.util.UUID; + +import static com.juick.server.xmpp.router.StreamNamespaces.NS_STREAM; +import static com.juick.server.xmpp.s2s.Connection.NS_SASL; + +/** + * @author ugnich + */ +public class ConnectionOut extends Stream { + protected static final Logger logger = LoggerFactory.getLogger(ConnectionOut.class); + public static final String NS_TLS = "urn:ietf:params:xml:ns:xmpp-tls"; + public static final String NS_DB = "jabber:server:dialback"; + private boolean secured = false; + private boolean trusted = false; + public boolean streamReady = false; + String checkSID = null; + String dbKey = null; + private String streamID; + ConnectionListener listener; + RandomStringGenerator generator = new RandomStringGenerator.Builder().withinRange('a', 'z').build(); + + public ConnectionOut(Jid from, Jid to, InputStream is, OutputStream os, String checkSID, String dbKey) throws Exception { + super(from, to, is, os); + this.to = to; + this.checkSID = checkSID; + this.dbKey = dbKey; + if (dbKey == null) { + this.dbKey = DialbackUtils.generateDialbackKey(generator.generate(15), to, from, streamID); + } + streamID = UUID.randomUUID().toString(); + } + + public void sendOpenStream() throws IOException { + send("<?xml version='1.0'?><stream:stream xmlns='jabber:server' id='" + streamID + + "' xmlns:stream='http://etherx.jabber.org/streams' xmlns:db='jabber:server:dialback' from='" + + from.toEscapedString() + "' to='" + to.toEscapedString() + "' version='1.0'>"); + } + + void processDialback() throws Exception { + if (checkSID != null) { + sendDialbackVerify(checkSID, dbKey); + } + send("<db:result from='" + from.toEscapedString() + "' to='" + to.toEscapedString() + "'>" + + dbKey + "</db:result>"); + } + + @Override + public void handshake() { + try { + restartStream(); + + sendOpenStream(); + + parser.next(); // stream:stream + streamID = parser.getAttributeValue(null, "id"); + if (streamID == null || streamID.isEmpty()) { + throw new Exception("stream to " + to + " invalid first packet"); + } + + logger.debug("stream to {} {} open", to, streamID); + boolean xmppversionnew = parser.getAttributeValue(null, "version") != null; + if (!xmppversionnew) { + processDialback(); + } + + while (parser.next() != XmlPullParser.END_DOCUMENT) { + if (parser.getEventType() != XmlPullParser.START_TAG) { + continue; + } + + String tag = parser.getName(); + if (tag.equals("result") && parser.getNamespace().equals(NS_DB)) { + String type = parser.getAttributeValue(null, "type"); + if (type != null && type.equals("valid")) { + streamReady = true; + listener.ready(this); + } else { + logger.warn("stream to {} {} dialback fail", to, streamID); + } + XmlUtils.skip(parser); + } else if (tag.equals("verify") && parser.getNamespace().equals(NS_DB)) { + String from = parser.getAttributeValue(null, "from"); + String type = parser.getAttributeValue(null, "type"); + String sid = parser.getAttributeValue(null, "id"); + listener.verify(this, from, type, sid); + XmlUtils.skip(parser); + } else if (tag.equals("features") && parser.getNamespace().equals(NS_STREAM)) { + StreamFeatures features = StreamFeatures.parse(parser); + if (listener != null && !secured && features.STARTTLS >= 0 + && listener.securing(this)) { + logger.debug("stream to {} {} securing", to.toEscapedString(), streamID); + send("<starttls xmlns=\"" + NS_TLS + "\" />"); + } else if (secured && features.EXTERNAL >=0) { + String authid = Base64.encodeBase64String(from.toEscapedString().getBytes(Charsets.UTF_8)); + send(String.format("<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' mechanism='EXTERNAL'>%s</auth>", authid)); + } else if (secured && streamReady) { + listener.ready(this); + } else { + processDialback(); + } + } else if (tag.equals("proceed") && parser.getNamespace().equals(NS_TLS)) { + listener.proceed(this); + } else if (tag.equals("success") && parser.getNamespace().equals(NS_SASL)) { + streamReady = true; + restartStream(); + sendOpenStream(); + } else if (secured && tag.equals("stream") && parser.getNamespace().equals(NS_STREAM)) { + streamID = parser.getAttributeValue(null, "id"); + } else if (tag.equals("error")) { + StreamError streamError = StreamError.parse(parser); + listener.dialbackError(this, streamError); + } else { + String unhandledStanza = XmlUtils.parseToString(parser, false); + logger.warn("Unhandled stanza from {} {} : {}", to, streamID, unhandledStanza); + } + } + listener.finished(this, false); + } catch (EOFException | SocketException eofex) { + listener.finished(this, true); + } catch (Exception e) { + listener.exception(this, e); + } + } + + public void sendDialbackVerify(String sid, String key) { + send("<db:verify from='" + from.toEscapedString() + "' to='" + to + "' id='" + sid + "'>" + + key + "</db:verify>"); + } + public void setListener(ConnectionListener listener) { + this.listener = listener; + } + + public String getStreamID() { + return streamID; + } + + public boolean isSecured() { + return secured; + } + + public void setSecured(boolean secured) { + this.secured = secured; + } + + public boolean isTrusted() { + return trusted; + } + + public void setTrusted(boolean trusted) { + this.trusted = trusted; + } +} diff --git a/src/main/java/com/juick/server/xmpp/s2s/DNSQueries.java b/src/main/java/com/juick/server/xmpp/s2s/DNSQueries.java new file mode 100644 index 00000000..1367d333 --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/s2s/DNSQueries.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.xmpp.s2s; + +import org.apache.commons.lang3.math.NumberUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.InetSocketAddress; +import java.util.Hashtable; +import java.util.Random; +import javax.naming.NamingException; +import javax.naming.directory.Attribute; +import javax.naming.directory.DirContext; +import javax.naming.directory.InitialDirContext; + +/** + * + * @author ugnich + */ +public class DNSQueries { + + private static final Logger logger = LoggerFactory.getLogger(DNSQueries.class); + + private static Random rand = new Random(); + + public static InetSocketAddress getServerAddress(String hostname) { + + String host = hostname; + int port = 5269; + + Hashtable<String, String> env = new Hashtable<>(5); + env.put("java.naming.factory.initial", "com.sun.jndi.dns.DnsContextFactory"); + try { + DirContext ctx = new InitialDirContext(env); + Attribute att = ctx.getAttributes("_xmpp-server._tcp." + hostname, new String[]{"SRV"}).get("SRV"); + + if (att != null && att.size() > 0) { + int i = rand.nextInt(att.size()); + String srv[] = att.get(i).toString().split(" "); + port = NumberUtils.toInt(srv[2], 5269); + host = srv[3]; + } + ctx.close(); + } catch (NamingException e) { + logger.debug("SRV record for {} is not resolved, falling back to A record", hostname); + } + return new InetSocketAddress(host, port); + } +} diff --git a/src/main/java/com/juick/server/xmpp/s2s/StanzaListener.java b/src/main/java/com/juick/server/xmpp/s2s/StanzaListener.java new file mode 100644 index 00000000..6932298f --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/s2s/StanzaListener.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.xmpp.s2s; + + +import rocks.xmpp.core.stanza.model.Stanza; + +/** + * Created by vitalyster on 07.12.2016. + */ +public interface StanzaListener { + void stanzaReceived(Stanza xmlValue); +} diff --git a/src/main/java/com/juick/server/xmpp/s2s/util/DialbackUtils.java b/src/main/java/com/juick/server/xmpp/s2s/util/DialbackUtils.java new file mode 100644 index 00000000..d25dbad8 --- /dev/null +++ b/src/main/java/com/juick/server/xmpp/s2s/util/DialbackUtils.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2008-2017, 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 <http://www.gnu.org/licenses/>. + */ + +package com.juick.server.xmpp.s2s.util; + +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.codec.digest.HmacAlgorithms; +import org.apache.commons.codec.digest.HmacUtils; +import rocks.xmpp.addr.Jid; + +/** + * Created by vitalyster on 05.12.2016. + */ +public class DialbackUtils { + private DialbackUtils() { + throw new IllegalStateException(); + } + + public static String generateDialbackKey(String secret, Jid to, Jid from, String id) { + return new HmacUtils(HmacAlgorithms.HMAC_SHA_256, DigestUtils.sha256(secret)) + .hmacHex(to.toEscapedString() + " " + from.toEscapedString() + " " + id); + } +} |