aboutsummaryrefslogtreecommitdiff
path: root/src/main/java/com/juick/server
diff options
context:
space:
mode:
authorGravatar Vitaly Takmazov2018-11-08 21:38:27 +0300
committerGravatar Vitaly Takmazov2018-11-08 21:38:27 +0300
commit7aaa3f9a29c280f01c677c918932620be45cdbd7 (patch)
tree39947b2c889afd08f9c73ba54fab91159d2af258 /src/main/java/com/juick/server
parent3ea9770d0d43fbe45449ac4531ec4b0a374d98ea (diff)
Merge everything into single Spring Boot application
Diffstat (limited to 'src/main/java/com/juick/server')
-rw-r--r--src/main/java/com/juick/server/ActivityPubManager.java331
-rw-r--r--src/main/java/com/juick/server/CommandsManager.java540
-rw-r--r--src/main/java/com/juick/server/EmailManager.java165
-rw-r--r--src/main/java/com/juick/server/KeystoreManager.java92
-rw-r--r--src/main/java/com/juick/server/ServerManager.java295
-rw-r--r--src/main/java/com/juick/server/SignatureManager.java113
-rw-r--r--src/main/java/com/juick/server/TelegramBotManager.java412
-rw-r--r--src/main/java/com/juick/server/TopManager.java54
-rw-r--r--src/main/java/com/juick/server/TwitterManager.java125
-rw-r--r--src/main/java/com/juick/server/Utils.java45
-rw-r--r--src/main/java/com/juick/server/WebsocketManager.java174
-rw-r--r--src/main/java/com/juick/server/XMPPConnection.java693
-rw-r--r--src/main/java/com/juick/server/XMPPServer.java429
-rw-r--r--src/main/java/com/juick/server/api/ApiSocialLogin.java302
-rw-r--r--src/main/java/com/juick/server/api/Index.java54
-rw-r--r--src/main/java/com/juick/server/api/Messages.java201
-rw-r--r--src/main/java/com/juick/server/api/Notifications.java221
-rw-r--r--src/main/java/com/juick/server/api/PM.java116
-rw-r--r--src/main/java/com/juick/server/api/Post.java245
-rw-r--r--src/main/java/com/juick/server/api/Service.java166
-rw-r--r--src/main/java/com/juick/server/api/Tags.java54
-rw-r--r--src/main/java/com/juick/server/api/Users.java179
-rw-r--r--src/main/java/com/juick/server/api/activity/Profile.java379
-rw-r--r--src/main/java/com/juick/server/api/activity/model/Activity.java23
-rw-r--r--src/main/java/com/juick/server/api/activity/model/Context.java123
-rw-r--r--src/main/java/com/juick/server/api/activity/model/activities/Accept.java6
-rw-r--r--src/main/java/com/juick/server/api/activity/model/activities/Announce.java6
-rw-r--r--src/main/java/com/juick/server/api/activity/model/activities/Block.java6
-rw-r--r--src/main/java/com/juick/server/api/activity/model/activities/Create.java6
-rw-r--r--src/main/java/com/juick/server/api/activity/model/activities/Delete.java6
-rw-r--r--src/main/java/com/juick/server/api/activity/model/activities/Follow.java6
-rw-r--r--src/main/java/com/juick/server/api/activity/model/activities/Like.java6
-rw-r--r--src/main/java/com/juick/server/api/activity/model/activities/Undo.java6
-rw-r--r--src/main/java/com/juick/server/api/activity/model/objects/Hashtag.java6
-rw-r--r--src/main/java/com/juick/server/api/activity/model/objects/Image.java15
-rw-r--r--src/main/java/com/juick/server/api/activity/model/objects/Key.java24
-rw-r--r--src/main/java/com/juick/server/api/activity/model/objects/Link.java15
-rw-r--r--src/main/java/com/juick/server/api/activity/model/objects/Mention.java12
-rw-r--r--src/main/java/com/juick/server/api/activity/model/objects/Note.java64
-rw-r--r--src/main/java/com/juick/server/api/activity/model/objects/OrderedCollection.java25
-rw-r--r--src/main/java/com/juick/server/api/activity/model/objects/OrderedCollectionPage.java58
-rw-r--r--src/main/java/com/juick/server/api/activity/model/objects/Person.java87
-rw-r--r--src/main/java/com/juick/server/api/apple/AppSiteAssociation.java49
-rw-r--r--src/main/java/com/juick/server/api/hostmeta/HostMeta.java25
-rw-r--r--src/main/java/com/juick/server/api/rss/Feeds.java75
-rw-r--r--src/main/java/com/juick/server/api/rss/MessagesView.java153
-rw-r--r--src/main/java/com/juick/server/api/rss/RepliesView.java111
-rw-r--r--src/main/java/com/juick/server/api/rss/extension/JuickModule.java33
-rw-r--r--src/main/java/com/juick/server/api/rss/extension/JuickModuleGenerator.java70
-rw-r--r--src/main/java/com/juick/server/api/rss/extension/JuickModuleImpl.java54
-rw-r--r--src/main/java/com/juick/server/api/rss/extension/JuickModuleParser.java42
-rw-r--r--src/main/java/com/juick/server/api/webfinger/Resource.java51
-rw-r--r--src/main/java/com/juick/server/api/webfinger/model/Account.java24
-rw-r--r--src/main/java/com/juick/server/api/webfinger/model/Link.java31
-rw-r--r--src/main/java/com/juick/server/api/webhooks/TelegramWebhook.java57
-rw-r--r--src/main/java/com/juick/server/api/xnodeinfo2/Info.java51
-rw-r--r--src/main/java/com/juick/server/api/xnodeinfo2/model/NodeInfo.java54
-rw-r--r--src/main/java/com/juick/server/api/xnodeinfo2/model/Server.java40
-rw-r--r--src/main/java/com/juick/server/api/xnodeinfo2/model/ServiceInfo.java24
-rw-r--r--src/main/java/com/juick/server/api/xnodeinfo2/model/Usage.java31
-rw-r--r--src/main/java/com/juick/server/api/xnodeinfo2/model/UserStats.java31
-rw-r--r--src/main/java/com/juick/server/configuration/ActivityPubClientConfig.java22
-rw-r--r--src/main/java/com/juick/server/configuration/ActivityPubClientErrorHandler.java35
-rw-r--r--src/main/java/com/juick/server/configuration/ApiAppConfiguration.java76
-rw-r--r--src/main/java/com/juick/server/configuration/BaseWebConfiguration.java63
-rw-r--r--src/main/java/com/juick/server/configuration/SapeConfiguration.java39
-rw-r--r--src/main/java/com/juick/server/configuration/SecurityConfig.java215
-rw-r--r--src/main/java/com/juick/server/configuration/StorageConfiguration.java20
-rw-r--r--src/main/java/com/juick/server/configuration/TelegramConfig.java15
-rw-r--r--src/main/java/com/juick/server/configuration/WwwAppConfiguration.java120
-rw-r--r--src/main/java/com/juick/server/configuration/XMPPConfig.java55
-rw-r--r--src/main/java/com/juick/server/helpers/annotation/UserCommand.java50
-rw-r--r--src/main/java/com/juick/server/util/HttpBadRequestException.java32
-rw-r--r--src/main/java/com/juick/server/util/HttpForbiddenException.java33
-rw-r--r--src/main/java/com/juick/server/util/HttpNotFoundException.java32
-rw-r--r--src/main/java/com/juick/server/util/HttpUtils.java115
-rw-r--r--src/main/java/com/juick/server/util/ImageUtils.java175
-rw-r--r--src/main/java/com/juick/server/util/TagUtils.java42
-rw-r--r--src/main/java/com/juick/server/util/UserUtils.java55
-rw-r--r--src/main/java/com/juick/server/util/WebUtils.java62
-rw-r--r--src/main/java/com/juick/server/www/HelpService.java69
-rw-r--r--src/main/java/com/juick/server/www/WebApp.java71
-rw-r--r--src/main/java/com/juick/server/www/controllers/AnythingFilter.java69
-rw-r--r--src/main/java/com/juick/server/www/controllers/Help.java93
-rw-r--r--src/main/java/com/juick/server/www/controllers/Login.java50
-rw-r--r--src/main/java/com/juick/server/www/controllers/MessagesWWW.java593
-rw-r--r--src/main/java/com/juick/server/www/controllers/NewMessage.java59
-rw-r--r--src/main/java/com/juick/server/www/controllers/Settings.java278
-rw-r--r--src/main/java/com/juick/server/www/controllers/SignUp.java172
-rw-r--r--src/main/java/com/juick/server/www/controllers/SocialLogin.java329
-rw-r--r--src/main/java/com/juick/server/xmpp/JidConverter.java13
-rw-r--r--src/main/java/com/juick/server/xmpp/XMPPStatusPage.java32
-rw-r--r--src/main/java/com/juick/server/xmpp/helpers/XMPPStatus.java48
-rw-r--r--src/main/java/com/juick/server/xmpp/iq/MessageQuery.java10
-rw-r--r--src/main/java/com/juick/server/xmpp/iq/package-info.java8
-rw-r--r--src/main/java/com/juick/server/xmpp/router/Handshake.java39
-rw-r--r--src/main/java/com/juick/server/xmpp/router/Stream.java202
-rw-r--r--src/main/java/com/juick/server/xmpp/router/StreamComponentServer.java57
-rw-r--r--src/main/java/com/juick/server/xmpp/router/StreamError.java57
-rw-r--r--src/main/java/com/juick/server/xmpp/router/StreamFeatures.java95
-rw-r--r--src/main/java/com/juick/server/xmpp/router/StreamHandler.java13
-rw-r--r--src/main/java/com/juick/server/xmpp/router/StreamNamespaces.java10
-rw-r--r--src/main/java/com/juick/server/xmpp/router/XMPPError.java73
-rw-r--r--src/main/java/com/juick/server/xmpp/router/XMPPRouter.java220
-rw-r--r--src/main/java/com/juick/server/xmpp/router/XmlUtils.java88
-rw-r--r--src/main/java/com/juick/server/xmpp/s2s/BasicXmppSession.java68
-rw-r--r--src/main/java/com/juick/server/xmpp/s2s/CacheEntry.java40
-rw-r--r--src/main/java/com/juick/server/xmpp/s2s/Connection.java158
-rw-r--r--src/main/java/com/juick/server/xmpp/s2s/ConnectionIn.java231
-rw-r--r--src/main/java/com/juick/server/xmpp/s2s/ConnectionListener.java16
-rw-r--r--src/main/java/com/juick/server/xmpp/s2s/ConnectionOut.java189
-rw-r--r--src/main/java/com/juick/server/xmpp/s2s/DNSQueries.java65
-rw-r--r--src/main/java/com/juick/server/xmpp/s2s/StanzaListener.java28
-rw-r--r--src/main/java/com/juick/server/xmpp/s2s/util/DialbackUtils.java37
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 += "&amp;show=" + paramShow;
+ }
+ if (paramSearch != null) {
+ nextpage += "&amp;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 += "&amp;show=" + paramShow;
+ }
+ if (paramSearch != null) {
+ nextpage += "&amp;search=" + URLEncoder.encode(paramSearch, CharEncoding.UTF_8);
+ }
+ if (paramTag != null) {
+ nextpage += "&amp;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);
+ }
+}