aboutsummaryrefslogtreecommitdiff
path: root/src/main/java/com/juick
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
parent3ea9770d0d43fbe45449ac4531ec4b0a374d98ea (diff)
Merge everything into single Spring Boot application
Diffstat (limited to 'src/main/java/com/juick')
-rw-r--r--src/main/java/com/juick/ApiServer.java16
-rw-r--r--src/main/java/com/juick/Attachment.java58
-rw-r--r--src/main/java/com/juick/Chat.java27
-rw-r--r--src/main/java/com/juick/ExternalToken.java64
-rw-r--r--src/main/java/com/juick/Message.java378
-rw-r--r--src/main/java/com/juick/Photo.java53
-rw-r--r--src/main/java/com/juick/Reaction.java45
-rw-r--r--src/main/java/com/juick/Status.java49
-rw-r--r--src/main/java/com/juick/Tag.java73
-rw-r--r--src/main/java/com/juick/User.java226
-rw-r--r--src/main/java/com/juick/adapters/SimpleDateAdapter.java40
-rw-r--r--src/main/java/com/juick/formatters/PlainTextFormatter.java97
-rw-r--r--src/main/java/com/juick/model/AnonymousUser.java129
-rw-r--r--src/main/java/com/juick/model/ApplicationStatus.java52
-rw-r--r--src/main/java/com/juick/model/Auth.java39
-rw-r--r--src/main/java/com/juick/model/CommandResult.java35
-rw-r--r--src/main/java/com/juick/model/NotifyOpts.java51
-rw-r--r--src/main/java/com/juick/model/PrivacyOpts.java46
-rw-r--r--src/main/java/com/juick/model/PrivateChats.java39
-rw-r--r--src/main/java/com/juick/model/ResponseReply.java98
-rw-r--r--src/main/java/com/juick/model/TagStats.java46
-rw-r--r--src/main/java/com/juick/model/UserInfo.java60
-rw-r--r--src/main/java/com/juick/model/facebook/User.java125
-rw-r--r--src/main/java/com/juick/model/twitter/User.java38
-rw-r--r--src/main/java/com/juick/model/vk/Token.java56
-rw-r--r--src/main/java/com/juick/model/vk/User.java65
-rw-r--r--src/main/java/com/juick/model/vk/UsersResponse.java38
-rw-r--r--src/main/java/com/juick/package-info.java35
-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
-rw-r--r--src/main/java/com/juick/service/ActivityPubService.java59
-rw-r--r--src/main/java/com/juick/service/BaseJdbcService.java41
-rw-r--r--src/main/java/com/juick/service/CrosspostService.java86
-rw-r--r--src/main/java/com/juick/service/CrosspostServiceImpl.java282
-rw-r--r--src/main/java/com/juick/service/EmailService.java35
-rw-r--r--src/main/java/com/juick/service/EmailServiceImpl.java108
-rw-r--r--src/main/java/com/juick/service/ImagesService.java24
-rw-r--r--src/main/java/com/juick/service/ImagesServiceImpl.java82
-rw-r--r--src/main/java/com/juick/service/MessagesService.java145
-rw-r--r--src/main/java/com/juick/service/MessagesServiceImpl.java1143
-rw-r--r--src/main/java/com/juick/service/MessengerService.java14
-rw-r--r--src/main/java/com/juick/service/MessengerServiceImpl.java71
-rw-r--r--src/main/java/com/juick/service/PMQueriesService.java44
-rw-r--r--src/main/java/com/juick/service/PMQueriesServiceImpl.java149
-rw-r--r--src/main/java/com/juick/service/PrivacyQueriesService.java34
-rw-r--r--src/main/java/com/juick/service/PrivacyQueriesServiceImpl.java63
-rw-r--r--src/main/java/com/juick/service/PushQueriesService.java50
-rw-r--r--src/main/java/com/juick/service/PushQueriesServiceImpl.java143
-rw-r--r--src/main/java/com/juick/service/SearchService.java33
-rw-r--r--src/main/java/com/juick/service/ShowQueriesService.java31
-rw-r--r--src/main/java/com/juick/service/ShowQueriesServiceImpl.java62
-rw-r--r--src/main/java/com/juick/service/SocialService.java16
-rw-r--r--src/main/java/com/juick/service/SphinxSearchService.java97
-rw-r--r--src/main/java/com/juick/service/SubscriptionService.java57
-rw-r--r--src/main/java/com/juick/service/SubscriptionServiceImpl.java229
-rw-r--r--src/main/java/com/juick/service/TagService.java64
-rw-r--r--src/main/java/com/juick/service/TagServiceImpl.java277
-rw-r--r--src/main/java/com/juick/service/TelegramService.java40
-rw-r--r--src/main/java/com/juick/service/TelegramServiceImpl.java84
-rw-r--r--src/main/java/com/juick/service/UserService.java137
-rw-r--r--src/main/java/com/juick/service/UserServiceImpl.java668
-rw-r--r--src/main/java/com/juick/service/activities/ActivityListener.java19
-rw-r--r--src/main/java/com/juick/service/activities/DeleteMessageEvent.java21
-rw-r--r--src/main/java/com/juick/service/activities/DeleteUserEvent.java20
-rw-r--r--src/main/java/com/juick/service/activities/FollowEvent.java21
-rw-r--r--src/main/java/com/juick/service/activities/UndoFollowEvent.java26
-rw-r--r--src/main/java/com/juick/service/component/DisconnectedEvent.java14
-rw-r--r--src/main/java/com/juick/service/component/LikeEvent.java36
-rw-r--r--src/main/java/com/juick/service/component/MessageEvent.java31
-rw-r--r--src/main/java/com/juick/service/component/MessageReadEvent.java30
-rw-r--r--src/main/java/com/juick/service/component/NotificationListener.java25
-rw-r--r--src/main/java/com/juick/service/component/PingEvent.java21
-rw-r--r--src/main/java/com/juick/service/component/SubscribeEvent.java27
-rw-r--r--src/main/java/com/juick/service/component/TopEvent.java21
-rw-r--r--src/main/java/com/juick/service/component/UserUpdatedEvent.java23
-rw-r--r--src/main/java/com/juick/service/security/HashParamAuthenticationFilter.java103
-rw-r--r--src/main/java/com/juick/service/security/JuickUserDetailsService.java53
-rw-r--r--src/main/java/com/juick/service/security/NullUserDetailsService.java33
-rw-r--r--src/main/java/com/juick/service/security/deprecated/CookieSimpleHashRememberMeServices.java130
-rw-r--r--src/main/java/com/juick/service/security/deprecated/RequestParamHashRememberMeServices.java88
-rw-r--r--src/main/java/com/juick/service/security/entities/JuickUser.java93
-rw-r--r--src/main/java/com/juick/util/DateFormatter.java57
-rw-r--r--src/main/java/com/juick/util/DateFormattersHolder.java75
-rw-r--r--src/main/java/com/juick/util/MessageUtils.java324
-rw-r--r--src/main/java/com/juick/util/PrettyTimeFormatter.java53
-rw-r--r--src/main/java/com/juick/util/StreamUtils.java38
198 files changed, 19555 insertions, 0 deletions
diff --git a/src/main/java/com/juick/ApiServer.java b/src/main/java/com/juick/ApiServer.java
new file mode 100644
index 00000000..fb2d9701
--- /dev/null
+++ b/src/main/java/com/juick/ApiServer.java
@@ -0,0 +1,16 @@
+package com.juick;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration;
+import org.springframework.context.annotation.ComponentScan;
+
+@SpringBootApplication
+@EnableAutoConfiguration(exclude = { MailSenderAutoConfiguration.class })
+@ComponentScan(basePackages = {"com.juick.server", "com.juick.service"})
+public class ApiServer {
+ public static void main(String[] args) {
+ SpringApplication.run(ApiServer.class, args);
+ }
+}
diff --git a/src/main/java/com/juick/Attachment.java b/src/main/java/com/juick/Attachment.java
new file mode 100644
index 00000000..76f2995a
--- /dev/null
+++ b/src/main/java/com/juick/Attachment.java
@@ -0,0 +1,58 @@
+package com.juick;
+
+public class Attachment {
+ private String url;
+ private Integer height;
+ private Integer width;
+ private Attachment small;
+ private Attachment medium;
+ private Attachment thumbnail;
+
+ public String getUrl() {
+ return url;
+ }
+
+ public void setUrl(String url) {
+ this.url = url;
+ }
+
+ public Integer getHeight() {
+ return height;
+ }
+
+ public void setHeight(Integer height) {
+ this.height = height;
+ }
+
+ public Integer getWidth() {
+ return width;
+ }
+
+ public void setWidth(Integer width) {
+ this.width = width;
+ }
+
+ public Attachment getSmall() {
+ return small;
+ }
+
+ public void setSmall(Attachment small) {
+ this.small = small;
+ }
+
+ public Attachment getMedium() {
+ return medium;
+ }
+
+ public void setMedium(Attachment medium) {
+ this.medium = medium;
+ }
+
+ public Attachment getThumbnail() {
+ return thumbnail;
+ }
+
+ public void setThumbnail(Attachment thumbnail) {
+ this.thumbnail = thumbnail;
+ }
+}
diff --git a/src/main/java/com/juick/Chat.java b/src/main/java/com/juick/Chat.java
new file mode 100644
index 00000000..59c0a2dc
--- /dev/null
+++ b/src/main/java/com/juick/Chat.java
@@ -0,0 +1,27 @@
+package com.juick;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+
+import java.time.Instant;
+
+public class Chat extends User {
+ private Instant lastMessageTimestamp;
+ private String lastMessageText;
+
+ public Instant getLastMessageTimestamp() {
+ return lastMessageTimestamp;
+ }
+
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "UTC")
+ public void setLastMessageTimestamp(Instant lastMessageTimestamp) {
+ this.lastMessageTimestamp = lastMessageTimestamp;
+ }
+
+ public String getLastMessageText() {
+ return lastMessageText;
+ }
+
+ public void setLastMessageText(String lastMessageText) {
+ this.lastMessageText = lastMessageText;
+ }
+}
diff --git a/src/main/java/com/juick/ExternalToken.java b/src/main/java/com/juick/ExternalToken.java
new file mode 100644
index 00000000..f6094478
--- /dev/null
+++ b/src/main/java/com/juick/ExternalToken.java
@@ -0,0 +1,64 @@
+/*
+ * 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;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Created by vitalyster on 22.11.2016.
+ */
+public class ExternalToken {
+ private String name;
+ private String type;
+ private String token;
+ private String secret;
+
+ @JsonCreator
+ public ExternalToken(@JsonProperty("name") String name,
+ @JsonProperty("type") String type,
+ @JsonProperty("token") String token,
+ @JsonProperty("secret") String secret) {
+ this.name = name;
+ this.type = type;
+ this.token = token;
+ this.secret = secret;
+ if (this.type == null) {
+ throw new IllegalStateException("Token must have type");
+ }
+ if (this.token == null) {
+ throw new IllegalStateException("Token must have value");
+ }
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public String getToken() {
+ return token;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getSecret() {
+ return secret;
+ }
+}
diff --git a/src/main/java/com/juick/Message.java b/src/main/java/com/juick/Message.java
new file mode 100644
index 00000000..bd2c91b5
--- /dev/null
+++ b/src/main/java/com/juick/Message.java
@@ -0,0 +1,378 @@
+/*
+ * 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;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.juick.adapters.SimpleDateAdapter;
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+
+import javax.xml.bind.annotation.*;
+import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
+import java.net.URI;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * @author Ugnich Anton
+ */
+@XmlRootElement(name = "juick", namespace = "http://juick.com/message")
+@XmlAccessorType()
+public class Message implements Comparable {
+ private int mid = 0;
+ private int rid = 0;
+ private int replyto = 0;
+ private String text = null;
+ private User user = null;
+ private final List<Tag> tags;
+ private Instant ts;
+ private Instant updated;
+ private Instant updatedAt;
+ private boolean unread;
+ @JsonIgnore
+ private int privacy = 1;
+ @XmlTransient
+ @JsonIgnore
+ public boolean FriendsOnly = false;
+ @XmlTransient
+ @JsonIgnore
+ public boolean ReadOnly = false;
+ @XmlTransient
+ @JsonIgnore
+ public boolean Hidden = false;
+ @JsonIgnore
+ @XmlTransient
+ public boolean VisitorCanComment = true;
+ private int replies = 0;
+ private String repliesBy;
+ private String attachmentType;
+ @XmlTransient
+ private Photo photo;
+ @XmlTransient
+ private Attachment attachment;
+ private int likes;
+ private User to;
+ private String replyQuote;
+ @XmlTransient
+ private Set<Reaction> reactions;
+ private boolean service;
+ private URI replyUri;
+ private URI replyToUri;
+ private boolean html;
+
+ private Set<String> recommendations;
+
+ public Message() {
+ tags = new ArrayList<>();
+ reactions = new HashSet<>();
+ }
+
+ @Override
+ public String toString() {
+ return new ToStringBuilder(this)
+ .append("mid", mid)
+ .append("rid", rid)
+ .append("replyto", replyto)
+ .append("privacy", privacy)
+ .append("FriendsOnly", FriendsOnly)
+ .append("ReadOnly", ReadOnly)
+ .append("Hidden", Hidden)
+ .append("VisitorCanComment", VisitorCanComment)
+ .append("replies", replies)
+ .append("likes", likes)
+ .append("reactions", reactions)
+ .toString();
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this)
+ return true;
+
+ if (!(obj instanceof Message))
+ return false;
+
+ Message jmsg = (Message) obj;
+ return (this.getMid() == jmsg.getMid() && this.getRid() == jmsg.getRid());
+ }
+
+ @Override
+ public int compareTo(Object obj) throws ClassCastException {
+ if (obj == this)
+ return 0;
+
+ if (!(obj instanceof Message))
+ throw new ClassCastException();
+
+ Message jmsg = (Message) obj;
+
+ int cmp = Integer.compare(jmsg.getMid(), getMid());
+
+ if (cmp == 0)
+ cmp = Integer.compare(getRid(), jmsg.getRid());
+
+ return cmp;
+ }
+
+ @JsonProperty("mid")
+ @XmlAttribute(name = "mid")
+ public int getMid() {
+ return mid;
+ }
+
+ public void setMid(int mid) {
+ this.mid = mid;
+ }
+
+ @JsonProperty("rid")
+ @XmlAttribute(name = "rid")
+ public int getRid() {
+ return rid;
+ }
+
+ public void setRid(int rid) {
+ this.rid = rid;
+ }
+
+ @XmlElement(name = "user", namespace = "http://juick.com/user")
+ public com.juick.User getUser() {
+ return user;
+ }
+
+ public void setUser(com.juick.User user) {
+ this.user = user;
+ }
+
+ @JsonProperty("body")
+ @XmlElement(name = "body")
+ public String getText() {
+ return text;
+ }
+
+ public void setText(String text) {
+ this.text = text;
+ }
+
+ @JsonProperty("timestamp")
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "UTC")
+ @XmlAttribute(name = "ts")
+ @XmlJavaTypeAdapter(SimpleDateAdapter.class)
+ public Instant getTimestamp() {
+ return ts;
+ }
+
+ public void setTimestamp(Instant timestamp) {
+ this.ts = timestamp;
+ }
+
+ @XmlElement(name = "to", namespace = "http://juick.com/user")
+ public User getTo() {
+ return to;
+ }
+
+ public void setTo(User to) {
+ this.to = to;
+ }
+
+ @XmlAttribute(name = "quote")
+ public String getReplyQuote() {
+ return replyQuote;
+ }
+
+ public void setReplyQuote(String quote) {
+ replyQuote = quote;
+ }
+
+ @JsonProperty("replyto")
+ @XmlAttribute(name = "replyto")
+ public int getReplyto() {
+ return replyto;
+ }
+
+ public void setReplyto(int replyto) {
+ this.replyto = replyto;
+ }
+
+ @JsonProperty("tags")
+ @XmlElement(name = "tag")
+ public List<Tag> getTags() {
+ return tags;
+ }
+
+ public void setTags(List<Tag> tags) {
+ this.tags.clear();
+ if (CollectionUtils.isNotEmpty(tags))
+ this.tags.addAll(tags);
+ }
+
+ @XmlAttribute
+ public int getPrivacy() {
+ return privacy;
+ }
+
+ public void setPrivacy(int privacy) {
+ this.privacy = privacy;
+ }
+
+ public Photo getPhoto() {
+ return photo;
+ }
+
+ public void setPhoto(Photo photo) {
+ this.photo = photo;
+ }
+
+ @XmlAttribute(name = "attach")
+ @JsonProperty("attach")
+ public String getAttachmentType() {
+ return attachmentType;
+ }
+
+ public void setAttachmentType(String attachmentType) {
+ this.attachmentType = attachmentType;
+ }
+
+ @XmlTransient
+ public int getReplies() {
+ return replies;
+ }
+
+ public void setReplies(int replies) {
+ this.replies = replies;
+ }
+
+ @XmlTransient
+ public int getLikes() {
+ return likes;
+ }
+
+ public void setLikes(int likes) {
+ this.likes = likes;
+ }
+
+ @JsonProperty("repliesby")
+ public String getRepliesBy() {
+ return repliesBy;
+ }
+
+ public void setRepliesBy(String repliesBy) {
+ this.repliesBy = repliesBy;
+ }
+
+ public Attachment getAttachment() {
+ return attachment;
+ }
+ public void setAttachment(Attachment attachment) {
+ this.attachment = attachment;
+ }
+
+ /**
+ * @return timestamp of the last comment
+ */
+ @XmlTransient
+ public Instant getUpdated() {
+ return updated;
+ }
+
+ public void setUpdated(Instant updated) {
+ this.updated = updated;
+ }
+
+ @XmlTransient
+ public boolean isUnread() {
+ return unread;
+ }
+
+ public void setUnread(boolean unread) {
+ this.unread = unread;
+ }
+
+
+ @XmlTransient
+ public Set<Reaction> getReactions() {
+ return reactions;
+ }
+
+ public void setReactions(Set<Reaction> reactions) {
+ this.reactions = reactions;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(mid, rid);
+ }
+
+ public boolean isService() {
+ return service;
+ }
+
+ public void setService(boolean service) {
+ this.service = service;
+ }
+
+ public Set<String> getRecommendations() {
+ return recommendations;
+ }
+
+ public void setRecommendations(Set<String> recommendations) {
+ this.recommendations = recommendations;
+ }
+
+ /**
+ * @return timestamp of the last edit
+ */
+ @XmlTransient
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "UTC")
+ @JsonProperty("updated_at")
+ public Instant getUpdatedAt() {
+ return updatedAt;
+ }
+
+ public void setUpdatedAt(Instant updatedAt) {
+ this.updatedAt = updatedAt;
+ }
+
+ public URI getReplyUri() {
+ return replyUri;
+ }
+
+ public void setReplyUri(URI replyUri) {
+ this.replyUri = replyUri;
+ }
+
+ public boolean isHtml() {
+ return html;
+ }
+
+ public void setHtml(boolean html) {
+ this.html = html;
+ }
+
+ public URI getReplyToUri() {
+ return replyToUri;
+ }
+
+ public void setReplyToUri(URI replyToUri) {
+ this.replyToUri = replyToUri;
+ }
+}
diff --git a/src/main/java/com/juick/Photo.java b/src/main/java/com/juick/Photo.java
new file mode 100644
index 00000000..06299610
--- /dev/null
+++ b/src/main/java/com/juick/Photo.java
@@ -0,0 +1,53 @@
+/*
+ * 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;
+
+/**
+ * Created by vitalyster on 30.11.2016.
+ */
+// used for compatibility
+@Deprecated
+public class Photo {
+ private String small;
+ private String medium;
+ private String thumbnail;
+
+ public String getSmall() {
+ return small;
+ }
+
+ public void setSmall(String small) {
+ this.small = small;
+ }
+
+ public String getMedium() {
+ return medium;
+ }
+
+ public void setMedium(String medium) {
+ this.medium = medium;
+ }
+
+ public String getThumbnail() {
+ return thumbnail;
+ }
+
+ public void setThumbnail(String thumbnail) {
+ this.thumbnail = thumbnail;
+ }
+}
diff --git a/src/main/java/com/juick/Reaction.java b/src/main/java/com/juick/Reaction.java
new file mode 100644
index 00000000..536ac241
--- /dev/null
+++ b/src/main/java/com/juick/Reaction.java
@@ -0,0 +1,45 @@
+package com.juick;
+
+import org.apache.commons.lang3.builder.ToStringBuilder;
+
+public class Reaction {
+
+ public static final int LIKE = 1;
+
+ private final int id;
+ private String description;
+ private int count;
+
+ public Reaction(int id) {
+ this.id = id;
+ }
+
+ @Override
+ public String toString() {
+ return new ToStringBuilder(this)
+ .append("id", getId())
+ .append("description", description)
+ .append("count", count)
+ .build();
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public int getCount() {
+ return count;
+ }
+
+ public void setCount(int count) {
+ this.count = count;
+ }
+
+ public int getId() {
+ return id;
+ }
+}
diff --git a/src/main/java/com/juick/Status.java b/src/main/java/com/juick/Status.java
new file mode 100644
index 00000000..d7983536
--- /dev/null
+++ b/src/main/java/com/juick/Status.java
@@ -0,0 +1,49 @@
+/*
+ * 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;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Created by vitalyster on 25.07.2016.
+ */
+public class Status {
+ private final String value;
+
+ public static final Status OK = new Status("ok");
+ public static final Status FAIL = new Status("Fail");
+ public static final Status ERROR = new Status("Error");
+
+ public static Status getStatus(final String stringStatus) {
+ return new Status(stringStatus);
+ }
+
+ private Status(String value) {
+ this.value = value;
+ }
+
+ @JsonProperty("status")
+ public String getValue() {
+ return value;
+ }
+
+ @Override
+ public String toString() {
+ return "value = " + value;
+ }
+}
diff --git a/src/main/java/com/juick/Tag.java b/src/main/java/com/juick/Tag.java
new file mode 100644
index 00000000..b93d0e76
--- /dev/null
+++ b/src/main/java/com/juick/Tag.java
@@ -0,0 +1,73 @@
+/*
+ * 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;
+
+import com.fasterxml.jackson.annotation.JsonValue;
+
+import javax.xml.bind.annotation.*;
+import java.util.Comparator;
+import java.util.Objects;
+
+/**
+ * @author Ugnich Anton
+ */
+@XmlRootElement(name = "tag", namespace = "http://juick.com/message")
+@XmlAccessorType(XmlAccessType.FIELD)
+public class Tag implements Comparable<Tag> {
+ @XmlValue
+ private String name;
+
+ @XmlTransient
+ public int TID = 0;
+ @XmlTransient
+ public int SynonymID = 0;
+
+ public Tag() {
+ // required for (de)serialization
+ }
+
+ public Tag(String name) {
+ this.name = name;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ return o == this ||
+ (o instanceof Tag) && Objects.equals(name, ((Tag) o).name);
+ }
+
+ @XmlTransient
+ @JsonValue
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(name);
+ }
+
+ @Override
+ public String toString() {
+ return name;
+ }
+
+ @Override
+ public int compareTo(Tag o) {
+ return Objects.compare(name, o.getName(), Comparator.naturalOrder());
+ }
+}
diff --git a/src/main/java/com/juick/User.java b/src/main/java/com/juick/User.java
new file mode 100644
index 00000000..e2e45122
--- /dev/null
+++ b/src/main/java/com/juick/User.java
@@ -0,0 +1,226 @@
+/*
+ * 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;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+
+import javax.annotation.Nonnull;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlAttribute;
+import javax.xml.bind.annotation.XmlRootElement;
+import javax.xml.bind.annotation.XmlTransient;
+import java.net.URI;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * @author Ugnich Anton
+ */
+@XmlRootElement(name = "user", namespace = "http://juick.com/user")
+@XmlAccessorType()
+public class User {
+ private int uid;
+ private String name;
+ private Object avatar;
+ private String fullName;
+ private int messagesCount;
+ private String authHash;
+ private boolean banned;
+ private String credentials;
+ private List<ExternalToken> tokens;
+ private List<User> read;
+ private List<User> readers;
+ private List<Integer> unread;
+ private URI uri;
+ private Instant seen;
+
+ public User() {
+ tokens = new ArrayList<>();
+ uri = URI.create(StringUtils.EMPTY);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ return obj == this ||
+ (obj instanceof User && ((User) obj).getUid() == this.getUid());
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(uid);
+ }
+
+ @Override
+ public String toString() {
+ return new ToStringBuilder(this)
+ .append("uid", uid)
+ .append("name", name)
+ .append("fullName", fullName)
+ .append("messagesCount", messagesCount)
+ .append("banned", banned)
+ .toString();
+ }
+
+ @JsonProperty("uid")
+ @XmlAttribute(name = "uid")
+ public int getUid() {
+ return uid;
+ }
+
+ public void setUid(int uid) {
+ this.uid = uid;
+ }
+
+ @JsonProperty("uname")
+ @XmlAttribute(name = "uname")
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ @JsonProperty("fullname")
+ @XmlTransient
+ public String getFullName() {
+ return fullName;
+ }
+
+ public void setFullName(String fullName) {
+ this.fullName = fullName;
+ }
+
+ @XmlTransient
+ @JsonIgnore
+ public String getAuthHash() {
+ return authHash;
+ }
+
+ public void setAuthHash(String authHash) {
+ this.authHash = authHash;
+ }
+
+ @JsonProperty("unreadCount")
+ @XmlTransient
+ public Integer getUnreadCount() {
+ return messagesCount;
+ }
+
+ public void setUnreadCount(Integer count) {
+ this.messagesCount = count;
+ }
+
+ @XmlTransient
+ public boolean isBanned() {
+ return banned;
+ }
+
+ public void setBanned(boolean banned) {
+ this.banned = banned;
+ }
+
+ public Object getAvatar() {
+ return avatar;
+ }
+
+ public void setAvatar(Object avatar) {
+ this.avatar = avatar;
+ }
+
+ @XmlTransient
+ @JsonIgnore
+ public String getCredentials() {
+ return credentials;
+ }
+
+ public void setCredentials(String credentials) {
+ this.credentials = credentials;
+ }
+
+ @XmlTransient
+ public int getMessagesCount() {
+ return messagesCount;
+ }
+
+ public void setMessagesCount(int messagesCount) {
+ this.messagesCount = messagesCount;
+ }
+
+ @XmlTransient
+ @JsonIgnore
+ public boolean isAnonymous() {
+ return false;
+ }
+
+ @Nonnull
+ public List<ExternalToken> getTokens() {
+ return tokens;
+ }
+
+ public void setTokens(List<ExternalToken> tokens) {
+ this.tokens = tokens;
+ }
+
+ public List<User> getRead() {
+ return read;
+ }
+ public List<User> getReaders() {
+ return readers;
+ }
+
+ public void setRead(List<User> read) {
+ this.read = read;
+ }
+
+ public void setReaders(List<User> readers) {
+ this.readers = readers;
+ }
+
+ public List<Integer> getUnread() {
+ return unread;
+ }
+
+ public void setUnread(List<Integer> unread) {
+ this.unread = unread;
+ }
+
+ @Nonnull
+ public URI getUri() {
+ if (uri == null) {
+ uri = URI.create(StringUtils.EMPTY);
+ }
+ return uri;
+ }
+
+ public void setUri(URI uri) {
+ this.uri = uri;
+ }
+
+ public Instant getSeen() {
+ return seen;
+ }
+
+ public void setSeen(Instant seen) {
+ this.seen = seen;
+ }
+}
diff --git a/src/main/java/com/juick/adapters/SimpleDateAdapter.java b/src/main/java/com/juick/adapters/SimpleDateAdapter.java
new file mode 100644
index 00000000..b8e08599
--- /dev/null
+++ b/src/main/java/com/juick/adapters/SimpleDateAdapter.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.adapters;
+
+import com.juick.util.DateFormattersHolder;
+
+import javax.xml.bind.annotation.adapters.XmlAdapter;
+import java.time.Instant;
+
+/**
+ * Created by vitalyster on 15.11.2016.
+ */
+
+public class SimpleDateAdapter extends XmlAdapter<String, Instant> {
+
+ @Override
+ public String marshal(Instant v) throws Exception {
+ return DateFormattersHolder.getMessageFormatterInstance().format(v);
+ }
+
+ @Override
+ public Instant unmarshal(String v) throws Exception {
+ return DateFormattersHolder.getMessageFormatterInstance().parse(v);
+ }
+}
diff --git a/src/main/java/com/juick/formatters/PlainTextFormatter.java b/src/main/java/com/juick/formatters/PlainTextFormatter.java
new file mode 100644
index 00000000..378a523f
--- /dev/null
+++ b/src/main/java/com/juick/formatters/PlainTextFormatter.java
@@ -0,0 +1,97 @@
+/*
+ * 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.formatters;
+
+import com.juick.Message;
+import com.juick.util.MessageUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.ocpsoft.prettytime.PrettyTime;
+
+import java.util.Date;
+import java.util.Locale;
+
+/**
+ * Created by vitalyster on 12.10.2016.
+ */
+public class PlainTextFormatter {
+ static PrettyTime pt = new PrettyTime(new Locale("en"));
+
+ public static String formatPost(Message jmsg) {
+ return formatPost(jmsg, false);
+ }
+
+ public static String formatPost(Message jmsg, boolean markdown) {
+ StringBuilder sb = new StringBuilder();
+ String title = MessageUtils.isReply(jmsg) ? "Reply by @" : MessageUtils.isPM(jmsg) ? "Private message from @" : "@";
+ String subtitle = MessageUtils.isReply(jmsg) ? markdown ? MessageUtils.escapeMarkdown(StringUtils.defaultString(jmsg.getReplyQuote()))
+ : jmsg.getReplyQuote()
+ : markdown ? MessageUtils.getMarkdownTags(jmsg) : MessageUtils.getTagsString(jmsg);
+ sb.append(title).append(markdown ? MessageUtils.getMarkdownUser(jmsg.getUser()) : jmsg.getUser().getName()).append(":\n")
+ .append(subtitle).append("\n");
+ if (markdown) {
+ sb.append(MessageUtils.formatMarkdownText(jmsg));
+ } else {
+ sb.append(StringUtils.defaultString(jmsg.getText()));
+ }
+ sb.append("\n");
+ if (!markdown && StringUtils.isNotEmpty(jmsg.getAttachmentType())) {
+ sb.append(MessageUtils.attachmentUrl(jmsg));
+ }
+ return sb.toString();
+ }
+
+ public static String formatPostSummary(Message m) {
+ int cropLength = 384;
+ String timeAgo = pt.format(Date.from(m.getTimestamp()));
+ String repliesCount = m.getReplies() == 1 ? "; 1 reply" : m.getReplies() == 0 ? ""
+ : String.format("; %d replies", m.getReplies());
+ StringBuilder sb = new StringBuilder();
+ String txt = StringUtils.defaultString(m.getText());
+ String attachmentUrl = MessageUtils.attachmentUrl(m);
+ if (StringUtils.isNotEmpty(attachmentUrl)) {
+ sb.append(attachmentUrl).append("\n");
+ }
+ if (txt.length() >= cropLength) {
+ sb.append(StringUtils.substring(txt, 0, cropLength)).append(" [...]");
+ } else {
+ sb.append(txt);
+ }
+ return String.format("@%s:%s\n%s\n#%s (%s%s) %s",
+ m.getUser().getName(), MessageUtils.getTagsString(m), sb.toString(), formatPostNumber(m), timeAgo, repliesCount, formatUrl(m));
+ }
+
+ public static String formatUrl(com.juick.Message jmsg) {
+ if (MessageUtils.isReply(jmsg)) {
+ return String.format("https://juick.com/m/%d#%d", jmsg.getMid(), jmsg.getRid());
+ } else if (MessageUtils.isPM(jmsg)) {
+ return "https://juick.com/pm/inbox";
+ }
+ return "https://juick.com/m/" + jmsg.getMid();
+ }
+
+ public static String formatPostNumber(com.juick.Message jmsg) {
+ if (jmsg.getRid() > 0) {
+ return String.format("%d/%d", jmsg.getMid(), jmsg.getRid());
+ }
+ return String.format("%d", jmsg.getMid());
+ }
+
+ public static String formatTwitterCard(Message jmsg) {
+ return MessageUtils.getMessageHashTags(jmsg) + StringUtils.defaultString(jmsg.getText());
+ }
+}
diff --git a/src/main/java/com/juick/model/AnonymousUser.java b/src/main/java/com/juick/model/AnonymousUser.java
new file mode 100644
index 00000000..f4511194
--- /dev/null
+++ b/src/main/java/com/juick/model/AnonymousUser.java
@@ -0,0 +1,129 @@
+/*
+ * 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.model;
+
+import com.juick.User;
+
+/**
+ * Created by aalexeev on 12/11/16.
+ */
+public final class AnonymousUser extends User {
+ public static final AnonymousUser INSTANCE = new AnonymousUser();
+
+ private AnonymousUser() {
+ super.setUid(getUid());
+ super.setName(getName());
+ super.setAvatar(getAvatar());
+ super.setFullName(getFullName());
+ super.setMessagesCount(getMessagesCount());
+ super.setAuthHash(getAuthHash());
+ super.setBanned(isBanned());
+ super.setCredentials(getCredentials());
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ return obj == this || obj instanceof AnonymousUser;
+ }
+
+ @Override
+ public int getUid() {
+ return 0;
+ }
+
+ @Override
+ public String getName() {
+ return "Anonymous";
+ }
+
+ @Override
+ public String getFullName() {
+ return getName();
+ }
+
+ @Override
+ public String getAuthHash() {
+ return null;
+ }
+
+ @Override
+ public Integer getUnreadCount() {
+ return 0;
+ }
+
+ @Override
+ public boolean isBanned() {
+ return false;
+ }
+
+ @Override
+ public Object getAvatar() {
+ return null;
+ }
+
+ @Override
+ public String getCredentials() {
+ return null;
+ }
+
+ @Override
+ public int getMessagesCount() {
+ return 0;
+ }
+
+ @Override
+ public boolean isAnonymous() {
+ return true;
+ }
+
+ @Override
+ public void setUid(int uid) {
+ }
+
+ @Override
+ public void setName(String name) {
+ }
+
+ @Override
+ public void setFullName(String fullName) {
+ }
+
+ @Override
+ public void setAuthHash(String authHash) {
+ }
+
+ @Override
+ public void setUnreadCount(Integer count) {
+ }
+
+ @Override
+ public void setBanned(boolean banned) {
+ }
+
+ @Override
+ public void setAvatar(Object avatar) {
+ }
+
+ @Override
+ public void setCredentials(String credentials) {
+ }
+
+ @Override
+ public void setMessagesCount(int messagesCount) {
+ }
+}
diff --git a/src/main/java/com/juick/model/ApplicationStatus.java b/src/main/java/com/juick/model/ApplicationStatus.java
new file mode 100644
index 00000000..b18e12bf
--- /dev/null
+++ b/src/main/java/com/juick/model/ApplicationStatus.java
@@ -0,0 +1,52 @@
+/*
+ * 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.model;
+
+import org.apache.commons.lang3.builder.ToStringBuilder;
+
+/**
+ * Created by vt on 03/09/16.
+ */
+public class ApplicationStatus {
+ private boolean connected;
+ private boolean crosspostEnabled;
+
+ @Override
+ public String toString() {
+ return new ToStringBuilder(this)
+ .append("connected", connected)
+ .append("crosspostEnabled", crosspostEnabled)
+ .toString();
+ }
+
+ public boolean isConnected() {
+ return connected;
+ }
+
+ public void setConnected(boolean connected) {
+ this.connected = connected;
+ }
+
+ public boolean isCrosspostEnabled() {
+ return crosspostEnabled;
+ }
+
+ public void setCrosspostEnabled(boolean crosspostEnabled) {
+ this.crosspostEnabled = crosspostEnabled;
+ }
+}
diff --git a/src/main/java/com/juick/model/Auth.java b/src/main/java/com/juick/model/Auth.java
new file mode 100644
index 00000000..66125567
--- /dev/null
+++ b/src/main/java/com/juick/model/Auth.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.model;
+
+/**
+ * Created by vt on 09/02/16.
+ */
+public class Auth {
+ private String account;
+ private String authCode;
+
+ public Auth(String account, String authCode) {
+ this.account = account;
+ this.authCode = authCode;
+ }
+
+ public String getAccount() {
+ return account;
+ }
+
+ public String getAuthCode() {
+ return authCode;
+ }
+} \ No newline at end of file
diff --git a/src/main/java/com/juick/model/CommandResult.java b/src/main/java/com/juick/model/CommandResult.java
new file mode 100644
index 00000000..c310756c
--- /dev/null
+++ b/src/main/java/com/juick/model/CommandResult.java
@@ -0,0 +1,35 @@
+package com.juick.model;
+
+import com.juick.Message;
+
+import java.util.Optional;
+
+public class CommandResult {
+ private String text;
+ private String markdown;
+ private Message newMessage;
+
+ public String getText() {
+ return text;
+ }
+ public String getMarkdown() {
+ return markdown;
+ }
+
+ public Optional<Message> getNewMessage() {
+ return Optional.ofNullable(newMessage);
+ }
+ public static CommandResult build(Message newMessage, String text, String markdown) {
+ CommandResult result = new CommandResult();
+ result.newMessage = newMessage;
+ result.text = text;
+ result.markdown = markdown;
+ return result;
+ }
+ public static CommandResult fromString(String text) {
+ CommandResult result = new CommandResult();
+ result.text = text;
+ return result;
+ }
+
+}
diff --git a/src/main/java/com/juick/model/NotifyOpts.java b/src/main/java/com/juick/model/NotifyOpts.java
new file mode 100644
index 00000000..1c0e0aac
--- /dev/null
+++ b/src/main/java/com/juick/model/NotifyOpts.java
@@ -0,0 +1,51 @@
+/*
+ * 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.model;
+
+/**
+ * Created by vt on 03/09/16.
+ */
+public class NotifyOpts {
+ private boolean repliesEnabled;
+ private boolean subscriptionsEnabled;
+ private boolean recommendationsEnabled;
+
+ public boolean isRepliesEnabled() {
+ return repliesEnabled;
+ }
+
+ public void setRepliesEnabled(boolean repliesEnabled) {
+ this.repliesEnabled = repliesEnabled;
+ }
+
+ public boolean isSubscriptionsEnabled() {
+ return subscriptionsEnabled;
+ }
+
+ public void setSubscriptionsEnabled(boolean subscriptionsEnabled) {
+ this.subscriptionsEnabled = subscriptionsEnabled;
+ }
+
+ public boolean isRecommendationsEnabled() {
+ return recommendationsEnabled;
+ }
+
+ public void setRecommendationsEnabled(boolean recommendationsEnabled) {
+ this.recommendationsEnabled = recommendationsEnabled;
+ }
+}
diff --git a/src/main/java/com/juick/model/PrivacyOpts.java b/src/main/java/com/juick/model/PrivacyOpts.java
new file mode 100644
index 00000000..52cbe588
--- /dev/null
+++ b/src/main/java/com/juick/model/PrivacyOpts.java
@@ -0,0 +1,46 @@
+/*
+ * 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.model;
+
+/**
+ * Created by vt on 16/01/16.
+ */
+public class PrivacyOpts {
+ private int uid;
+ private int privacy;
+
+ public PrivacyOpts() {
+
+ }
+
+ public int getUid() {
+ return uid;
+ }
+
+ public void setUid(int uid) {
+ this.uid = uid;
+ }
+
+ public int getPrivacy() {
+ return privacy;
+ }
+
+ public void setPrivacy(int privacy) {
+ this.privacy = privacy;
+ }
+}
diff --git a/src/main/java/com/juick/model/PrivateChats.java b/src/main/java/com/juick/model/PrivateChats.java
new file mode 100644
index 00000000..b6bb48ab
--- /dev/null
+++ b/src/main/java/com/juick/model/PrivateChats.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.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.juick.Chat;
+
+import java.util.List;
+
+/**
+ * Created by vt on 24/11/2016.
+ */
+public class PrivateChats {
+ private List<Chat> users;
+
+ @JsonProperty("pms")
+ public List<Chat> getUsers() {
+ return users;
+ }
+
+ public void setUsers(List<Chat> users) {
+ this.users = users;
+ }
+}
diff --git a/src/main/java/com/juick/model/ResponseReply.java b/src/main/java/com/juick/model/ResponseReply.java
new file mode 100644
index 00000000..183c6f72
--- /dev/null
+++ b/src/main/java/com/juick/model/ResponseReply.java
@@ -0,0 +1,98 @@
+/*
+ * 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.model;
+
+import java.util.Date;
+
+/**
+ * Created by vitalyster on 13.12.2016.
+ */
+public class ResponseReply {
+ private String muname;
+ private int mid;
+ private int rid;
+ private String uname;
+ private String description;
+ private Date pubDate;
+ private String attachmentType;
+ private boolean html;
+
+ public String getMuname() {
+ return muname;
+ }
+
+ public void setMuname(String muname) {
+ this.muname = muname;
+ }
+
+ public int getMid() {
+ return mid;
+ }
+
+ public void setMid(int mid) {
+ this.mid = mid;
+ }
+
+ public int getRid() {
+ return rid;
+ }
+
+ public void setRid(int rid) {
+ this.rid = rid;
+ }
+
+ public String getUname() {
+ return uname;
+ }
+
+ public void setUname(String uname) {
+ this.uname = uname;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public Date getPubDate() {
+ return pubDate;
+ }
+
+ public void setPubDate(Date pubDate) {
+ this.pubDate = pubDate;
+ }
+
+ public String getAttachmentType() {
+ return attachmentType;
+ }
+
+ public void setAttachmentType(String attachmentType) {
+ this.attachmentType = attachmentType;
+ }
+
+ public boolean isHtml() {
+ return html;
+ }
+
+ public void setHtml(boolean html) {
+ this.html = html;
+ }
+}
diff --git a/src/main/java/com/juick/model/TagStats.java b/src/main/java/com/juick/model/TagStats.java
new file mode 100644
index 00000000..da2f3f92
--- /dev/null
+++ b/src/main/java/com/juick/model/TagStats.java
@@ -0,0 +1,46 @@
+/*
+ * 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.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.juick.Tag;
+
+/**
+ * Created by vitalyster on 01.12.2016.
+ */
+public class TagStats {
+ private Tag tag;
+ private int usageCount;
+
+ public Tag getTag() {
+ return tag;
+ }
+
+ public void setTag(Tag tag) {
+ this.tag = tag;
+ }
+
+ @JsonProperty("messages")
+ public int getUsageCount() {
+ return usageCount;
+ }
+
+ public void setUsageCount(int usageCount) {
+ this.usageCount = usageCount;
+ }
+}
diff --git a/src/main/java/com/juick/model/UserInfo.java b/src/main/java/com/juick/model/UserInfo.java
new file mode 100644
index 00000000..ca5d75e0
--- /dev/null
+++ b/src/main/java/com/juick/model/UserInfo.java
@@ -0,0 +1,60 @@
+/*
+ * 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.model;
+
+/**
+ * Created by vt on 03/09/16.
+ */
+public class UserInfo {
+ private String fullName;
+ private String country;
+ private String url;
+ private String description;
+
+ public String getFullName() {
+ return fullName;
+ }
+
+ public void setFullName(String fullName) {
+ this.fullName = fullName;
+ }
+
+ public String getCountry() {
+ return country;
+ }
+
+ public void setCountry(String country) {
+ this.country = country;
+ }
+
+ public String getUrl() {
+ return url;
+ }
+
+ public void setUrl(String url) {
+ this.url = url;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+}
diff --git a/src/main/java/com/juick/model/facebook/User.java b/src/main/java/com/juick/model/facebook/User.java
new file mode 100644
index 00000000..80838de6
--- /dev/null
+++ b/src/main/java/com/juick/model/facebook/User.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.model.facebook;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Created by vitalyster on 28.11.2016.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class User {
+ private String id;
+ private String name;
+ private String link;
+ private boolean verified;
+ private String firstName;
+ private String lastName;
+ private String gender;
+ private String locale;
+ private String timezone;
+ private String updatedTime;
+ private String email;
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getLink() {
+ return link;
+ }
+
+ public void setLink(String link) {
+ this.link = link;
+ }
+
+ public boolean getVerified() {
+ return verified;
+ }
+
+ public void setVerified(boolean verified) {
+ this.verified = verified;
+ }
+
+ @JsonProperty("first_name")
+ public String getFirstName() {
+ return firstName;
+ }
+ public void setFirstName(String firstName) {
+ this.firstName = firstName;
+ }
+
+ public String getGender() {
+ return gender;
+ }
+
+ public void setGender(String gender) {
+ this.gender = gender;
+ }
+
+ @JsonProperty("last_name")
+ public String getLastName() {
+ return lastName;
+ }
+
+ public void setLastName(String lastName) {
+ this.lastName = lastName;
+ }
+
+ public String getLocale() {
+ return locale;
+ }
+
+ public void setLocale(String locale) {
+ this.locale = locale;
+ }
+
+ public String getTimezone() {
+ return timezone;
+ }
+
+ public void setTimezone(String timezone) {
+ this.timezone = timezone;
+ }
+
+ @JsonProperty("updated_time")
+ public String getUpdatedTime() {
+ return updatedTime;
+ }
+
+ public void setUpdatedTime(String updatedTime) {
+ this.updatedTime = updatedTime;
+ }
+
+ public String getEmail() {
+ return email;
+ }
+}
diff --git a/src/main/java/com/juick/model/twitter/User.java b/src/main/java/com/juick/model/twitter/User.java
new file mode 100644
index 00000000..3c80eff4
--- /dev/null
+++ b/src/main/java/com/juick/model/twitter/User.java
@@ -0,0 +1,38 @@
+/*
+ * 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.model.twitter;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Created by vitalyster on 28.11.2016.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class User {
+ private String screenName;
+
+ @JsonProperty("screen_name")
+ public String getScreenName() {
+ return screenName;
+ }
+
+ public void setScreenName(String screenName) {
+ this.screenName = screenName;
+ }
+}
diff --git a/src/main/java/com/juick/model/vk/Token.java b/src/main/java/com/juick/model/vk/Token.java
new file mode 100644
index 00000000..ed93a3ab
--- /dev/null
+++ b/src/main/java/com/juick/model/vk/Token.java
@@ -0,0 +1,56 @@
+/*
+ * 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.model.vk;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Created by vitalyster on 28.11.2016.
+ */
+public class Token {
+ private Long userId;
+ private String accessToken;
+ private String expiresIn;
+
+ @JsonProperty("user_id")
+ public Long getUserId() {
+ return userId;
+ }
+
+ public void setUserId(Long userId) {
+ this.userId = userId;
+ }
+
+ @JsonProperty("access_token")
+ public String getAccessToken() {
+ return accessToken;
+ }
+
+ public void setAccessToken(String accessToken) {
+ this.accessToken = accessToken;
+ }
+
+ @JsonProperty("expires_in")
+ public String getExpiresIn() {
+ return expiresIn;
+ }
+
+ public void setExpiresIn(String expiresIn) {
+ this.expiresIn = expiresIn;
+ }
+}
diff --git a/src/main/java/com/juick/model/vk/User.java b/src/main/java/com/juick/model/vk/User.java
new file mode 100644
index 00000000..aeb18285
--- /dev/null
+++ b/src/main/java/com/juick/model/vk/User.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.model.vk;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Created by vitalyster on 28.11.2016.
+ */
+public class User {
+ private String id;
+ private String firstName;
+ private String lastName;
+ private String screenName;
+
+ @JsonProperty("first_name")
+ public String getFirstName() {
+ return firstName;
+ }
+
+ public void setFirstName(String firstName) {
+ this.firstName = firstName;
+ }
+
+ @JsonProperty("last_name")
+ public String getLastName() {
+ return lastName;
+ }
+
+ public void setLastName(String lastName) {
+ this.lastName = lastName;
+ }
+
+ @JsonProperty("screen_name")
+ public String getScreenName() {
+ return screenName;
+ }
+
+ public void setScreenName(String screenName) {
+ this.screenName = screenName;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+}
diff --git a/src/main/java/com/juick/model/vk/UsersResponse.java b/src/main/java/com/juick/model/vk/UsersResponse.java
new file mode 100644
index 00000000..67505703
--- /dev/null
+++ b/src/main/java/com/juick/model/vk/UsersResponse.java
@@ -0,0 +1,38 @@
+/*
+ * 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.model.vk;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+
+/**
+ * Created by vitalyster on 28.11.2016.
+ */
+public class UsersResponse {
+ private List<User> users;
+
+ @JsonProperty("response")
+ public List<User> getUsers() {
+ return users;
+ }
+
+ public void setUsers(List<User> users) {
+ this.users = users;
+ }
+}
diff --git a/src/main/java/com/juick/package-info.java b/src/main/java/com/juick/package-info.java
new file mode 100644
index 00000000..c9023417
--- /dev/null
+++ b/src/main/java/com/juick/package-info.java
@@ -0,0 +1,35 @@
+/*
+ * 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/>.
+ */
+
+/**
+ * Created by vitalyster on 15.11.2016.
+ */
+@XmlSchema(
+ namespace="http://juick.com/message",
+ elementFormDefault = XmlNsForm.QUALIFIED,
+ xmlns={
+ @XmlNs(prefix= StringUtils.EMPTY, namespaceURI="http://juick.com/message"),
+ @XmlNs(prefix="user", namespaceURI="http://juick.com/user")
+ }
+)
+package com.juick;
+
+import org.apache.commons.lang3.StringUtils;
+
+import javax.xml.bind.annotation.XmlNs;
+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/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);
+ }
+}
diff --git a/src/main/java/com/juick/service/ActivityPubService.java b/src/main/java/com/juick/service/ActivityPubService.java
new file mode 100644
index 00000000..892022cf
--- /dev/null
+++ b/src/main/java/com/juick/service/ActivityPubService.java
@@ -0,0 +1,59 @@
+package com.juick.service;
+
+import com.juick.User;
+import com.juick.model.AnonymousUser;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.util.UriComponents;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import javax.annotation.Nonnull;
+import javax.inject.Inject;
+import java.util.List;
+
+@Repository
+public class ActivityPubService extends BaseJdbcService implements SocialService {
+ @Value("${ap_base_uri:http://localhost:8080/}")
+ private String baseUri;
+ @Inject
+ private UserService userService;
+
+ @Transactional(readOnly = true)
+ @Override
+ public @Nonnull User getUserByAccountUri(String acct) {
+ UriComponents baseUriComponents = UriComponentsBuilder.fromUriString(baseUri).build();
+ UriComponents acctComponents = UriComponentsBuilder.fromUriString(acct).build();
+ if (acctComponents.getHost().equals(baseUriComponents.getHost())) {
+ // /u/ugnich -> ugnich
+ String userName = acctComponents.getPath().substring(3);
+ return userService.getUserByName(userName);
+ }
+ return AnonymousUser.INSTANCE;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public @Nonnull List<String> getFollowers(User user) {
+ return getJdbcTemplate().queryForList("SELECT acct FROM followers WHERE user_id=?", String.class, user.getUid());
+ }
+
+ @Transactional
+ @Override
+ public void addFollower(User user, String acct) {
+ getJdbcTemplate().update("INSERT INTO followers(user_id, acct) " +
+ "VALUES(?, ?)", user.getUid(), acct);
+ }
+
+ @Transactional
+ @Override
+ public void removeFollower(User user, String acct) {
+ getJdbcTemplate().update("DELETE FROM followers WHERE user_id=? AND acct=?", user.getUid(), acct);
+ }
+
+ @Transactional
+ @Override
+ public void removeAccount(String acct) {
+ getJdbcTemplate().update("DELETE FROM followers WHERE acct=?", acct);
+ }
+}
diff --git a/src/main/java/com/juick/service/BaseJdbcService.java b/src/main/java/com/juick/service/BaseJdbcService.java
new file mode 100644
index 00000000..496a04ba
--- /dev/null
+++ b/src/main/java/com/juick/service/BaseJdbcService.java
@@ -0,0 +1,41 @@
+/*
+ * 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.service;
+
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
+
+import javax.inject.Inject;
+
+/**
+ * Created by aalexeev on 11/13/16.
+ */
+public class BaseJdbcService {
+ @Inject
+ JdbcTemplate jdbcTemplate;
+ @Inject
+ NamedParameterJdbcTemplate namedParameterJdbcTemplate;
+
+ public NamedParameterJdbcTemplate getNamedParameterJdbcTemplate() {
+ return namedParameterJdbcTemplate;
+ }
+
+ public JdbcTemplate getJdbcTemplate() {
+ return jdbcTemplate;
+ }
+}
diff --git a/src/main/java/com/juick/service/CrosspostService.java b/src/main/java/com/juick/service/CrosspostService.java
new file mode 100644
index 00000000..99911250
--- /dev/null
+++ b/src/main/java/com/juick/service/CrosspostService.java
@@ -0,0 +1,86 @@
+/*
+ * 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.service;
+
+import com.juick.ExternalToken;
+import com.juick.model.ApplicationStatus;
+import org.apache.commons.lang3.tuple.Pair;
+
+import javax.annotation.Nonnull;
+import java.util.Optional;
+
+/**
+ * Created by aalexeev on 11/13/16.
+ */
+public interface CrosspostService {
+
+ Optional<ExternalToken> getTwitterToken(int uid);
+
+ boolean deleteTwitterToken(Integer uid);
+
+ void addFacebookState(String state, String redirectUri);
+
+ void addVKState(String state, String redirectUri);
+
+ String verifyFacebookState(String state);
+
+ String verifyVKState(String state);
+
+ Optional<Pair<String, String>> getFacebookTokens(int uid);
+
+ ApplicationStatus getFbCrossPostStatus(int uid);
+
+ boolean enableFBCrosspost(Integer uid);
+
+ void disableFBCrosspost(Integer uid);
+
+ @Nonnull
+ String getTwitterName(int uid);
+
+ String getTelegramName(int uid);
+
+ Optional<Pair<String, String>> getVkTokens(int uid);
+
+ void deleteVKUser(Integer uid);
+
+ int getUIDbyFBID(long fbID);
+
+ boolean createFacebookUser(long fbID, String loginhash, String token, String fbName, String fbLink);
+
+ boolean updateFacebookUser(long fbID, String token, String fbName, String fbLink);
+
+ int getUIDbyVKID(long vkID);
+
+ boolean createVKUser(long vkID, String loginhash, String token, String vkName, String vkLink);
+
+ String getFacebookNameByHash(String hash);
+
+ String getTelegramNameByHash(String hash);
+
+ boolean setFacebookUser(String hash, int uid);
+
+ String getVKNameByHash(String hash);
+
+ boolean setVKUser(String hash, int uid);
+
+ boolean setTelegramUser(String hash, int uid);
+
+ String getJIDByHash(String hash);
+
+ boolean setJIDUser(String hash, int uid);
+}
diff --git a/src/main/java/com/juick/service/CrosspostServiceImpl.java b/src/main/java/com/juick/service/CrosspostServiceImpl.java
new file mode 100644
index 00000000..d190faba
--- /dev/null
+++ b/src/main/java/com/juick/service/CrosspostServiceImpl.java
@@ -0,0 +1,282 @@
+/*
+ * 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.service;
+
+import com.juick.ExternalToken;
+import com.juick.model.ApplicationStatus;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.tuple.Pair;
+import org.springframework.dao.EmptyResultDataAccessException;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Created by aalexeev on 11/13/16.
+ */
+@Repository
+public class CrosspostServiceImpl extends BaseJdbcService implements CrosspostService {
+
+ @Transactional(readOnly = true)
+ @Override
+ public Optional<ExternalToken> getTwitterToken(final int uid) {
+ List<ExternalToken> list = getJdbcTemplate().query(
+ "SELECT uname, access_token, access_token_secret FROM twitter WHERE user_id = ? AND crosspost = 1",
+ (rs, num) -> new ExternalToken(rs.getString(1), "twitter",
+ rs.getString(2), rs.getString(3)),
+ uid);
+
+ return list.isEmpty() ?
+ Optional.empty() : Optional.of(list.get(0));
+ }
+
+ @Transactional
+ @Override
+ public boolean deleteTwitterToken(Integer uid) {
+ return getJdbcTemplate().update("DELETE FROM twitter WHERE user_id=?", uid) > 0;
+ }
+
+ @Override
+ public void addFacebookState(String state, String redirectUri) {
+ jdbcTemplate.update("INSERT INTO facebook(loginhash, fb_link) VALUES(?, ?)", state, redirectUri);
+ }
+
+ @Override
+ public void addVKState(String state, String redirectUri) {
+ jdbcTemplate.update("INSERT INTO vk(loginhash, vk_link) VALUES(?, ?)", state, redirectUri);
+ }
+
+ @Override
+ public String verifyFacebookState(String state) {
+ try {
+ return jdbcTemplate.queryForObject("SELECT fb_link FROM facebook WHERE loginhash=?",
+ String.class, state);
+ } catch (EmptyResultDataAccessException e) {
+ return StringUtils.EMPTY;
+ }
+ }
+
+ @Override
+ public String verifyVKState(String state) {
+ try {
+ return jdbcTemplate.queryForObject("SELECT vk_link FROM vk WHERE loginhash=?",
+ String.class, state);
+ } catch (EmptyResultDataAccessException e) {
+ return StringUtils.EMPTY;
+ }
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public Optional<Pair<String, String>> getFacebookTokens(final int uid) {
+ List<Optional<Pair<String, String>>> list = getJdbcTemplate().query(
+ "SELECT fb_id, access_token FROM facebook WHERE user_id = ? AND access_token IS NOT NULL AND crosspost = 1",
+ (rs, num) -> Optional.of(Pair.of(rs.getString(1), rs.getString(2))),
+ uid);
+ return list.isEmpty() ?
+ Optional.empty() : list.get(0);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public ApplicationStatus getFbCrossPostStatus(final int uid) {
+ List<ApplicationStatus> list = getJdbcTemplate().query(
+ "SELECT 1, crosspost FROM facebook WHERE user_id = ? LIMIT 1",
+ (rs, num) -> {
+ ApplicationStatus status = new ApplicationStatus();
+
+ status.setConnected(rs.getInt(1) > 0);
+ status.setCrosspostEnabled(rs.getBoolean(2));
+
+ return status;
+ },
+ uid);
+
+ return list.isEmpty() ?
+ new ApplicationStatus() : list.get(0);
+ }
+
+ @Transactional
+ @Override
+ public boolean enableFBCrosspost(Integer uid) {
+ return getJdbcTemplate().update("UPDATE facebook SET crosspost=1 WHERE user_id=?", uid) > 0;
+ }
+
+ @Transactional
+ @Override
+ public void disableFBCrosspost(Integer uid) {
+ getJdbcTemplate().update("UPDATE facebook SET crosspost=0 WHERE user_id=?", uid);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public String getTwitterName(final int uid) {
+ List<String> list = getJdbcTemplate().queryForList(
+ "SELECT uname FROM twitter WHERE user_id = ?",
+ String.class,
+ uid);
+
+ return list.isEmpty() ?
+ StringUtils.EMPTY : list.get(0);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public String getTelegramName(final int uid) {
+ List<String> list = getJdbcTemplate().queryForList(
+ "SELECT tg_name FROM telegram WHERE user_id = ?",
+ String.class,
+ uid);
+
+ return list.isEmpty() ?
+ StringUtils.EMPTY : list.get(0);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public Optional<Pair<String, String>> getVkTokens(final int uid) {
+ List<Optional<Pair<String, String>>> list = getJdbcTemplate().query(
+ "SELECT vk_id, access_token FROM vk WHERE user_id = ? AND crosspost = 1",
+ (rs, num) -> Optional.of(Pair.of(rs.getString(1), rs.getString(2))),
+ uid);
+
+ return list.isEmpty() ?
+ Optional.empty() : list.get(0);
+ }
+
+ @Transactional
+ @Override
+ public void deleteVKUser(Integer uid) {
+ getJdbcTemplate().update("DELETE FROM vk WHERE user_id=?", uid);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public int getUIDbyFBID(long fbID) {
+ try {
+ return getJdbcTemplate().queryForObject("SELECT user_id FROM facebook WHERE fb_id=? AND user_id IS NOT NULL",
+ Integer.class, fbID);
+ } catch (EmptyResultDataAccessException e) {
+ return 0;
+ }
+ }
+
+ @Transactional
+ @Override
+ public boolean createFacebookUser(long fbID, String loginhash, String token, String fbName, String fbLink) {
+ return getJdbcTemplate().update("UPDATE facebook SET fb_id=?, access_token=?, fb_name=?, fb_link=? WHERE loginhash=?",
+ fbID, token, fbName, fbLink, loginhash) > 0;
+ }
+
+ @Transactional
+ @Override
+ public boolean updateFacebookUser(long fbID, String token, String fbName, String fbLink) {
+ return getJdbcTemplate().update("UPDATE facebook SET access_token=?,fb_name=?,fb_link=? WHERE fb_id=?",
+ token, fbName, fbLink, fbID) > 0;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public int getUIDbyVKID(long vkID) {
+ try {
+ return getJdbcTemplate().queryForObject("SELECT user_id FROM vk WHERE vk_id=? AND user_id IS NOT NULL", Integer.class, vkID);
+ } catch (EmptyResultDataAccessException e) {
+ return 0;
+ }
+ }
+
+ @Transactional
+ @Override
+ public boolean createVKUser(long vkID, String loginhash, String token, String vkName, String vkLink) {
+ return getJdbcTemplate().update("INSERT INTO vk(vk_id,loginhash,access_token,vk_name,vk_link) VALUES (?,?,?,?,?)",
+ vkID, loginhash, token, vkName, vkLink) > 0;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public String getFacebookNameByHash(String hash) {
+ try {
+ List<Pair<String, String>> fb = getJdbcTemplate().query("SELECT fb_name,fb_link FROM facebook WHERE loginhash=?",
+ (rs, num) -> Pair.of(rs.getString(1), rs.getString(2)), hash);
+ if (fb.size() > 0) {
+ return "<a href=\"" + fb.get(0).getRight() + "\" rel=\"nofollow\">" + fb.get(0).getLeft() + "</a>";
+ }
+ return null;
+ } catch (EmptyResultDataAccessException e) {
+ return null;
+ }
+ }
+
+ @Transactional
+ @Override
+ public String getTelegramNameByHash(String hash) {
+ try {
+ String name = getJdbcTemplate().queryForObject("SELECT tg_name FROM telegram WHERE loginhash=?", String.class, hash);
+ return "<a href=\"https://telegram.me/" + name + "\" rel=\"nofollow\">" + name + "</a>";
+ } catch (EmptyResultDataAccessException e) {
+ return null;
+ }
+ }
+
+ @Transactional
+ @Override
+ public boolean setFacebookUser(String hash, int uid) {
+ return getJdbcTemplate().update("UPDATE facebook SET user_id=?,loginhash=NULL WHERE loginhash=?", uid, hash) > 0;
+ }
+
+ @Transactional
+ @Override
+ public String getVKNameByHash(String hash) {
+ List<Pair<String, String>> logins = getJdbcTemplate().query("SELECT vk_name,vk_link FROM vk WHERE loginhash=?",
+ (rs, num) -> Pair.of(rs.getString(1), rs.getString(2)), hash);
+ if (logins.size() > 0) {
+ return "<a href=\"http://vk.com/" + logins.get(0).getRight() + "\" rel=\"nofollow\">" + logins.get(0).getLeft() + "</a>";
+ }
+ return null;
+ }
+
+ @Transactional
+ @Override
+ public boolean setVKUser(String hash, int uid) {
+ return getJdbcTemplate().update("UPDATE vk SET user_id=?,loginhash=NULL WHERE loginhash=?", uid, hash) > 0;
+ }
+
+ @Transactional
+ @Override
+ public boolean setTelegramUser(String hash, int uid) {
+ return getJdbcTemplate().update("UPDATE telegram SET user_id=?,loginhash=NULL WHERE loginhash=?", uid, hash) > 0;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public String getJIDByHash(String hash) {
+ try {
+ return getJdbcTemplate().queryForObject("SELECT jid FROM jids WHERE loginhash=?", String.class, hash);
+ } catch (EmptyResultDataAccessException e) {
+ return null;
+ }
+ }
+
+ @Transactional
+ @Override
+ public boolean setJIDUser(String hash, int uid) {
+ return getJdbcTemplate().update("UPDATE jids SET user_id=?,loginhash=NULL WHERE loginhash=?", uid, hash) > 0;
+ }
+}
diff --git a/src/main/java/com/juick/service/EmailService.java b/src/main/java/com/juick/service/EmailService.java
new file mode 100644
index 00000000..0708cd96
--- /dev/null
+++ b/src/main/java/com/juick/service/EmailService.java
@@ -0,0 +1,35 @@
+/*
+ * 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.service;
+
+import java.util.List;
+
+/**
+ * Created by vitalyster on 09.12.2016.
+ */
+public interface EmailService {
+ boolean verifyAddressByCode(Integer userId, String code);
+ boolean addVerificationCode(Integer userId, String account, String code);
+ boolean addEmail(Integer userId, String email);
+ boolean deleteEmail(Integer userId, String account);
+ String getNotificationsEmail(Integer userId);
+ boolean setNotificationsEmail(Integer userId, String account);
+ List<String> getEmails(Integer userId, boolean active);
+ String getEmailByAuthCode(String code);
+ void deleteAuthCode(String code);
+}
diff --git a/src/main/java/com/juick/service/EmailServiceImpl.java b/src/main/java/com/juick/service/EmailServiceImpl.java
new file mode 100644
index 00000000..78bdd42a
--- /dev/null
+++ b/src/main/java/com/juick/service/EmailServiceImpl.java
@@ -0,0 +1,108 @@
+/*
+ * 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.service;
+
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.dao.EmptyResultDataAccessException;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.inject.Inject;
+import java.util.List;
+
+/**
+ * Created by vitalyster on 09.12.2016.
+ */
+@Repository
+@Transactional
+public class EmailServiceImpl extends BaseJdbcService implements EmailService {
+
+ @Override
+ public boolean verifyAddressByCode(Integer userId, String code) {
+ try {
+ String address = getJdbcTemplate().queryForObject("SELECT account FROM auth WHERE user_id=? AND protocol='email' AND authcode=?",
+ String.class, userId, code);
+ addEmail(userId, address);
+ getJdbcTemplate().update("DELETE FROM auth WHERE user_id=? AND authcode=?", userId, code);
+ } catch (EmptyResultDataAccessException e) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public boolean addVerificationCode(Integer userId, String account, String code) {
+ return getJdbcTemplate().update("INSERT INTO auth(user_id,protocol,account,authcode) VALUES (?,'email',?,?)",
+ userId, account, code) > 0;
+ }
+
+ @Override
+ public boolean addEmail(Integer userId, String email) {
+ return getJdbcTemplate().update("INSERT INTO emails(user_id,email, subscr_hour) VALUES (?,?, 1)", userId, email) > 0;
+ }
+
+ @Override
+ public boolean deleteEmail(Integer userId, String account) {
+ return getNamedParameterJdbcTemplate().update("DELETE FROM emails " +
+ "WHERE (SELECT COUNT(*) cnt FROM (select user_id, email FROM emails e) c WHERE user_id=:uid) > 1 " +
+ "AND user_id=:uid AND email=:email",
+ new MapSqlParameterSource()
+ .addValue("uid", userId)
+ .addValue("email", account)) > 0;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public String getNotificationsEmail(Integer userId) {
+ List<String> list = getJdbcTemplate().queryForList(
+ "SELECT email FROM emails WHERE user_id=? AND subscr_hour IS NOT NULL", String.class, userId);
+ return list.isEmpty() ? StringUtils.EMPTY : list.get(0);
+ }
+
+ @Override
+ public boolean setNotificationsEmail(Integer userId, String account) {
+ getJdbcTemplate().update("UPDATE emails SET subscr_hour=NULL WHERE user_id=?", userId);
+ return StringUtils.isNotEmpty(account) && getJdbcTemplate().update(
+ "UPDATE emails SET subscr_hour=1 WHERE user_id=? AND email=?", userId, account) > 0;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<String> getEmails(Integer userId, boolean active) {
+ return getJdbcTemplate().queryForList("SELECT email FROM emails WHERE user_id=? " +
+ (active ? "AND subscr_hour IS NOT NULL" : ""), String.class, userId);
+ }
+ @Transactional(readOnly = true)
+ @Override
+ public String getEmailByAuthCode(String code) {
+ try {
+ return getJdbcTemplate().queryForObject("SELECT account FROM auth WHERE protocol='email' AND authcode=?", String.class, code);
+ } catch (EmptyResultDataAccessException e) {
+ return StringUtils.EMPTY;
+ }
+ }
+
+ @Transactional
+ @Override
+ public void deleteAuthCode(String code) {
+ getJdbcTemplate().update("DELETE FROM auth WHERE authcode=?", code);
+ }
+
+}
diff --git a/src/main/java/com/juick/service/ImagesService.java b/src/main/java/com/juick/service/ImagesService.java
new file mode 100644
index 00000000..902301ed
--- /dev/null
+++ b/src/main/java/com/juick/service/ImagesService.java
@@ -0,0 +1,24 @@
+package com.juick.service;
+
+import com.juick.Message;
+
+import java.io.IOException;
+
+public interface ImagesService {
+ void setAttachmentMetadata(String baseUrl, Message msg) throws Exception;
+ /**
+ * Move attached image from temp folder to image folder.
+ * Create preview images in corresponding folders.
+ *
+ * @param tempFilename Name of the image file in the temp folder.
+ * @param outputFilename Name that will be used in the image folder.
+ */
+ void saveImageWithPreviews(String tempFilename, String outputFilename) throws IOException;
+ /**
+ * Save new avatar in all required sizes.
+ *
+ * @param tempFilename Name of the image file in the temp folder.
+ * @param uid User id that is used to build image file names.
+ */
+ void saveAvatar(String tempFilename, int uid) throws IOException;
+}
diff --git a/src/main/java/com/juick/service/ImagesServiceImpl.java b/src/main/java/com/juick/service/ImagesServiceImpl.java
new file mode 100644
index 00000000..67c8360e
--- /dev/null
+++ b/src/main/java/com/juick/service/ImagesServiceImpl.java
@@ -0,0 +1,82 @@
+package com.juick.service;
+
+import com.juick.Attachment;
+import com.juick.Message;
+import com.juick.Photo;
+import com.juick.server.util.ImageUtils;
+import org.springframework.util.StringUtils;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Paths;
+
+public class ImagesServiceImpl implements ImagesService {
+ private ImageUtils imageUtils;
+ private String imgDir;
+ private String tmpDir;
+ public ImagesServiceImpl(String imgDir, String tmpDir) {
+ this.imgDir = imgDir;
+ this.tmpDir = tmpDir;
+ imageUtils = new ImageUtils(imgDir, tmpDir);
+ }
+ @Override
+ public void setAttachmentMetadata(String baseUrl, Message msg) throws Exception {
+ if (!StringUtils.isEmpty(msg.getAttachmentType())) {
+ Photo photo = new Photo();
+ if (msg.getRid()> 0) {
+ photo.setSmall(String.format("%sphotos-512/%d-%d.%s", baseUrl, msg.getMid(), msg.getRid(), msg.getAttachmentType()));
+ photo.setMedium(String.format("%sphotos-1024/%d-%d.%s", baseUrl, msg.getMid(), msg.getRid(), msg.getAttachmentType()));
+ photo.setThumbnail(String.format("%sps/%d-%d.%s", baseUrl, msg.getMid(), msg.getRid(), msg.getAttachmentType()));
+ } else {
+ photo.setSmall(String.format("%sphotos-512/%d.%s", baseUrl, msg.getMid(), msg.getAttachmentType()));
+ photo.setMedium(String.format("%sphotos-1024/%d.%s", baseUrl, msg.getMid(), msg.getAttachmentType()));
+ photo.setThumbnail(String.format("%sps/%d.%s", baseUrl, msg.getMid(), msg.getAttachmentType()));
+ }
+ msg.setPhoto(photo);
+ String imageName = String.format("%s.%s", msg.getMid(), msg.getAttachmentType());
+ if (msg.getRid() > 0) {
+ imageName = String.format("%s-%s.%s", msg.getMid(), msg.getRid(), msg.getAttachmentType());
+ }
+ File fullImage = Paths.get(imgDir, "p", imageName).toFile();
+ File mediumImage = Paths.get(imgDir, "photos-1024", imageName).toFile();
+ File smallImage = Paths.get(imgDir, "photos-512", imageName).toFile();
+ File thumbnailImage = Paths.get(imgDir, "ps", imageName).toFile();
+ StringBuilder builder = new StringBuilder();
+ builder.append(baseUrl);
+ builder.append(msg.getAttachmentType().equals("mp4") ? "video" : "p");
+ builder.append("/").append(msg.getMid());
+ if (msg.getRid() > 0) {
+ builder.append("-").append(msg.getRid());
+ }
+ builder.append(".").append(msg.getAttachmentType());
+ String originalUrl = builder.toString();
+
+ Attachment original = imageUtils.getAttachment(fullImage);
+ original.setUrl(originalUrl);
+
+ Attachment medium = imageUtils.getAttachment(mediumImage);
+ medium.setUrl(photo.getMedium());
+ original.setMedium(medium);
+
+ Attachment small = imageUtils.getAttachment(smallImage);
+ small.setUrl(photo.getSmall());
+ original.setSmall(small);
+
+ Attachment thumb = imageUtils.getAttachment(thumbnailImage);
+ thumb.setUrl(photo.getMedium());
+ original.setThumbnail(thumb);
+
+ msg.setAttachment(original);
+ }
+ }
+
+ @Override
+ public void saveImageWithPreviews(String tempFilename, String outputFilename) throws IOException {
+ imageUtils.saveImageWithPreviews(tempFilename, outputFilename);
+ }
+
+ @Override
+ public void saveAvatar(String tempFilename, int uid) throws IOException {
+ imageUtils.saveAvatar(tempFilename, uid);
+ }
+}
diff --git a/src/main/java/com/juick/service/MessagesService.java b/src/main/java/com/juick/service/MessagesService.java
new file mode 100644
index 00000000..362501b5
--- /dev/null
+++ b/src/main/java/com/juick/service/MessagesService.java
@@ -0,0 +1,145 @@
+/*
+ * 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.service;
+
+import com.juick.Message;
+import com.juick.Reaction;
+import com.juick.User;
+import com.juick.model.ResponseReply;
+
+import java.net.URI;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Created by aalexeev on 11/13/16.
+ */
+public interface MessagesService {
+ int createMessage(int uid, String txt, String attachment, Collection<com.juick.Tag> tags);
+
+ int createReply(int mid, int rid, User user, String txt, String attachment);
+
+ int getReplyIDIncrement(int mid);
+
+ enum RecommendStatus {
+ Error,
+ Added,
+ Deleted
+ }
+
+ RecommendStatus recommendMessage(int mid, int vuid, String userUri);
+
+ RecommendStatus recommendMessage(int mid, int vuid);
+
+ List<Reaction> listReactions();
+
+ RecommendStatus likeMessage(int mid, int vuid, int reactionId);
+
+ RecommendStatus likeMessage(int mid, int vuid, int reactionId, String userUri);
+
+
+ boolean canViewThread(int mid, int uid);
+
+ boolean isReadOnly(int mid);
+
+ boolean isSubscribed(int uid, int mid);
+
+ int getMessagePrivacy(int mid);
+
+ com.juick.Message getMessage(int mid);
+
+ com.juick.Message getReply(int mid, int rid);
+
+ com.juick.Message getReplyByUri(String replyUri);
+
+ User getMessageAuthor(int mid);
+
+ List<String> getMessageRecommendations(int mid);
+
+ List<Integer> getAll(int visitorUid, int before);
+
+ List<Integer> getTag(int tid, int visitorUid, int before, int cnt);
+
+ List<Integer> getTags(String tids, int visitorUid, int before, int cnt);
+
+ List<Integer> getPlace(int placeId, int visitorUid, int before);
+
+ List<Integer> getMyFeed(int uid, int before, boolean recommended);
+
+ List<Integer> getPrivate(int uid, int before);
+
+ List<Integer> getDiscussions(int uid, Long to);
+
+ List<Integer> getRecommended(int uid, int before);
+
+ List<Integer> getPopular(int visitorUid, int before);
+
+ List<Integer> getPhotos(int visitorUid, int before);
+
+ List<Integer> getSearch(User visitor, String search, int page);
+
+ List<Integer> getUserBlog(int uid, int privacy, int before);
+
+ List<Integer> getUserTag(int uid, int tid, int privacy, int before);
+
+ List<Integer> getUserBlogAtDay(int uid, int privacy, int daysback);
+
+ List<Integer> getUserBlogWithRecommendations(int uid, int privacy, int before);
+
+ List<Integer> getUserRecommendations(int uid, int before);
+
+ List<Integer> getUserPhotos(int uid, int privacy, int before);
+
+ List<Integer> getUserSearch(User visitor, int UID, String search, int privacy, int page);
+
+ List<com.juick.Message> getMessages(User visitor, List<Integer> mids);
+
+ Map<Integer,Set<Reaction>> updateReactionsFor(final List<Integer> mid);
+
+ List<com.juick.Message> getReplies(User user, int mid);
+
+ boolean setMessagePopular(int mid, int popular);
+
+ boolean setMessagePrivacy(int mid);
+
+ boolean deleteMessage(int uid, int mid);
+
+ boolean deleteReply(int uid, int mid, int rid);
+
+ List<Integer> getLastMessages(int hours);
+
+ List<ResponseReply> getLastReplies(int hours);
+
+ List<Integer> getPopularCandidates();
+
+ void setLastReadComment(User user, Integer mid, Integer rid);
+
+ void setRead(User user, Integer mid);
+
+ List<Integer> getUnread(User user);
+
+ boolean updateMessage(Integer mid, Integer rid, String body);
+
+ boolean updateReplyUri(Message reply, URI replyUri);
+
+ boolean replyExists(URI replyUri);
+
+ boolean deleteReply(URI userUri, URI replyUri);
+}
diff --git a/src/main/java/com/juick/service/MessagesServiceImpl.java b/src/main/java/com/juick/service/MessagesServiceImpl.java
new file mode 100644
index 00000000..0b7faf87
--- /dev/null
+++ b/src/main/java/com/juick/service/MessagesServiceImpl.java
@@ -0,0 +1,1143 @@
+/*
+ * 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.service;
+
+import com.juick.*;
+import com.juick.model.AnonymousUser;
+import com.juick.model.PrivacyOpts;
+import com.juick.model.ResponseReply;
+import com.juick.server.util.HttpNotFoundException;
+import com.juick.util.MessageUtils;
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.dao.IncorrectResultSizeDataAccessException;
+import org.springframework.jdbc.core.ConnectionCallback;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.SqlParameterSource;
+import org.springframework.jdbc.core.simple.SimpleJdbcInsert;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.inject.Inject;
+import java.net.URI;
+import java.sql.*;
+import java.time.Instant;
+import java.util.*;
+import java.util.Date;
+import java.util.stream.Collectors;
+
+/**
+ * Created by aalexeev on 11/13/16.
+ */
+@Repository
+public class MessagesServiceImpl extends BaseJdbcService implements MessagesService {
+ private static final Logger logger = LoggerFactory.getLogger(MessagesServiceImpl.class);
+ @Inject
+ private UserService userService;
+ @Inject
+ private TagService tagService;
+ @Inject
+ private SearchService searchService;
+ @Inject
+ private ImagesService imagesService;
+ @Value("${img_url:https://i.juick.com/}")
+ private String baseImagesUrl;
+
+ private class MessageMapper implements RowMapper<Message> {
+ @Override
+ public Message mapRow(ResultSet rs, int rowNum) throws SQLException {
+ Message msg = new Message();
+ msg.setMid(rs.getInt(1));
+ msg.setRid(rs.getInt(2));
+ msg.setReplyto(rs.getInt(3));
+ User user = new User();
+ user.setUid(rs.getInt(4));
+ user.setName(Optional.ofNullable(rs.getString(5)).orElse(AnonymousUser.INSTANCE.getName()));
+ user.setBanned(rs.getBoolean(6));
+ user.setUri(URI.create(Optional.ofNullable(rs.getString(22)).orElse(StringUtils.EMPTY)));
+ msg.setUser(user);
+ msg.setTimestamp(rs.getTimestamp(7).toInstant());
+ msg.ReadOnly = rs.getBoolean(8);
+ msg.setPrivacy(rs.getInt(9));
+ msg.FriendsOnly = msg.getPrivacy() < 0;
+ msg.setReplies(rs.getInt(10));
+ msg.setAttachmentType(rs.getString(11));
+ msg.setLikes(rs.getInt(12));
+ msg.Hidden = rs.getBoolean(13);
+ String tagsStr = rs.getString(14);
+ msg.setTags(MessageUtils.parseTags(tagsStr));
+ msg.setRepliesBy(rs.getString(15));
+ msg.setText(rs.getString(16));
+ msg.setReplyQuote(MessageUtils.formatQuote(rs.getString(17)));
+ msg.setUpdated(rs.getTimestamp(18).toInstant());
+ int quoteUid = rs.getInt(19);
+ User quoteUser = new User();
+ quoteUser.setUid(quoteUid);
+ quoteUser.setName(rs.getString(20));
+ if (quoteUid == 0) {
+ quoteUser.setName(AnonymousUser.INSTANCE.getName());
+ quoteUser.setUri(URI.create(Optional.ofNullable(rs.getString(23)).orElse(StringUtils.EMPTY)));
+ }
+ msg.setTo(quoteUser);
+ msg.setUpdatedAt(rs.getTimestamp(21).toInstant());
+ msg.setReplyUri(URI.create(Optional.ofNullable(rs.getString(24)).orElse(StringUtils.EMPTY)));
+ msg.setHtml(rs.getBoolean(25));
+ if (StringUtils.isNotEmpty(msg.getAttachmentType())) {
+ try {
+ imagesService.setAttachmentMetadata(baseImagesUrl, msg);
+ } catch (Exception e) {
+ logger.warn("exception reading images for mid {} rid {}", msg.getMid(), msg.getRid(), e);
+ }
+ }
+ return msg;
+ }
+ }
+
+
+
+ /**
+ * @see <a href="https://dev.mysql.com/doc/connector-j/5.1/en/connector-j-reference-type-conversions.html">Java, JDBC and MySQL Types</a>
+ */
+ @Transactional
+ @Override
+ public int createMessage(final int uid, final String txt, final String attachment, final Collection<com.juick.Tag> tags) {
+ SimpleJdbcInsert simpleJdbcInsert = new SimpleJdbcInsert(getJdbcTemplate()).withTableName("messages")
+ .usingColumns("user_id", "attach", "ts")
+ .usingGeneratedKeyColumns("message_id");
+ Map<String, Object> insertMap = new HashMap<>();
+ insertMap.put("user_id", uid);
+ Instant now = Instant.now();
+ insertMap.put("ts", Timestamp.from(now));
+ if (StringUtils.isNotEmpty(attachment)) {
+ insertMap.put("attach", attachment);
+ }
+ int mid = simpleJdbcInsert.executeAndReturnKey(insertMap).intValue();
+ if (mid > 0) {
+ String tagsNames = StringUtils.EMPTY;
+
+ if (CollectionUtils.isNotEmpty(tags)) {
+ StringBuilder tasNamesBuilder = new StringBuilder();
+ List<Object[]> params = new ArrayList<>(tags.size());
+
+ boolean next = false;
+
+ for (Tag tag : tags) {
+ if (next) {
+ tasNamesBuilder.append(" ");
+ } else
+ next = true;
+
+ tasNamesBuilder.append(tag.getName());
+ params.add(new Object[]{mid, tag.TID});
+ }
+ tagsNames = tasNamesBuilder.toString();
+
+ getJdbcTemplate().batchUpdate(
+ "INSERT INTO messages_tags(message_id, tag_id) VALUES (?, ?)",
+ params, new int[]{Types.INTEGER, Types.INTEGER});
+ }
+
+ getJdbcTemplate().update(
+ "INSERT INTO messages_txt(message_id, tags, txt, updated_at) VALUES (?, ?, ?, ?)",
+ new Object[]{mid, tagsNames, txt, Timestamp.from(now)},
+ new int[]{Types.INTEGER, Types.VARCHAR, Types.VARCHAR, Types.TIMESTAMP});
+ getJdbcTemplate().update("UPDATE users SET lastmessage=?, last_seen=? where id=?", Timestamp.from(now), Timestamp.from(now), uid);
+ }
+
+ return mid;
+ }
+
+ /**
+ * @param mid
+ * @param rid
+ * @param user
+ * @param txt
+ * @param attachment
+ * @return
+ * @see <a href="https://dev.mysql.com/doc/connector-j/5.1/en/connector-j-reference-type-conversions.html">Java, JDBC and MySQL Types</a>
+ */
+ @Transactional
+ @Override
+ public int createReply(final int mid, final int rid, final User user, final String txt, final String attachment) {
+ int ridnew = getReplyIDIncrement(mid);
+ Date ts = Date.from(Instant.now());
+ getJdbcTemplate().update("INSERT INTO replies(message_id, reply_id, user_id, replyto, attach, txt, ts, updated_at, user_uri) " +
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ mid, ridnew, user.getUid(), rid, attachment, txt, ts, ts, user.getUri().toASCIIString());
+
+ if (ridnew > 0) {
+ getJdbcTemplate().update(
+ "UPDATE messages SET replies = replies + 1, updated=? WHERE message_id = ?",
+ ts, mid);
+ setLastReadComment(user, mid, ridnew);
+ getJdbcTemplate().update("UPDATE users SET lastmessage=?, last_seen=? where id=?", ts, ts, user.getUid());
+ }
+ return ridnew;
+ }
+
+ @Override
+ public int getReplyIDIncrement(final int mid) {
+ return getJdbcTemplate().execute((ConnectionCallback<Integer>) conn -> {
+ conn.setAutoCommit(false);
+ final int replyNo;
+ try (PreparedStatement ps = conn.prepareStatement("SELECT maxreplyid+1 FROM messages WHERE message_id=? FOR UPDATE")) {
+ ps.setInt(1, mid);
+ try (ResultSet resultSet = ps.executeQuery()) {
+ if (resultSet.next()) {
+ replyNo = resultSet.getInt(1);
+ } else {
+ throw new IncorrectResultSizeDataAccessException("while getting getReplyIDIncrement, mid=" + mid, 1, 0);
+ }
+ }
+ }
+ try (PreparedStatement ps = conn.prepareStatement("UPDATE messages SET maxreplyid=? WHERE message_id=?")) {
+ ps.setInt(1, replyNo);
+ ps.setInt(2, mid);
+ if (ps.executeUpdate() != 1) {
+ throw new IncorrectResultSizeDataAccessException("Cannot find a message to update: " + mid, 1, 0);
+ }
+ }
+ conn.commit();
+ return replyNo;
+ });
+
+ }
+
+ @Transactional
+ void updateRepliesBy(int mid) {
+ List<String> users = getJdbcTemplate().queryForList("SELECT users.nick FROM replies " +
+ "INNER JOIN users ON replies.user_id=users.id WHERE replies.message_id=? " +
+ "GROUP BY replies.user_id ORDER BY COUNT(replies.reply_id) DESC LIMIT 5", String.class, mid);
+ String result = users.stream().map(u -> "@" + u).collect(Collectors.joining(","));
+ getJdbcTemplate().update("UPDATE messages_txt SET repliesby=? WHERE message_id=?", result, mid);
+ }
+
+ @Transactional
+ @Override
+ public RecommendStatus recommendMessage(final int mid, final int vuid, final String userUri) {
+ SqlParameterSource sqlParameterSource = new MapSqlParameterSource()
+ .addValue("uid", vuid)
+ .addValue("uri", userUri)
+ .addValue("like_id", Reaction.LIKE)
+ .addValue("mid", mid);
+ int wasDeleted = getNamedParameterJdbcTemplate()
+ .update("DELETE FROM favorites WHERE user_id=:uid AND message_id=:mid AND like_id=:like_id AND user_uri=:uri", sqlParameterSource);
+ if (wasDeleted > 0) {
+ return RecommendStatus.Deleted;
+ } else {
+ boolean wasAdded = getJdbcTemplate()
+ .update("INSERT INTO favorites(user_id, message_id, ts, like_id, user_uri) VALUES (?, ?, NOW(), ?, ?)", vuid, mid,Reaction.LIKE, userUri) == 1;
+ if (wasAdded) {
+ return RecommendStatus.Added;
+ }
+ }
+ return RecommendStatus.Error;
+ }
+
+ @Override
+ public RecommendStatus recommendMessage(int mid, int vuid) {
+ return recommendMessage(mid, vuid, StringUtils.EMPTY);
+ }
+
+ @Override
+ public List<Reaction> listReactions() {
+ return jdbcTemplate.query("SELECT like_id, description FROM reactions", (rs, rowNum) -> {
+ Reaction reaction = new Reaction(rs.getInt("like_id"));
+ reaction.setDescription(rs.getString("description"));
+ return reaction;
+ });
+ }
+
+ @Override
+ public RecommendStatus likeMessage(int mid, int vuid, int reactionId) {
+ return likeMessage(mid, vuid, reactionId, StringUtils.EMPTY);
+ }
+
+ @Transactional
+ @Override
+ public RecommendStatus likeMessage(int mid, int vuid, int reactionId, String userUri) throws IllegalArgumentException {
+ boolean wasAdded = getJdbcTemplate()
+ .update("INSERT INTO favorites(user_id, message_id, ts, like_id, user_uri) VALUES (?, ?, NOW(), ?, ?)", vuid, mid, reactionId, userUri) == 1;
+ if (wasAdded) {
+ return RecommendStatus.Added;
+ }
+
+ return RecommendStatus.Error;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public boolean canViewThread(final int mid, final int uid) {
+ List<PrivacyOpts> list = getJdbcTemplate().query(
+ "SELECT user_id, privacy FROM messages WHERE message_id = ?",
+ (rs, rowNum) -> {
+ PrivacyOpts res = new PrivacyOpts();
+
+ res.setUid(rs.getInt(1));
+ res.setPrivacy(rs.getInt(2));
+
+ return res;
+ },
+ mid);
+
+ PrivacyOpts privacyOpts = list.isEmpty() ? null : list.get(0);
+
+ return privacyOpts == null ||
+ privacyOpts.getPrivacy() >= 0 ||
+ uid == privacyOpts.getUid() ||
+ ((privacyOpts.getPrivacy() == -1 || privacyOpts.getPrivacy() == -2) &&
+ uid > 0 && userService.isInWL(privacyOpts.getUid(), uid));
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public boolean isReadOnly(final int mid) {
+ List<Integer> list = getJdbcTemplate().queryForList(
+ "SELECT readonly FROM messages WHERE message_id = ?",
+ new Object[]{mid},
+ Integer.class);
+
+ return !list.isEmpty() && list.get(0) == 1;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public boolean isSubscribed(final int uid, final int mid) {
+ List<Integer> list = getJdbcTemplate().queryForList(
+ "SELECT 1 FROM subscr_messages WHERE suser_id = ? AND message_id = ?",
+ new Object[]{uid, mid},
+ Integer.class);
+
+ return !list.isEmpty() && list.get(0) == 1;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public int getMessagePrivacy(final int mid) {
+ List<Integer> list = getJdbcTemplate().queryForList(
+ "SELECT privacy FROM messages WHERE message_id = ?",
+ new Object[]{mid},
+ Integer.class);
+
+ return list.isEmpty() ? -4 : list.get(0);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public com.juick.Message getMessage(final int mid) {
+
+ List<com.juick.Message> list = getJdbcTemplate().query(
+ "SELECT messages.message_id as mid, 0 as rid, 0 as replyto, "
+ + "messages.user_id as uid, users.nick, users.banned as banned, "
+ + ""
+ + "messages.ts,"
+ + "messages.readonly, messages.privacy, messages.replies,"
+ + "messages.attach, COUNT(DISTINCT favorites.user_id) as likes, messages.hidden,"
+ + "txt.tags, txt.repliesby, txt.txt, '' as q, messages.updated as updated, 0 as to_uid, "
+ + "NULL as to_name, txt.updated_at, '' as user_uri, '' as to_uri, '' as reply_uri, 0 as html FROM messages "
+ + "INNER JOIN users ON messages.user_id = users.id "
+ + "INNER JOIN messages_txt AS txt "
+ + "ON messages.message_id = txt.message_id "
+ + "LEFT JOIN favorites "
+ + "ON messages.message_id = favorites.message_id AND favorites.like_id=1 "
+ + "WHERE messages.message_id = ? "
+ + "GROUP BY mid, rid, replyto, uid, nick, banned, messages.ts, readonly, "
+ + "privacy, replies, attach, tags, repliesby, q, updated_at, user_uri, to_uri, reply_uri, html",
+ new MessageMapper(),
+ mid);
+ if (!list.isEmpty()) {
+ final Message message = list.get(0);
+ Map<Integer, Set<Reaction>> reactionStats = updateReactionsFor(Collections.singletonList(mid));
+ message.setReactions(reactionStats.get(message.getMid()));
+ return message;
+ }
+ return null;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public com.juick.Message getReply(final int mid, final int rid) {
+ List<com.juick.Message> list = getJdbcTemplate().query(
+ "SELECT replies.user_id, users.nick,"
+ + "replies.replyto, replies.ts,"
+ + "replies.attach, replies.txt, IFNULL(q.txt,t.txt) as quote, "
+ + "COALESCE(q.user_id, m.user_id) AS to_uid, COALESCE(qu.nick, mu.nick) AS to_name, "
+ + "replies.updated_at, replies.user_uri as uri, "
+ + "q.user_uri AS to_uri, replies.reply_uri AS reply_uri, replies.html, q.reply_uri "
+ + "FROM replies LEFT JOIN users ON replies.user_id = users.id "
+ + "LEFT JOIN replies q ON replies.message_id = q.message_id and replies.replyto = q.reply_id "
+ + "LEFT JOIN messages_txt t ON replies.message_id = t.message_id "
+ + "LEFT JOIN messages m ON replies.message_id = m.message_id "
+ + "LEFT JOIN users qu ON q.user_id=qu.id "
+ + "LEFT JOIN users mu ON m.user_id=mu.id "
+ + "WHERE replies.message_id = ? AND replies.reply_id = ?",
+ (rs, num) -> {
+ Message msg = new Message();
+
+ msg.setMid(mid);
+ msg.setRid(rid);
+ msg.setUser(new User());
+ msg.getUser().setUid(rs.getInt(1));
+ msg.getUser().setName(rs.getString(2));
+ if (msg.getUser().getUid() == 0) {
+ msg.getUser().setName(AnonymousUser.INSTANCE.getName());
+ msg.getUser().setUri(
+ URI.create(Optional.ofNullable(rs.getString(11)).orElse(StringUtils.EMPTY)));
+ }
+ msg.setReplyto(rs.getInt(3));
+ msg.setTimestamp(rs.getTimestamp(4).toInstant());
+ msg.setAttachmentType(rs.getString(5));
+ msg.setText(rs.getString(6));
+ String quote = rs.getString(7);
+
+ if (!StringUtils.isEmpty(quote)) {
+ msg.setReplyQuote(MessageUtils.formatQuote(quote));
+ }
+ int quoteUid = rs.getInt(8);
+ User quoteUser = new User();
+ quoteUser.setUid(quoteUid);
+ quoteUser.setName(Optional.ofNullable(rs.getString(9)).orElse(AnonymousUser.INSTANCE.getName()));
+ quoteUser.setUri(URI.create(Optional.ofNullable(rs.getString(12)).orElse(StringUtils.EMPTY)));
+ msg.setTo(quoteUser);
+ msg.setUpdatedAt(rs.getTimestamp(10).toInstant());
+ msg.setReplyUri(URI.create(Optional.ofNullable(rs.getString(13)).orElse(StringUtils.EMPTY)));
+ msg.setHtml(rs.getBoolean(14));
+ msg.setReplyToUri(URI.create(Optional.ofNullable(rs.getString(15)).orElse(StringUtils.EMPTY)));
+ if (StringUtils.isNotEmpty(msg.getAttachmentType())) {
+ try {
+ imagesService.setAttachmentMetadata(baseImagesUrl, msg);
+ } catch (Exception e) {
+ logger.warn("exception reading images for mid {} rid {}", msg.getMid(), msg.getRid(), e);
+ }
+ }
+ return msg;
+ },
+ mid, rid);
+
+ return list.isEmpty() ? null : list.get(0);
+ }
+
+ @Override
+ public Message getReplyByUri(String replyUri) {
+ List<Message> replies = getJdbcTemplate().query("SELECT message_id, reply_id from replies WHERE reply_uri=?", (rs, rowNum) -> getReply(rs.getInt(1), rs.getInt(2)), replyUri);
+ return replies.isEmpty() ? null : replies.get(0);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public User getMessageAuthor(final int mid) {
+ List<User> list = getJdbcTemplate().query(
+ "SELECT messages.user_id, users.nick "
+ + "FROM messages INNER JOIN users ON messages.user_id = users.id WHERE messages.message_id = ?",
+ new Object[]{mid},
+ (rs, num) -> {
+ User res = new com.juick.User();
+ res.setUid(rs.getInt(1));
+ res.setName(rs.getString(2));
+ return res;
+ });
+
+ return list.isEmpty() ?
+ null : list.get(0);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<String> getMessageRecommendations(final int mid) {
+ return getJdbcTemplate().queryForList(
+ "SELECT DISTINCT users.nick FROM favorites " +
+ "INNER JOIN users ON (favorites.message_id = ? AND favorites.user_id = users.id) " +
+ "INNER JOIN messages m ON favorites.message_id=m.message_id WHERE favorites.like_id=1 " +
+ "AND NOT EXISTS (SELECT 1 FROM bl_users WHERE " +
+ "(user_id = favorites.user_id AND bl_user_id = m.user_id) " +
+ "OR (user_id = m.user_id AND bl_user_id = favorites.user_id))",
+ String.class, mid);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getAll(final int visitorUid, final int before) {
+ SqlParameterSource sqlParameterSource = new MapSqlParameterSource()
+ .addValue("before", before)
+ .addValue("visitorUid", visitorUid);
+
+ return getNamedParameterJdbcTemplate().queryForList(
+ "SELECT m.message_id FROM messages m WHERE " +
+ (before > 0 ?
+ " m.message_id < :before AND " : StringUtils.EMPTY) +
+ " m.hidden = 0 AND (m.privacy > 0" +
+ (visitorUid > 1 ?
+ " OR m.user_id = :visitorUid) AND NOT EXISTS (" +
+ " SELECT 1 FROM bl_users b WHERE b.user_id = :visitorUid AND b.bl_user_id = m.user_id)" :
+ ")") +
+ " AND NOT EXISTS (SELECT 1 FROM bl_tags bt WHERE bt.tag_id IN " +
+ "(SELECT tag_id FROM messages_tags WHERE message_id = m.message_id) and :visitorUid = bt.user_id)" +
+ " AND NOT EXISTS (SELECT 1 from users u WHERE u.banned = 1 and u.id = m.user_id and u.id <> :visitorUid) ORDER BY m.message_id DESC LIMIT 20",
+ sqlParameterSource,
+ Integer.class);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getTag(final int tid, final int visitorUid, final int before, final int cnt) {
+ SqlParameterSource sqlParameterSource = new MapSqlParameterSource()
+ .addValue("tid", tid)
+ .addValue("cnt", cnt)
+ .addValue("before", before)
+ .addValue("visitorUid", visitorUid);
+
+ return getNamedParameterJdbcTemplate().queryForList(
+ "SELECT messages.message_id FROM (tags INNER JOIN messages_tags " +
+ "ON ((tags.synonym_id = :tid OR tags.tag_id = :tid) AND tags.tag_id = messages_tags.tag_id)) " +
+ "INNER JOIN messages ON messages.message_id = messages_tags.message_id WHERE " +
+ (before > 0 ?
+ " messages.message_id < :before AND " : StringUtils.EMPTY) +
+ "(messages.privacy > 0 OR messages.user_id = :visitorUid) " +
+ "AND NOT EXISTS (SELECT 1 FROM bl_users b WHERE b.user_id = :visitorUid and b.bl_user_id = messages.user_id) " +
+ "ORDER BY messages.message_id DESC LIMIT :cnt",
+ sqlParameterSource,
+ Integer.class);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getTags(final String tids, final int visitorUid, final int before, final int cnt) {
+ SqlParameterSource sqlParameterSource = new MapSqlParameterSource()
+ .addValue("cnt", cnt)
+ .addValue("before", before)
+ .addValue("visitorUid", visitorUid);
+
+ return getNamedParameterJdbcTemplate().queryForList(
+ "SELECT messages.message_id FROM messages_tags " +
+ "INNER JOIN messages USING(message_id) WHERE messages_tags.tag_id IN (" + tids + ") " +
+ (before > 0 ?
+ " AND messages.message_id < :before " : StringUtils.EMPTY) +
+ " AND (messages.privacy > 0 OR messages.user_id = :visitorUid) " +
+ "ORDER BY messages.message_id DESC LIMIT :cnt",
+ sqlParameterSource,
+ Integer.class);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getPlace(final int placeId, final int visitorUid, final int before) {
+ SqlParameterSource sqlParameterSource = new MapSqlParameterSource()
+ .addValue("placeId", placeId)
+ .addValue("before", before)
+ .addValue("visitorUid", visitorUid);
+
+ return getNamedParameterJdbcTemplate().queryForList(
+ "SELECT message_id FROM messages WHERE place_id = :placeId " +
+ (before > 0 ?
+ " AND message_id < :before " : StringUtils.EMPTY) +
+ " AND (privacy > 0 OR user_id = :visitorUid) ORDER BY message_id DESC LIMIT 20",
+ sqlParameterSource,
+ Integer.class);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getMyFeed(final int uid, final int before, boolean recommended) {
+ SqlParameterSource sqlParameterSource = new MapSqlParameterSource()
+ .addValue("uid", uid)
+ .addValue("before", before);
+
+ List<Integer> mids = getNamedParameterJdbcTemplate().queryForList(
+ "(SELECT message_id FROM messages " +
+ " INNER JOIN subscr_users ON (subscr_users.suser_id = :uid AND subscr_users.user_id = messages.user_id) " +
+ " WHERE " +
+ (before > 0 ?
+ " message_id < :before AND " : StringUtils.EMPTY) +
+ " (privacy >= 0 OR (privacy >= -2 AND privacy <= -1" +
+ " AND EXISTS (SELECT 1 FROM wl_users w WHERE w.wl_user_id = :uid and w.user_id = messages.user_id))) " +
+ " AND NOT EXISTS (SELECT 1 FROM bl_tags bt WHERE bt.tag_id IN " +
+ "(SELECT tag_id FROM messages_tags WHERE message_id = messages.message_id) and :uid = bt.user_id))" +
+ " UNION " +
+ " (SELECT message_id FROM messages WHERE user_id=:uid " +
+ (before > 0 ?
+ " AND message_id < :before " : StringUtils.EMPTY) +
+ (recommended ?
+ ") UNION " +
+ " (SELECT f.message_id as message_id FROM favorites f INNER JOIN messages ON " +
+ "f.message_id=messages.message_id WHERE " +
+ "EXISTS (SELECT 1 FROM subscr_users s WHERE s.suser_id = :uid and f.user_id = s.user_id)" +
+ (before > 0 ?
+ " AND f.message_id < :before " : StringUtils.EMPTY) : StringUtils.EMPTY) +
+ " AND NOT EXISTS (SELECT 1 FROM bl_users b WHERE b.user_id = :uid and b.bl_user_id = messages.user_id)" +
+ " AND NOT EXISTS (SELECT 1 FROM bl_tags bt WHERE bt.tag_id IN " +
+ "(SELECT tag_id FROM messages_tags WHERE message_id = messages.message_id) and :uid = bt.user_id)) " +
+ "ORDER BY message_id DESC LIMIT 20",
+ sqlParameterSource,
+ Integer.class);
+
+ return mids;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getPrivate(final int uid, final int before) {
+ SqlParameterSource sqlParameterSource = new MapSqlParameterSource()
+ .addValue("uid", uid)
+ .addValue("before", before);
+
+ return getNamedParameterJdbcTemplate().queryForList
+ ("SELECT message_id FROM messages WHERE user_id = :uid AND privacy < 0" +
+ (before > 0 ?
+ " AND message_id < :before " : StringUtils.EMPTY) +
+ "ORDER BY message_id DESC LIMIT 20",
+ sqlParameterSource,
+ Integer.class);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getDiscussions(final int uid, final Long to) {
+ SqlParameterSource sqlParameterSource = new MapSqlParameterSource()
+ .addValue("uid", uid)
+ .addValue("to", new Timestamp(to));
+
+ if (uid == 0) {
+ return getNamedParameterJdbcTemplate().query("SELECT message_id FROM messages WHERE " +
+ (to != 0 ?
+ " updated < :to AND" : StringUtils.EMPTY) +
+ " NOT EXISTS (SELECT 1 from users u WHERE u.banned = 1" +
+ " AND u.id = messages.user_id and u.id <> :uid) " +
+ " ORDER BY updated DESC, message_id DESC LIMIT 20",
+ sqlParameterSource,
+ (rs, rowNum) -> rs.getInt(1));
+ }
+ return getNamedParameterJdbcTemplate().query(
+ "SELECT messages.message_id, messages.updated FROM subscr_messages " +
+ "INNER JOIN messages ON messages.message_id=subscr_messages.message_id " +
+ "WHERE suser_id = :uid " +
+ (to != 0 ?
+ "AND updated < :to " : StringUtils.EMPTY) +
+ " AND NOT EXISTS (SELECT 1 from users u WHERE u.banned = 1" +
+ " AND u.id = messages.user_id and u.id <> :uid) " +
+ "ORDER BY updated DESC, message_id DESC LIMIT 20",
+ sqlParameterSource,
+ (rs, rowNum) -> rs.getInt(1));
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getRecommended(final int uid, final int before) {
+ SqlParameterSource sqlParameterSource = new MapSqlParameterSource()
+ .addValue("uid", uid)
+ .addValue("before", before);
+
+ return getNamedParameterJdbcTemplate().queryForList(
+ "SELECT f.message_id FROM favorites f WHERE " +
+ "EXISTS (SELECT 1 FROM subscr_users s WHERE s.suser_id = :uid and f.user_id = s.user_id)" +
+ (before > 0 ?
+ " AND f.message_id < :before " : StringUtils.EMPTY) +
+ "ORDER BY f.message_id DESC LIMIT 20",
+ sqlParameterSource,
+ Integer.class);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getPopular(final int visitorUid, final int before) {
+ SqlParameterSource sqlParameterSource = new MapSqlParameterSource()
+ .addValue("vid", visitorUid)
+ .addValue("before", before);
+
+ return getNamedParameterJdbcTemplate().queryForList(
+ "SELECT m.message_id FROM messages m WHERE m.privacy > 0 " +
+ (before > 0 ?
+ " AND m.message_id < :before " : StringUtils.EMPTY) +
+ " AND m.popular > 0 AND NOT EXISTS (SELECT 1 FROM bl_users b WHERE b.user_id = :vid and b.bl_user_id = m.user_id) " +
+ " AND NOT EXISTS (SELECT 1 FROM bl_tags bt WHERE bt.tag_id IN " +
+ "(SELECT tag_id FROM messages_tags WHERE message_id = m.message_id) and :vid = bt.user_id)" +
+ " ORDER BY m.message_id DESC LIMIT 20",
+ sqlParameterSource,
+ Integer.class);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getPhotos(final int visitorUid, final int before) {
+ SqlParameterSource sqlParameterSource = new MapSqlParameterSource()
+ .addValue("vid", visitorUid)
+ .addValue("before", before);
+
+ return getNamedParameterJdbcTemplate().queryForList(
+ "SELECT m.message_id FROM messages m WHERE (m.privacy > 0 OR m.user_id = :vid) " +
+ (before > 0 ?
+ " AND m.message_id < :before " : StringUtils.EMPTY) +
+ " AND m.attach IS NOT NULL " +
+ " AND NOT EXISTS (SELECT 1 FROM bl_tags bt WHERE bt.tag_id IN " +
+ "(SELECT tag_id FROM messages_tags WHERE message_id = m.message_id) and :vid = bt.user_id)" +
+ " AND NOT EXISTS (SELECT 1 from users u WHERE u.banned = 1 and u.id = m.user_id and u.id <> :vid) " +
+ " AND NOT EXISTS (SELECT 1 FROM bl_users b WHERE b.user_id = :vid and b.bl_user_id = m.user_id) " +
+ " ORDER BY m.message_id DESC LIMIT 20",
+ sqlParameterSource,
+ Integer.class);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getSearch(final User visitor, final String search, final int page) {
+ return searchService.searchInAllMessages(visitor, search, page);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getUserBlog(final int uid, final int privacy, final int before) {
+ SqlParameterSource sqlParameterSource = new MapSqlParameterSource()
+ .addValue("uid", uid)
+ .addValue("privacy", privacy)
+ .addValue("before", before);
+
+ ;
+ if (userService.getUserByUID(uid).orElseThrow(IllegalStateException::new).isBanned()) {
+ throw new HttpNotFoundException();
+ }
+
+ return getNamedParameterJdbcTemplate().queryForList(
+ "SELECT message_id FROM messages WHERE user_id = :uid" +
+ (before > 0 ?
+ " AND message_id < :before" : StringUtils.EMPTY) +
+ " AND privacy >= :privacy ORDER BY message_id DESC LIMIT 20",
+ sqlParameterSource,
+ Integer.class);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getUserTag(final int uid, final int tid, final int privacy, final int before) {
+ SqlParameterSource sqlParameterSource = new MapSqlParameterSource()
+ .addValue("uid", uid)
+ .addValue("tid", tid)
+ .addValue("privacy", privacy)
+ .addValue("before", before);
+
+ if (userService.getUserByUID(uid).orElseThrow(IllegalStateException::new).isBanned()) {
+ throw new HttpNotFoundException();
+ }
+
+ return getNamedParameterJdbcTemplate().queryForList(
+ "SELECT messages.message_id FROM messages_tags INNER JOIN messages " +
+ " USING (message_id) WHERE messages.user_id = :uid AND messages_tags.tag_id = :tid " +
+ (before > 0 ?
+ " AND messages.message_id < :before " : StringUtils.EMPTY) +
+ " AND messages.privacy >= :privacy ORDER BY messages.message_id DESC LIMIT 20",
+ sqlParameterSource,
+ Integer.class);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getUserBlogAtDay(final int uid, final int privacy, final int daysback) {
+ SqlParameterSource sqlParameterSource = new MapSqlParameterSource()
+ .addValue("uid", uid)
+ .addValue("privacy", privacy)
+ .addValue("daysback", daysback);
+
+ if (userService.getUserByUID(uid).orElseThrow(IllegalStateException::new).isBanned()) {
+ throw new HttpNotFoundException();
+ }
+
+ return getNamedParameterJdbcTemplate().queryForList(
+ "SELECT message_id FROM messages WHERE user_id = :uid" +
+ (daysback > 0 ?
+ " AND ts >= date(NOW() - INTERVAL :daysback day)" +
+ " AND ts < date(NOW() - INTERVAL :daysback day + INTERVAL 1 day)" : StringUtils.EMPTY) +
+ " AND privacy >= :privacy ORDER BY message_id DESC LIMIT 20",
+ sqlParameterSource,
+ Integer.class);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getUserBlogWithRecommendations(final int uid, final int privacy, final int before) {
+ SqlParameterSource sqlParameterSource = new MapSqlParameterSource()
+ .addValue("uid", uid)
+ .addValue("privacy", privacy)
+ .addValue("before", before);
+
+ if (userService.getUserByUID(uid).orElseThrow(IllegalStateException::new).isBanned()) {
+ throw new HttpNotFoundException();
+ }
+
+ return getNamedParameterJdbcTemplate().queryForList(
+ "SELECT message_id FROM " +
+ "(SELECT message_id FROM favorites " +
+ " WHERE user_id = :uid " +
+ (before > 0 ?
+ " AND message_id < :before " : StringUtils.EMPTY) +
+ " ORDER BY message_id DESC LIMIT 20) as r" +
+ " UNION ALL " +
+ "SELECT message_id FROM " +
+ "(SELECT message_id FROM messages WHERE user_id = :uid" +
+ (before > 0 ?
+ " AND message_id < :before" : StringUtils.EMPTY) +
+ " AND privacy >= :privacy ORDER BY message_id DESC LIMIT 20) as m " +
+ "ORDER BY message_id DESC LIMIT 20",
+ sqlParameterSource,
+ Integer.class);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getUserRecommendations(final int uid, final int before) {
+ SqlParameterSource sqlParameterSource = new MapSqlParameterSource()
+ .addValue("uid", uid)
+ .addValue("before", before);
+
+ return getNamedParameterJdbcTemplate().queryForList(
+ "SELECT message_id FROM favorites " +
+ " WHERE user_id = :uid " +
+ (before > 0 ?
+ " AND message_id < :before " : StringUtils.EMPTY) +
+ " ORDER BY message_id DESC LIMIT 20",
+ sqlParameterSource,
+ Integer.class);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getUserPhotos(final int uid, final int privacy, final int before) {
+ SqlParameterSource sqlParameterSource = new MapSqlParameterSource()
+ .addValue("uid", uid)
+ .addValue("privacy", privacy)
+ .addValue("before", before);
+
+ return getNamedParameterJdbcTemplate().queryForList(
+ "SELECT message_id FROM messages WHERE user_id = :uid " +
+ (before > 0 ?
+ " AND message_id < :before " : StringUtils.EMPTY) +
+ " AND privacy >= :privacy AND attach IS NOT NULL ORDER BY message_id DESC LIMIT 20",
+ sqlParameterSource,
+ Integer.class);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getUserSearch(final User visitor, final int UID, final String search, final int privacy, final int page) {
+ return searchService.searchByStringAndUser(visitor, search, UID, page);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<com.juick.Message> getMessages(final User visitor, final List<Integer> mids) {
+ if (CollectionUtils.isNotEmpty(mids)) {
+
+ List<com.juick.Message> msgs = getNamedParameterJdbcTemplate().query(
+ "WITH RECURSIVE banned(message_id, reply_id) "
+ + "AS (SELECT message_id, reply_id FROM replies WHERE replies.message_id IN (:ids) "
+ + "AND (EXISTS (SELECT 1 FROM bl_users b WHERE b.user_id = :uid AND b.bl_user_id = replies.user_id) "
+ + "OR EXISTS (SELECT 1 from users u WHERE u.banned = 1 and u.id = replies.user_id and u.id <> :uid)) "
+ + "UNION ALL SELECT replies.message_id, replies.reply_id FROM replies INNER JOIN banned "
+ + "ON banned.reply_id = replies.replyto AND banned.message_id=replies.message_id "
+ + "WHERE replies.message_id IN (:ids)) "
+ + "SELECT messages.message_id, 0 as rid, 0 as replyto, "
+ + "messages.user_id,users.nick, 0 as banned, "
+ + "messages.ts,"
+ + "messages.readonly,messages.privacy, messages.replies-COUNT(DISTINCT banned.reply_id) as replies,"
+ + "messages.attach,COUNT(DISTINCT favorites.user_id) AS likes,messages.hidden,"
+ + "messages_txt.tags,messages_txt.repliesby, messages_txt.txt, '' as q, "
+ + "messages.updated, 0 as to_uid, NULL as to_name, messages_txt.updated_at, '' as user_uri, "
+ + "'' as to_uri, '' as reply_uri, 0 as html "
+ + "FROM (messages INNER JOIN messages_txt "
+ + "ON messages.message_id=messages_txt.message_id) "
+ + "INNER JOIN users ON messages.user_id=users.id "
+ + "LEFT JOIN favorites "
+ + "ON messages.message_id = favorites.message_id AND favorites.like_id=1 "
+ + "LEFT JOIN banned "
+ + "ON messages.message_id = banned.message_id "
+ + "WHERE messages.message_id IN (:ids) GROUP BY "
+ + "messages.message_id, rid, replyto, messages.user_id, users.nick, banned, messages.ts, "
+ + "messages.readonly, messages.privacy, messages.attach, messages.hidden, messages_txt.tags, "
+ + "messages_txt.repliesby, messages_txt.txt, q, messages.updated, to_uid, to_name, updated_at, "
+ + "user_uri, reply_uri, html",
+ new MapSqlParameterSource("ids", mids)
+ .addValue("uid", visitor.getUid()),
+ new MessageMapper());
+
+
+ Map<Integer,Set<Reaction>> likes = updateReactionsFor(mids);
+
+ msgs.forEach(i -> i.setReactions(likes.get(i.getMid())));
+
+ msgs.sort(Comparator.comparing(item -> mids.indexOf(item.getMid())));
+
+ return msgs;
+ }
+ return Collections.emptyList();
+ }
+
+
+ @Transactional(readOnly = true)
+ @Override
+ public Map<Integer,Set<Reaction>> updateReactionsFor(final List<Integer> mids) {
+
+ return getNamedParameterJdbcTemplate().query("select f.message_id as mid, f.like_id as lid," +
+ " r.description as descr, count(f.like_id) as cnt" +
+ " from favorites f LEFT JOIN reactions r ON f.like_id = r.like_id " +
+ " where f.message_id IN (:mids) " +
+ " group by f.message_id, f.like_id", new MapSqlParameterSource("mids", mids), (ResultSet rs) -> {
+ Map<Integer,Set<Reaction>> results = new HashMap<>();
+
+
+ while (rs.next()) {
+ int messageId = rs.getInt("mid");
+ int likeId = rs.getInt("lid");
+ int count = rs.getInt("cnt");
+ String description = rs.getString("descr");
+ Reaction reaction = new Reaction(likeId);
+ reaction.setCount(count);
+ reaction.setDescription(description);
+ results.computeIfAbsent(messageId, HashSet::new);
+ results.get(messageId).add(reaction);
+ }
+
+ return results;
+ });
+
+ }
+
+
+ @Transactional
+ @Override
+ public List<Message> getReplies(final User user, final int mid) {
+ List<Message> replies = getNamedParameterJdbcTemplate().query(
+ "WITH RECURSIVE banned(reply_id, user_id) AS (" +
+ "SELECT reply_id, user_id FROM replies " +
+ "WHERE replies.message_id = :mid " +
+ "AND EXISTS (SELECT 1 FROM bl_users b WHERE b.user_id = :uid AND b.bl_user_id = replies.user_id) " +
+ "UNION ALL SELECT replies.reply_id, replies.user_id FROM replies " +
+ "INNER JOIN banned ON banned.reply_id = replies.replyto " +
+ "WHERE replies.message_id = :mid) " +
+ "SELECT replies.message_id as mid, replies.reply_id, replies.replyto, " +
+ "replies.user_id, users.nick, users.banned, " +
+ "replies.ts, " +
+ "0 as readonly, 0 as privacy, 0 as replies, " +
+ "replies.attach, 0 as likes, 0 as hidden, " +
+ "NULL as tags, NULL as repliesby, replies.txt, " +
+ "IFNULL(qw.txt, t.txt) as q, " +
+ "NOW(), " +
+ "COALESCE(qw.user_id, m.user_id) as to_uid, COALESCE(qu.nick, mu.nick) as to_name, " +
+ "replies.updated_at, replies.user_uri as uri, " +
+ "qw.user_uri as to_uri, replies.reply_uri, replies.html " +
+ "FROM replies LEFT JOIN users " +
+ "ON replies.user_id = users.id " +
+ "LEFT JOIN replies qw ON replies.message_id = qw.message_id and replies.replyto = qw.reply_id " +
+ "LEFT JOIN messages_txt t on replies.message_id = t.message_id " +
+ "LEFT JOIN messages m on replies.message_id = m.message_id " +
+ "LEFT JOIN users qu ON qw.user_id=qu.id " +
+ "LEFT JOIN users mu ON m.user_id=mu.id " +
+ "WHERE replies.message_id = :mid " +
+ "AND NOT EXISTS (SELECT 1 from users u WHERE u.banned = 1 and u.id = replies.user_id and u.id <> :uid)" +
+ "AND NOT EXISTS (SELECT 1 FROM banned WHERE banned.reply_id = replies.reply_id) " +
+ "AND NOT EXISTS (SELECT 1 FROM bl_users b WHERE b.user_id = :uid AND b.bl_user_id = m.user_id) " +
+ "ORDER BY replies.reply_id ASC",
+ new MapSqlParameterSource("mid", mid).addValue("uid", user.getUid()),
+ new MessageMapper());
+ if (replies.size() > 0) {
+ setRead(user, mid);
+ }
+ return replies;
+ }
+
+ @Transactional
+ @Override
+ public boolean setMessagePopular(final int mid, final int popular) {
+ int ret;
+ MapSqlParameterSource sqlParameterSource = new MapSqlParameterSource()
+ .addValue("mid", mid)
+ .addValue("popular", popular);
+
+ switch (popular) {
+ case -2:
+ ret = getNamedParameterJdbcTemplate().update(
+ "UPDATE messages SET hidden = 1 WHERE message_id = :mid",
+ sqlParameterSource);
+ break;
+ case -1:
+ sqlParameterSource.addValue("popular", 0);
+ default:
+ ret = getNamedParameterJdbcTemplate().update(
+ "UPDATE messages SET popular = :popular WHERE message_id = :mid",
+ sqlParameterSource);
+ break;
+ }
+
+ if (popular == -1)
+ ret = getNamedParameterJdbcTemplate().update(
+ "INSERT INTO top_ignore_messages VALUES (:mid)",
+ sqlParameterSource);
+
+ return ret > 0;
+ }
+
+ @Transactional
+ @Override
+ public boolean setMessagePrivacy(final int mid) {
+ return getJdbcTemplate().update("UPDATE messages SET privacy=1 WHERE message_id=?", mid) > 0;
+ }
+
+ @Transactional
+ @Override
+ public boolean deleteMessage(final int uid, final int mid) {
+ SqlParameterSource sqlParameterSource = new MapSqlParameterSource()
+ .addValue("mid", mid)
+ .addValue("uid", uid);
+
+ if (getNamedParameterJdbcTemplate().update(
+ "DELETE FROM messages WHERE message_id = :mid AND user_id = :uid", sqlParameterSource) > 0) {
+
+ getNamedParameterJdbcTemplate().update("DELETE FROM messages_txt WHERE message_id = :mid", sqlParameterSource);
+ getNamedParameterJdbcTemplate().update("DELETE FROM replies WHERE message_id = :mid", sqlParameterSource);
+ getNamedParameterJdbcTemplate().update("DELETE FROM subscr_messages WHERE message_id = :mid", sqlParameterSource);
+ getNamedParameterJdbcTemplate().update("DELETE FROM messages_tags WHERE message_id = :mid", sqlParameterSource);
+
+ return true;
+ }
+ return false;
+ }
+ @Transactional
+ @Override
+ public boolean deleteReply(final int uid, final int mid, final int rid) {
+ User author = getMessageAuthor(mid);
+ SqlParameterSource sqlParameterSource = new MapSqlParameterSource()
+ .addValue("mid", mid)
+ .addValue("uid", uid)
+ .addValue("rid", rid);
+ boolean result;
+ if (author.getUid() == uid) {
+ result = getNamedParameterJdbcTemplate()
+ .update("DELETE FROM replies WHERE message_id=:mid AND reply_id=:rid", sqlParameterSource) > 0;
+ } else {
+ result = getNamedParameterJdbcTemplate()
+ .update("DELETE FROM replies WHERE message_id=:mid AND reply_id=:rid AND user_id=:uid"
+ , sqlParameterSource) > 0;
+ }
+ if (result) {
+ getNamedParameterJdbcTemplate().update("UPDATE messages SET replies=replies-1 WHERE message_id=:mid", sqlParameterSource);
+ updateRepliesBy(mid);
+ return true;
+ }
+ return false;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getLastMessages(int hours) {
+ return getJdbcTemplate().queryForList("SELECT message_id FROM messages WHERE messages.ts>TIMESTAMPADD(HOUR,?,NOW())",
+ Integer.class, -hours);
+
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<ResponseReply> getLastReplies(int hours) {
+ return getJdbcTemplate().query("SELECT users2.nick,replies.message_id,replies.reply_id," +
+ "users.nick,replies.txt," +
+ "replies.ts,replies.attach,replies.ts+0, replies.html " +
+ "FROM ((replies INNER JOIN users ON replies.user_id=users.id) " +
+ "INNER JOIN messages ON replies.message_id=messages.message_id) " +
+ "INNER JOIN users AS users2 ON messages.user_id=users2.id " +
+ "WHERE replies.ts>TIMESTAMPADD(HOUR,?,NOW()) AND messages.privacy>0", (rs, rowNum) -> {
+ ResponseReply reply = new ResponseReply();
+ reply.setMuname(rs.getString(1));
+ reply.setMid(rs.getInt(2));
+ reply.setRid(rs.getInt(3));
+ reply.setUname(rs.getString(4));
+ reply.setDescription(rs.getString(5));
+ reply.setPubDate(rs.getTimestamp(6));
+ reply.setAttachmentType(rs.getString(7));
+ reply.setHtml(rs.getBoolean(8));
+ return reply;
+ }, -hours);
+ }
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getPopularCandidates() {
+ return getJdbcTemplate().queryForList("SELECT replies.message_id FROM replies " +
+ "INNER JOIN messages ON replies.message_id = messages.message_id " +
+ "LEFT JOIN messages_tags ON messages_tags.message_id = messages.message_id " +
+ "WHERE COALESCE(messages_tags.tag_id, 0) != 2 " +
+ "AND COALESCE(messages_tags.tag_id, 0) != 805 AND replies.ts > TIMESTAMPADD(HOUR, -2, CURRENT_TIMESTAMP) " +
+ "AND messages.popular=0 GROUP BY messages.message_id having COUNT(DISTINCT(replies.user_id)) > 5 " +
+ "UNION ALL SELECT favorites.message_id FROM favorites " +
+ "INNER JOIN messages ON messages.message_id = favorites.message_id " +
+ "LEFT JOIN messages_tags ON messages_tags.message_id = messages.message_id " +
+ "WHERE COALESCE(messages_tags.tag_id, 0) != 2 AND favorites.ts > TIMESTAMPADD(HOUR, -2, CURRENT_TIMESTAMP) " +
+ "AND messages.popular=0 GROUP BY messages.message_id HAVING COUNT(DISTINCT favorites.user_id) > 1;", Integer.class);
+ }
+ @Transactional
+ @Override
+ public void setLastReadComment(User user, Integer mid, Integer rid) {
+ jdbcTemplate.update("UPDATE subscr_messages SET last_read_rid=GREATEST(?, last_read_rid) WHERE message_id=? AND suser_id=?",
+ rid, mid, user.getUid());
+ }
+ @Transactional
+ @Override
+ public void setRead(User user, Integer mid) {
+ jdbcTemplate.update("UPDATE subscr_messages SET last_read_rid=(select replies from messages " +
+ "where messages.message_id=subscr_messages.message_id) WHERE message_id=? AND suser_id=?",
+ mid, user.getUid());
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getUnread(User user) {
+ return jdbcTemplate.queryForList(
+ "select subscr_messages.message_id " +
+ "from subscr_messages inner join messages on subscr_messages.message_id=messages.message_id " +
+ "where subscr_messages.suser_id=? and " +
+ "messages.replies>subscr_messages.last_read_rid",
+ Integer.class, user.getUid());
+ }
+
+ @Transactional
+ @Override
+ public boolean updateMessage(Integer mid, Integer rid, String body) {
+ Instant now = Instant.now();
+ if (rid == 0) {
+ return jdbcTemplate.update("UPDATE messages_txt SET txt=?, updated_at=? WHERE message_id=?", body, Timestamp.from(now), mid) > 0;
+ } else {
+ return jdbcTemplate.update("UPDATE replies SET txt=?, updated_at=? WHERE message_id=? and reply_id=?",
+ body, Timestamp.from(now), mid, rid) > 0;
+ }
+ }
+
+ @Override
+ public boolean updateReplyUri(Message reply, URI replyUri) {
+ return jdbcTemplate.update("UPDATE replies SET reply_uri=?, html=1 WHERE message_id=? AND reply_id=?",
+ replyUri.toASCIIString(), reply.getMid(), reply.getRid()) > 0;
+ }
+
+ @Override
+ public boolean replyExists(URI replyUri) {
+ return jdbcTemplate.queryForList("SELECT reply_id FROM replies WHERE reply_uri=?",
+ Integer.class, replyUri.toASCIIString()).size() > 0;
+ }
+
+ @Override
+ public boolean deleteReply(URI userUri, URI replyUri) {
+ return jdbcTemplate.update("DELETE FROM replies WHERE user_uri=? AND reply_uri=?",
+ userUri.toASCIIString(), replyUri.toASCIIString()) > 0;
+ }
+}
diff --git a/src/main/java/com/juick/service/MessengerService.java b/src/main/java/com/juick/service/MessengerService.java
new file mode 100644
index 00000000..e07c73fe
--- /dev/null
+++ b/src/main/java/com/juick/service/MessengerService.java
@@ -0,0 +1,14 @@
+package com.juick.service;
+
+import com.juick.User;
+
+import java.util.Optional;
+
+public interface MessengerService {
+ Integer getUserId(String senderId);
+ Optional<String> getSenderId(User user);
+ boolean createMessengerUser(String senderId, String displayName);
+ String getDisplayName(String hash);
+ String getSignUpHash(String senderId, String username);
+ boolean linkMessengerUser(String hash, int uid);
+}
diff --git a/src/main/java/com/juick/service/MessengerServiceImpl.java b/src/main/java/com/juick/service/MessengerServiceImpl.java
new file mode 100644
index 00000000..57101ffe
--- /dev/null
+++ b/src/main/java/com/juick/service/MessengerServiceImpl.java
@@ -0,0 +1,71 @@
+package com.juick.service;
+
+import com.juick.User;
+import org.springframework.dao.EmptyResultDataAccessException;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+@Repository
+public class MessengerServiceImpl extends BaseJdbcService implements MessengerService {
+
+ @Transactional(readOnly = true)
+ @Override
+ public Integer getUserId(String senderId) {
+ List<Integer> list = getJdbcTemplate().queryForList(
+ "SELECT id FROM users INNER JOIN messenger " +
+ "ON messenger.user_id = users.id WHERE messenger.sender_id=?", Integer.class, senderId);
+
+ return list.isEmpty() ? 0 : list.get(0);
+ }
+ @Transactional(readOnly = true)
+ @Override
+ public Optional<String> getSenderId(User user) {
+ List<String> list = getJdbcTemplate().queryForList(
+ "SELECT sender_id FROM messenger " +
+ "WHERE user_id=?", String.class, user.getUid());
+
+ return list.isEmpty() ? Optional.empty() : Optional.of(list.get(0));
+ }
+
+ @Transactional
+ @Override
+ public boolean createMessengerUser(String senderId, String displayName) {
+ return getJdbcTemplate().update(
+ "INSERT INTO messenger(sender_id, display_name, loginhash) VALUES(?,?,?)",
+ senderId, displayName, UUID.randomUUID().toString()) > 0;
+ }
+ @Transactional(readOnly = true)
+ @Override
+ public String getDisplayName(String hash) {
+ try {
+ return getJdbcTemplate().queryForObject("SELECT display_name FROM messenger WHERE loginhash=?", String.class, hash);
+ } catch (EmptyResultDataAccessException e) {
+ return null;
+ }
+ }
+ @Transactional
+ @Override
+ public String getSignUpHash(final String senderId, final String username) {
+ List<String> list = getJdbcTemplate().queryForList(
+ "SELECT loginhash FROM messenger WHERE sender_id = ? AND user_id IS NULL",
+ String.class,
+ senderId);
+
+ if (list.isEmpty()) {
+ String hash = UUID.randomUUID().toString();
+ getJdbcTemplate().update(
+ "INSERT INTO messenger(sender_id, loginhash, display_name) VALUES (?, ?, ?)", senderId, hash, username);
+ return hash;
+ }
+ return list.get(0);
+ }
+ @Transactional
+ @Override
+ public boolean linkMessengerUser(String hash, int uid) {
+ return getJdbcTemplate().update("UPDATE messenger SET user_id=?, loginhash=NULL WHERE loginhash=?", uid, hash) > 0;
+ }
+}
diff --git a/src/main/java/com/juick/service/PMQueriesService.java b/src/main/java/com/juick/service/PMQueriesService.java
new file mode 100644
index 00000000..e0067544
--- /dev/null
+++ b/src/main/java/com/juick/service/PMQueriesService.java
@@ -0,0 +1,44 @@
+/*
+ * 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.service;
+
+import com.juick.Chat;
+import com.juick.User;
+
+import java.util.List;
+
+/**
+ * Created by aalexeev on 11/13/16.
+ */
+public interface PMQueriesService {
+ boolean createPM(int uidFrom, int uid_to, String body);
+
+ boolean addPMinRoster(int uid, String jid);
+
+ boolean removePMinRoster(int uid, String jid);
+
+ boolean havePMinRoster(int uid, String jid);
+
+ List<Chat> getLastChats(User user);
+
+ List<com.juick.Message> getPMMessages(int uid, int uidTo);
+
+ List<com.juick.Message> getLastPMInbox(int uid);
+
+ List<com.juick.Message> getLastPMSent(int uid);
+}
diff --git a/src/main/java/com/juick/service/PMQueriesServiceImpl.java b/src/main/java/com/juick/service/PMQueriesServiceImpl.java
new file mode 100644
index 00000000..712e4b0e
--- /dev/null
+++ b/src/main/java/com/juick/service/PMQueriesServiceImpl.java
@@ -0,0 +1,149 @@
+/*
+ * 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.service;
+
+import com.juick.Chat;
+import com.juick.User;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.core.namedparam.SqlParameterSource;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+/**
+ * Created by aalexeev on 11/13/16.
+ */
+@Repository
+public class PMQueriesServiceImpl extends BaseJdbcService implements PMQueriesService {
+
+ @Transactional
+ @Override
+ public boolean createPM(final int uidFrom, final int uid_to, final String body) {
+ return getJdbcTemplate().update(
+ "INSERT INTO pm(user_id, user_id_to, txt) VALUES (?, ?, ?)",
+ uidFrom, uid_to, body) > 0;
+ }
+
+ @Transactional
+ @Override
+ public boolean addPMinRoster(final int uid, final String jid) {
+ return getJdbcTemplate().update(
+ "INSERT INTO pm_inroster(user_id, jid) VALUES (?, ?) ON DUPLICATE KEY UPDATE user_id=user_id", uid, jid) > 0;
+ }
+
+ @Transactional
+ @Override
+ public boolean removePMinRoster(final int uid, final String jid) {
+ return getJdbcTemplate().update(
+ "DELETE FROM pm_inroster WHERE user_id = ? AND jid = ?", uid, jid) > 0;
+ }
+
+ @Transactional
+ @Override
+ public boolean havePMinRoster(final int uid, final String jid) {
+ List<Integer> res = getJdbcTemplate().queryForList(
+ "SELECT 1 FROM pm_inroster WHERE user_id = ? AND jid = ?",
+ Integer.class,
+ uid, jid);
+ return res.size() > 0;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Chat> getLastChats(final User user) {
+ return getJdbcTemplate().query(
+ "SELECT l.user_id, users.nick, l.last, pm.txt FROM pm "
+ + "INNER JOIN users ON users.id = pm.user_id "
+ + ""
+ + "INNER JOIN (SELECT user_id, MAX(ts) AS last FROM pm "
+ + "WHERE user_id_to=? GROUP BY user_id) l ON l.last = pm.ts "
+ + "WHERE pm.user_id_to=? "
+ + "ORDER BY l.last DESC",
+ (rs, rowNum) -> {
+ com.juick.Chat u = new com.juick.Chat();
+ u.setUid(rs.getInt(1));
+ u.setName(rs.getString(2));
+ u.setLastMessageTimestamp(rs.getTimestamp(3).toInstant());
+ u.setLastMessageText(rs.getString(4).trim());
+ return u;
+ },
+ user.getUid(), user.getUid());
+ }
+
+ @Transactional
+ @Override
+ public List<com.juick.Message> getPMMessages(final int uid, final int uidTo) {
+ SqlParameterSource sqlParameterSource = new MapSqlParameterSource()
+ .addValue("uid", uid)
+ .addValue("uidTo", uidTo);
+
+ return getNamedParameterJdbcTemplate().query(
+ "SELECT pm.user_id, pm.txt, pm.ts, users.nick FROM pm INNER JOIN users ON users.id=pm.user_id WHERE (user_id = :uid AND user_id_to = :uidTo) "
+ + "OR (user_id_to = :uid AND user_id = :uidTo) ORDER BY ts DESC LIMIT 20",
+ sqlParameterSource,
+ (rs, rowNum) -> {
+ com.juick.Message msg = new com.juick.Message();
+ int uuid = rs.getInt(1);
+ User user = new User();
+ user.setUid(uuid);
+ user.setName(rs.getString(4));
+ msg.setUser(user);
+ msg.setText(rs.getString(2).trim());
+ msg.setTimestamp(rs.getTimestamp(3).toInstant());
+ return msg;
+ });
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<com.juick.Message> getLastPMInbox(final int uid) {
+ return getJdbcTemplate().query(
+ "SELECT pm.user_id, users.nick, pm.txt, pm.ts " +
+ "FROM pm INNER JOIN users ON pm.user_id=users.id WHERE pm.user_id_to=? ORDER BY pm.ts DESC LIMIT 20",
+ (rs, num) -> {
+ com.juick.Message msg = new com.juick.Message();
+ msg.setUser(new User());
+ msg.getUser().setUid(rs.getInt(1));
+ msg.getUser().setName(rs.getString(2));
+ msg.setText(rs.getString(3).trim());
+ msg.setTimestamp(rs.getTimestamp(4).toInstant());
+ return msg;
+ },
+ uid);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<com.juick.Message> getLastPMSent(final int uid) {
+ return getJdbcTemplate().query(
+ "SELECT pm.user_id_to, users.nick, pm.txt, " +
+ "pm.ts FROM pm INNER JOIN users ON pm.user_id_to=users.id " +
+ "WHERE pm.user_id=? ORDER BY pm.ts DESC LIMIT 20",
+ (rs, num) -> {
+ com.juick.Message msg = new com.juick.Message();
+ msg.setUser(new User());
+ msg.getUser().setUid(rs.getInt(1));
+ msg.getUser().setName(rs.getString(2));
+ msg.setText(rs.getString(3).trim());
+ msg.setTimestamp(rs.getTimestamp(4).toInstant());
+ return msg;
+ },
+ uid);
+ }
+}
diff --git a/src/main/java/com/juick/service/PrivacyQueriesService.java b/src/main/java/com/juick/service/PrivacyQueriesService.java
new file mode 100644
index 00000000..17dd6a9b
--- /dev/null
+++ b/src/main/java/com/juick/service/PrivacyQueriesService.java
@@ -0,0 +1,34 @@
+/*
+ * 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.service;
+
+import com.juick.Tag;
+import com.juick.User;
+
+/**
+ * Created by aalexeev on 11/13/16.
+ */
+public interface PrivacyQueriesService {
+ enum PrivacyResult {
+ Removed, Added
+ }
+
+ PrivacyResult blacklistUser(User user, User target);
+
+ PrivacyResult blacklistTag(User user, Tag tag);
+}
diff --git a/src/main/java/com/juick/service/PrivacyQueriesServiceImpl.java b/src/main/java/com/juick/service/PrivacyQueriesServiceImpl.java
new file mode 100644
index 00000000..9f9cda1d
--- /dev/null
+++ b/src/main/java/com/juick/service/PrivacyQueriesServiceImpl.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.service;
+
+import com.juick.Tag;
+import com.juick.User;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Created by aalexeev on 11/13/16.
+ */
+@Repository
+@Transactional
+public class PrivacyQueriesServiceImpl extends BaseJdbcService implements PrivacyQueriesService {
+
+ @Override
+ public PrivacyResult blacklistUser(final User user, final User target) {
+ int result = getJdbcTemplate().update(
+ "DELETE FROM bl_users WHERE user_id = ? AND bl_user_id = ?",
+ user.getUid(), target.getUid());
+
+ if (result > 0)
+ return PrivacyResult.Removed;
+
+ getJdbcTemplate().update(
+ "INSERT INTO bl_users(user_id, bl_user_id) VALUES (?, ?)",
+ user.getUid(), target.getUid());
+
+ return PrivacyResult.Added;
+ }
+
+ @Override
+ public PrivacyResult blacklistTag(final User user, final Tag tag) {
+ int result = getJdbcTemplate().update(
+ "DELETE FROM bl_tags WHERE user_id = ? AND tag_id = ?",
+ user.getUid(), tag.TID);
+
+ if (result > 0)
+ return PrivacyResult.Removed;
+
+ getJdbcTemplate().update(
+ "INSERT INTO bl_tags(user_id, tag_id) VALUES (?, ?)",
+ user.getUid(), tag.TID);
+
+ return PrivacyResult.Added;
+ }
+}
diff --git a/src/main/java/com/juick/service/PushQueriesService.java b/src/main/java/com/juick/service/PushQueriesService.java
new file mode 100644
index 00000000..f84a83e4
--- /dev/null
+++ b/src/main/java/com/juick/service/PushQueriesService.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.service;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * Created by aalexeev on 11/13/16.
+ */
+public interface PushQueriesService {
+ List<String> getGCMRegID(int uid);
+
+ List<String> getGCMTokens(Collection<Integer> uids);
+
+ boolean addGCMToken(Integer uid, String token);
+
+ boolean deleteGCMToken(String token);
+
+ List<String> getMPNSURL(int uid);
+
+ List<String> getMPNSTokens(Collection<Integer> uids);
+
+ boolean addMPNSToken(Integer uid, String token);
+
+ boolean deleteMPNSToken(String token);
+
+ List<String> getAPNSToken(int uid);
+
+ List<String> getAPNSTokens(Collection<Integer> uids);
+
+ boolean addAPNSToken(Integer uid, String token);
+
+ boolean deleteAPNSToken(String token);
+}
diff --git a/src/main/java/com/juick/service/PushQueriesServiceImpl.java b/src/main/java/com/juick/service/PushQueriesServiceImpl.java
new file mode 100644
index 00000000..7f97956c
--- /dev/null
+++ b/src/main/java/com/juick/service/PushQueriesServiceImpl.java
@@ -0,0 +1,143 @@
+/*
+ * 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.service;
+
+import org.apache.commons.collections4.CollectionUtils;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.inject.Inject;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Created by aalexeev on 11/13/16.
+ */
+@Repository
+public class PushQueriesServiceImpl extends BaseJdbcService implements PushQueriesService {
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<String> getGCMRegID(final int uid) {
+ return getJdbcTemplate().queryForList(
+ "SELECT regid FROM android WHERE user_id=?",
+ String.class,
+ uid);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<String> getGCMTokens(final Collection<Integer> uids) {
+ if (CollectionUtils.isEmpty(uids))
+ return Collections.emptyList();
+
+ return getNamedParameterJdbcTemplate().queryForList(
+ "SELECT regid FROM android INNER JOIN users ON (users.id = android.user_id) WHERE users.id IN (:ids)",
+ new MapSqlParameterSource("ids", uids),
+ String.class);
+ }
+
+ @Transactional
+ @Override
+ public boolean addGCMToken(Integer uid, String token) {
+ return getJdbcTemplate().update("INSERT IGNORE INTO android(user_id,regid) VALUES (?, ?)",
+ uid, token) > 0;
+ }
+
+ @Transactional
+ @Override
+ public boolean deleteGCMToken(String token) {
+ return getJdbcTemplate().update("DELETE FROM android WHERE regid=?", token) > 0;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<String> getMPNSURL(final int uid) {
+ return getJdbcTemplate().queryForList(
+ "SELECT url FROM winphone WHERE user_id=?",
+ String.class,
+ uid);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<String> getMPNSTokens(final Collection<Integer> uids) {
+ if (CollectionUtils.isEmpty(uids))
+ return Collections.emptyList();
+
+ return getNamedParameterJdbcTemplate().queryForList(
+ "SELECT url FROM winphone INNER JOIN users ON (users.id=winphone.user_id) WHERE users.id IN (:ids)",
+ new MapSqlParameterSource("ids", uids),
+ String.class);
+ }
+
+ @Transactional
+ @Override
+ public boolean addMPNSToken(Integer uid, String token) {
+ return getJdbcTemplate().update("INSERT IGNORE INTO winphone(user_id,url) VALUES (?, ?)",
+ uid, token) > 0;
+ }
+
+ @Transactional
+ @Override
+ public boolean deleteMPNSToken(String token) {
+ return getJdbcTemplate().update("DELETE FROM winphone WHERE url=?", token) > 0;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<String> getAPNSToken(final int uid) {
+ return getJdbcTemplate().queryForList(
+ "SELECT token from ios WHERE user_id=?",
+ String.class,
+ uid);
+ }
+
+ @Transactional
+ @Override
+ public boolean deleteAPNSToken(String token) {
+ return getJdbcTemplate().update("DELETE FROM ios WHERE token=?", token) > 0;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<String> getAPNSTokens(final Collection<Integer> uids) {
+ if (CollectionUtils.isEmpty(uids))
+ return Collections.emptyList();
+
+ return getNamedParameterJdbcTemplate().queryForList(
+ "SELECT token FROM ios INNER JOIN users ON (users.id = ios.user_id) WHERE users.id IN (:ids)",
+ new MapSqlParameterSource("ids", uids),
+ String.class);
+ }
+
+ @Transactional
+ @Override
+ public boolean addAPNSToken(Integer uid, String token) {
+ try {
+ return getJdbcTemplate().update("INSERT INTO ios(user_id,token) VALUES (?, ?)",
+ uid, token) > 0;
+ } catch (DuplicateKeyException e) {
+ return true;
+ }
+ }
+}
diff --git a/src/main/java/com/juick/service/SearchService.java b/src/main/java/com/juick/service/SearchService.java
new file mode 100644
index 00000000..0dae5cfc
--- /dev/null
+++ b/src/main/java/com/juick/service/SearchService.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.service;
+
+import com.juick.User;
+
+import java.util.List;
+
+/**
+ * Created by aalexeev on 11/18/16.
+ */
+public interface SearchService {
+ void setMaxResult(int maxResult);
+
+ List<Integer> searchInAllMessages(User visitor, String searchString, int messageIdBefore);
+
+ List<Integer> searchByStringAndUser(User visitor, String searchString, final int userId, int messageIdBefore);
+}
diff --git a/src/main/java/com/juick/service/ShowQueriesService.java b/src/main/java/com/juick/service/ShowQueriesService.java
new file mode 100644
index 00000000..32b34b4e
--- /dev/null
+++ b/src/main/java/com/juick/service/ShowQueriesService.java
@@ -0,0 +1,31 @@
+/*
+ * 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.service;
+
+import com.juick.User;
+
+import java.util.List;
+
+/**
+ * Created by aalexeev on 11/13/16.
+ */
+public interface ShowQueriesService {
+ List<String> getRecommendedUsers(User forUser);
+
+ List<String> getTopUsers();
+}
diff --git a/src/main/java/com/juick/service/ShowQueriesServiceImpl.java b/src/main/java/com/juick/service/ShowQueriesServiceImpl.java
new file mode 100644
index 00000000..0fba35f1
--- /dev/null
+++ b/src/main/java/com/juick/service/ShowQueriesServiceImpl.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.service;
+
+import com.juick.User;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Created by aalexeev on 11/13/16.
+ */
+@Repository
+@Transactional(readOnly = true)
+public class ShowQueriesServiceImpl extends BaseJdbcService implements ShowQueriesService {
+
+ @Override
+ public List<String> getRecommendedUsers(final User forUser) {
+ if (forUser == null)
+ return Collections.emptyList();
+
+ return getNamedParameterJdbcTemplate().queryForList(
+ "SELECT u.nick FROM subscr_users su1 INNER JOIN users u " +
+ "ON su1.user_id = u.id " +
+ "WHERE NOT EXISTS (SELECT 1 FROM subscr_users su2 WHERE su2.suser_id = :uid and su1.user_id = su2.user_id) " +
+ "AND EXISTS (SELECT 1 FROM subscr_users su3 WHERE su3.suser_id = :uid and su3.user_id = su1.suser_id ) " +
+ "AND NOT EXISTS (SELECT 1 FROM bl_users b WHERE b.user_id = :uid and su1.user_id = b.bl_user_id) " +
+ "AND su1.user_id != :uid AND u.lastmessage > UNIX_TIMESTAMP() - 259200 " +
+ "GROUP BY su1.user_id ORDER BY count(*) DESC LIMIT 10",
+ new MapSqlParameterSource("uid", forUser.getUid()),
+ String.class);
+ }
+
+ @Override
+ public List<String> getTopUsers() {
+ return getJdbcTemplate().query(
+ "SELECT users.nick,COUNT(subscr_users.suser_id) AS cnt " +
+ "FROM (subscr_users INNER JOIN users ON subscr_users.user_id=users.id) " +
+ "INNER JOIN useroptions ON users.id=useroptions.user_id " +
+ "WHERE useroptions.privacy_view>0 AND users.lastmessage > UNIX_TIMESTAMP() - 259200 " +
+ "AND users.id!=2 GROUP BY subscr_users.user_id ORDER BY cnt DESC LIMIT 10",
+ (rs, rowNum) -> rs.getString(1));
+ }
+}
diff --git a/src/main/java/com/juick/service/SocialService.java b/src/main/java/com/juick/service/SocialService.java
new file mode 100644
index 00000000..eb77619b
--- /dev/null
+++ b/src/main/java/com/juick/service/SocialService.java
@@ -0,0 +1,16 @@
+package com.juick.service;
+
+import com.juick.User;
+
+import javax.annotation.Nonnull;
+import java.util.List;
+
+public interface SocialService {
+ @Nonnull
+ User getUserByAccountUri(String acct);
+ @Nonnull
+ List<String> getFollowers(User user);
+ void addFollower(User user, String acct);
+ void removeFollower(User user, String acct);
+ void removeAccount(String acct);
+}
diff --git a/src/main/java/com/juick/service/SphinxSearchService.java b/src/main/java/com/juick/service/SphinxSearchService.java
new file mode 100644
index 00000000..de7bd7f2
--- /dev/null
+++ b/src/main/java/com/juick/service/SphinxSearchService.java
@@ -0,0 +1,97 @@
+/*
+ * 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.service;
+
+import com.juick.User;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.inject.Inject;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Created by aalexeev on 11/18/16.
+ */
+
+@Repository
+@Transactional(readOnly = true)
+public class SphinxSearchService extends BaseJdbcService implements SearchService {
+ private static final int DEFAULT_MAX_RESULT = 20;
+
+ private int maxResult = DEFAULT_MAX_RESULT;
+
+ @Inject
+ UserService userService;
+
+ public String sortHint(String searchString) {
+ boolean isOneWord = searchString.split("[^\\S\\+]+").length == 1;
+ return isOneWord ? "extended:@id desc" : "extended:@weight desc, @id desc";
+ }
+
+ @Override
+ public List<Integer> searchInAllMessages(User visitor, final String searchString, final int page) {
+ if (StringUtils.isBlank(searchString))
+ return Collections.emptyList();
+
+ Map<String, String> sphinxQuery = new HashMap<>();
+ sphinxQuery.put("limit", String.valueOf(maxResult));
+ sphinxQuery.put("mode", "any");
+ sphinxQuery.put("sort", sortHint(searchString));
+ String usersFilter = userService.getUserBLUsers(visitor.getUid()).stream().map(u -> String.valueOf(u.getUid())).collect(Collectors.joining(","));
+ sphinxQuery.put("!filter", "user_id," + usersFilter);
+ if (page > 0) {
+ sphinxQuery.put("offset", String.valueOf(page * maxResult));
+ }
+
+ return getJdbcTemplate().queryForList(
+ String.format("SELECT id FROM search WHERE query = '%s;%s'", searchString,
+ sphinxQuery.entrySet().stream().map(Object::toString)
+ .collect(Collectors.joining(";"))), Integer.class);
+ }
+
+ @Override
+ public List<Integer> searchByStringAndUser(User visitor, final String searchString, final int userId, int page) {
+ if (StringUtils.isBlank(searchString))
+ return Collections.emptyList();
+
+ Map<String, String> sphinxQuery = new HashMap<>();
+ sphinxQuery.put("limit", String.valueOf(maxResult));
+ sphinxQuery.put("mode", "any");
+ sphinxQuery.put("sort", sortHint(searchString));
+ if (page > 0) {
+ sphinxQuery.put("offset", String.valueOf(page * maxResult));
+ }
+ return getJdbcTemplate().queryForList(
+ String.format("SELECT id FROM search WHERE query = '%s;%s;filter=user_id,%d'", searchString,
+ sphinxQuery.entrySet().stream().map(Object::toString)
+ .collect(Collectors.joining(";")), userId), Integer.class);
+ }
+
+ @Override
+ public void setMaxResult(int maxResult) {
+ if (maxResult <= 0)
+ throw new IllegalArgumentException("maxResult value (" + maxResult + ") must be greater then 0");
+
+ this.maxResult = maxResult;
+ }
+} \ No newline at end of file
diff --git a/src/main/java/com/juick/service/SubscriptionService.java b/src/main/java/com/juick/service/SubscriptionService.java
new file mode 100644
index 00000000..8bc8d071
--- /dev/null
+++ b/src/main/java/com/juick/service/SubscriptionService.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.service;
+
+import com.juick.Message;
+import com.juick.Tag;
+import com.juick.User;
+import com.juick.model.NotifyOpts;
+
+import java.util.List;
+
+/**
+ * Created by aalexeev on 11/13/16.
+ */
+public interface SubscriptionService {
+
+ List<User> getSubscribedUsers(int uid, Message msg);
+
+ List<User> getUsersSubscribedToComments(Message msg, Message reply);
+
+ List<User> getUsersSubscribedToComments(Message msg, Message reply, boolean blacklisted);
+
+ List<User> getUsersSubscribedToUserRecommendations(int uid, Message msg);
+
+ boolean subscribeMessage(Message message, User user);
+
+ boolean unSubscribeMessage(int mid, int vuid);
+
+ boolean subscribeUser(User user, User toUser);
+
+ boolean unSubscribeUser(User user, User fromUser);
+
+ boolean subscribeTag(User user, Tag toTag);
+
+ boolean unSubscribeTag(User user, Tag toTag);
+
+ List<String> getSubscribedTags(User user);
+
+ NotifyOpts getNotifyOptions(User user);
+
+ boolean setNotifyOptions(User user, NotifyOpts options);
+}
diff --git a/src/main/java/com/juick/service/SubscriptionServiceImpl.java b/src/main/java/com/juick/service/SubscriptionServiceImpl.java
new file mode 100644
index 00000000..5ce3593b
--- /dev/null
+++ b/src/main/java/com/juick/service/SubscriptionServiceImpl.java
@@ -0,0 +1,229 @@
+/*
+ * 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.service;
+
+import com.juick.Message;
+import com.juick.Tag;
+import com.juick.User;
+import com.juick.model.NotifyOpts;
+import com.juick.util.MessageUtils;
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.commons.collections4.IteratorUtils;
+import org.apache.commons.collections4.ListUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Nonnull;
+import javax.inject.Inject;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Created by aalexeev on 11/13/16.
+ */
+@Repository
+public class SubscriptionServiceImpl extends BaseJdbcService implements SubscriptionService {
+ @Inject
+ private UserService userService;
+ @Inject
+ private MessagesService messagesService;
+ @Inject
+ private TagService tagService;
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<User> getSubscribedUsers(final int uid, final Message msg) {
+ int mid = msg.getMid();
+ User author = messagesService.getMessageAuthor(mid);
+
+ List<User> subscribers = userService.getUserReaders(uid);
+ List<User> mentionedUsers = userService.getUsersByName(MessageUtils.getMentions(msg).stream()
+ .map(u -> u.substring(1)).collect(Collectors.toList()));
+ List<User> users = ListUtils.union(subscribers, mentionedUsers);
+ List<Integer> tags = tagService.getMessageTagsIDs(mid);
+ List<String> tagsStr = tagService.getMessageTags(mid).stream().map(t -> t.getTag().getName()).collect(Collectors.toList());
+
+ Set<Integer> set = new HashSet<>();
+ set.addAll(
+ users.stream()
+ .map(User::getUid).filter(u -> Collections.disjoint(tagService.getUserBLTags(u), tagsStr))
+ .collect(Collectors.toList()));
+
+
+ if (!tags.isEmpty()) {
+ List<Integer> tagUsers = getNamedParameterJdbcTemplate().queryForList(
+ "SELECT st.suser_id FROM subscr_tags st " +
+ "WHERE st.tag_id IN (:ids) AND st.suser_id != :uid " +
+ " AND NOT EXISTS (SELECT 1 FROM bl_users bu WHERE bu.bl_user_id = :authorUid and st.suser_id = bu.user_id)" +
+ " AND NOT EXISTS (SELECT 1 FROM bl_tags bt WHERE bt.tag_id IN (:ids) and st.suser_id = bt.user_id)",
+ new MapSqlParameterSource()
+ .addValue("ids", tags)
+ .addValue("uid", uid)
+ .addValue("authorUid", author.getUid()),
+ Integer.class);
+ set.addAll(tagUsers);
+ }
+ return userService.getUsersByID(set);
+ }
+ @Override
+ public List<User> getUsersSubscribedToComments(@Nonnull final Message msg, @Nonnull final Message reply) {
+ return getUsersSubscribedToComments(msg, reply, false);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<User> getUsersSubscribedToComments(@Nonnull final Message msg, @Nonnull final Message reply,
+ boolean blacklisted) {
+ List<User> subscribers = userService.getUsersByID(getJdbcTemplate().queryForList(
+ "SELECT suser_id FROM subscr_messages WHERE message_id=? AND suser_id!=?",
+ Integer.class,
+ msg.getMid(), reply.getUser().getUid()));
+ List<User> mentionedUsers = userService.getUsersByName(MessageUtils.getMentions(reply).stream()
+ .map(u -> u.substring(1)).collect(Collectors.toList()));
+ List<User> users = IteratorUtils.toList(CollectionUtils.union(subscribers, mentionedUsers).iterator());
+ if (!users.isEmpty()) {
+ return users.stream()
+ .filter(u -> blacklisted || !userService.isReplyToBL(u, reply))
+ .collect(Collectors.toList());
+ }
+ return Collections.emptyList();
+ }
+
+ @Override
+ public List<User> getUsersSubscribedToUserRecommendations(final int uid, final Message msg) {
+ List<String> msgTags = tagService.getMessageTags(msg.getMid()).stream().map(t -> t.getTag().getName()).collect(Collectors.toList());
+ if (msg.getLikes() == 1) {
+ return userService.getUserReaders(uid).stream()
+ .filter(u -> !u.equals(msg.getUser()))
+ .filter(u -> !userService.isInBLAny(u.getUid(), msg.getUser().getUid()))
+ .filter(u -> Collections.disjoint(tagService.getUserBLTags(u.getUid()), msgTags))
+ .collect(Collectors.toList());
+ }
+ return Collections.emptyList();
+ }
+
+ @Transactional
+ @Override
+ public boolean subscribeMessage(final Message message, final User user) {
+ try {
+ boolean result = getJdbcTemplate().update(
+ "INSERT INTO subscr_messages(suser_id, message_id) VALUES (?, ?)", user.getUid(), message.getMid()) == 1;
+ messagesService.setLastReadComment(user, message.getMid(), message.getReplies());
+ return result;
+ } catch (DuplicateKeyException e) {
+ return true;
+ }
+ }
+
+ @Transactional
+ @Override
+ public boolean unSubscribeMessage(final int mid, final int vuid) {
+ return getJdbcTemplate().update(
+ "DELETE FROM subscr_messages WHERE message_id=? AND suser_id=?", mid, vuid) > 0;
+ }
+
+ @Transactional
+ @Override
+ public boolean subscribeUser(final User user, final User toUser) {
+ try {
+ return getJdbcTemplate().update(
+ "INSERT INTO subscr_users(user_id,suser_id) VALUES (?,?)", toUser.getUid(), user.getUid()) == 1;
+ } catch (DuplicateKeyException e) {
+ return true;
+ }
+ }
+
+ @Transactional
+ @Override
+ public boolean unSubscribeUser(final User user, final User fromUser) {
+ return getJdbcTemplate().update(
+ "DELETE FROM subscr_users WHERE suser_id=? AND user_id=?", user.getUid(), fromUser.getUid()) > 0;
+ }
+
+ @Transactional
+ @Override
+ public boolean subscribeTag(final User user, final Tag toTag) {
+ try {
+
+ return getJdbcTemplate().update(
+ "INSERT INTO subscr_tags(tag_id,suser_id) VALUES (?,?)", toTag.TID, user.getUid()) == 1;
+ } catch (DuplicateKeyException e) {
+ return true;
+ }
+ }
+
+ @Transactional
+ @Override
+ public boolean unSubscribeTag(final User user, final Tag toTag) {
+ return getJdbcTemplate().update(
+ "DELETE FROM subscr_tags WHERE tag_id=? AND suser_id=?", toTag.TID, user.getUid()) > 0;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<String> getSubscribedTags(User user) {
+ return getJdbcTemplate().queryForList("SELECT tags.name FROM subscr_tags INNER JOIN tags " +
+ "ON(tags.tag_id = subscr_tags.tag_id) " +
+ "WHERE subscr_tags.suser_id=? ORDER BY tags.name", String.class, user.getUid());
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public NotifyOpts getNotifyOptions(final User user) {
+ List<NotifyOpts> list = getJdbcTemplate().query(
+ "SELECT jnotify,subscr_notify,recommendations FROM useroptions WHERE user_id=?",
+ (rs, num) -> {
+ NotifyOpts options = new NotifyOpts();
+ options.setRepliesEnabled(rs.getInt(1) > 0);
+ options.setSubscriptionsEnabled(rs.getInt(2) > 0);
+ options.setRecommendationsEnabled(rs.getInt(3) > 0);
+ return options;
+ },
+ user.getUid());
+
+ return list.isEmpty() ?
+ new NotifyOpts() : list.get(0);
+ }
+
+ @Transactional
+ @Override
+ public boolean setNotifyOptions(final User user, final NotifyOpts options) {
+ int jnotify = getJdbcTemplate().update(
+ "UPDATE useroptions SET jnotify=? WHERE user_id=?",
+ options.isRepliesEnabled() ? 1 : 0,
+ user.getUid());
+
+ int subscr_notify = getJdbcTemplate().update(
+ "UPDATE useroptions SET subscr_notify=? WHERE user_id=?",
+ options.isSubscriptionsEnabled() ? 1 : 0,
+ user.getUid());
+
+ int recommendations = getJdbcTemplate().update(
+ "UPDATE useroptions SET recommendations=? WHERE user_id=?",
+ options.isRecommendationsEnabled() ? 1 : 0,
+ user.getUid());
+
+ return jnotify > 0 && subscr_notify > 0 && recommendations > 0;
+ }
+}
diff --git a/src/main/java/com/juick/service/TagService.java b/src/main/java/com/juick/service/TagService.java
new file mode 100644
index 00000000..489f405a
--- /dev/null
+++ b/src/main/java/com/juick/service/TagService.java
@@ -0,0 +1,64 @@
+/*
+ * 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.service;
+
+import com.juick.Tag;
+import com.juick.User;
+import com.juick.model.TagStats;
+import org.apache.commons.lang3.tuple.Pair;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.stream.Stream;
+
+/**
+ * Created by aalexeev on 11/13/16.
+ */
+public interface TagService {
+ Tag getTag(int tid);
+
+ Tag getTag(String tag, boolean autoCreate);
+
+ List<Tag> getTags(Stream<String> tags, boolean autoCreate);
+
+ boolean getTagNoIndex(int tagId);
+
+ int createTag(String name);
+
+ List<TagStats> getUserTagStats(int uid);
+
+ List<String> getUserBLTags(int uid);
+
+ List<String> getPopularTags();
+
+ List<TagStats> getTagStats();
+
+ List<Tag> updateTags(int mid, Collection<Tag> newTags);
+
+ Pair<String, List<Tag>> fromString(String txt);
+
+ List<TagStats> getMessageTags(int mid);
+
+ List<Integer> getMessageTagsIDs(int mid);
+
+ boolean blacklistTag(User user, Tag tag);
+
+ boolean isInBL(User user, Tag tag);
+
+ boolean isSubscribed(User user, Tag tag);
+}
diff --git a/src/main/java/com/juick/service/TagServiceImpl.java b/src/main/java/com/juick/service/TagServiceImpl.java
new file mode 100644
index 00000000..42159d3b
--- /dev/null
+++ b/src/main/java/com/juick/service/TagServiceImpl.java
@@ -0,0 +1,277 @@
+/*
+ * 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.service;
+
+import com.juick.Tag;
+import com.juick.User;
+import com.juick.model.TagStats;
+import com.juick.util.StreamUtils;
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.tuple.Pair;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Created by aalexeev on 11/13/16.
+ */
+@Repository
+public class TagServiceImpl extends BaseJdbcService implements TagService {
+
+ @Transactional(readOnly = true)
+ @Override
+ public com.juick.Tag getTag(final int tid) {
+ List<Tag> list = getJdbcTemplate().query(
+ "SELECT synonym_id,name FROM tags WHERE tag_id=?",
+ (rs, num) -> {
+ Tag ret = new Tag(rs.getString(2));
+ ret.TID = tid;
+ ret.SynonymID = rs.getInt(1);
+ return ret;
+ },
+ tid);
+
+ return list.isEmpty() ?
+ null : list.get(0);
+ }
+
+ @Transactional
+ @Override
+ public com.juick.Tag getTag(final String tag, final boolean autoCreate) {
+ if (StringUtils.isBlank(tag))
+ return null;
+
+ List<Tag> list = getJdbcTemplate().query(
+ "SELECT tag_id, synonym_id, name FROM tags WHERE name = ?",
+ (rs, rowNum) -> {
+ Tag ret1 = new Tag(rs.getString(3));
+ ret1.TID = rs.getInt(1);
+ ret1.SynonymID = rs.getInt(2);
+ return ret1;
+ },
+ tag);
+
+ Tag ret = list.isEmpty() ?
+ null : list.get(0);
+
+ if (ret == null && autoCreate) {
+ ret = new com.juick.Tag(tag);
+ ret.TID = createTag(tag);
+ }
+
+ return ret;
+ }
+
+ @Override
+ public List<Tag> getTags(Stream<String> tags, final boolean autoCreate) {
+ return tags.filter(StringUtils::isNotBlank).map(tag -> getTag(tag, autoCreate)).filter(Objects::nonNull).distinct()
+ .collect(Collectors.toList());
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public boolean getTagNoIndex(final int tagId) {
+ List<Integer> list = getJdbcTemplate().queryForList(
+ "SELECT noindex FROM tags WHERE tag_id=?", Integer.class, tagId);
+
+ return !list.isEmpty() && list.get(0) == 1;
+ }
+
+ @Transactional
+ @Override
+ public int createTag(final String name) {
+ KeyHolder holder = new GeneratedKeyHolder();
+ getJdbcTemplate().update(
+ con -> {
+ PreparedStatement stmt = con.prepareStatement(
+ "INSERT INTO tags(name) VALUES (?)",
+ Statement.RETURN_GENERATED_KEYS);
+ stmt.setString(1, name);
+ return stmt;
+ },
+ holder);
+
+ return holder.getKey().intValue();
+ }
+
+ private class TagStatsMapper implements RowMapper<TagStats> {
+
+ @Override
+ public TagStats mapRow(ResultSet rs, int rowNum) throws SQLException {
+ Tag t = new Tag(rs.getString(1));
+ TagStats s = new TagStats();
+ s.setTag(t);
+ s.setUsageCount(rs.getInt(2));
+ return s;
+ }
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<TagStats> getUserTagStats(final int uid) {
+ return getJdbcTemplate().query(
+ "SELECT tags.name,COUNT(messages.message_id) " +
+ "FROM (messages INNER JOIN messages_tags ON (messages.user_id=? " +
+ "AND messages.message_id=messages_tags.message_id)) " +
+ "INNER JOIN tags ON messages_tags.tag_id=tags.tag_id GROUP BY tags.tag_id ORDER BY tags.name ASC",
+ new TagStatsMapper(),
+ uid);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<String> getUserBLTags(final int uid) {
+ return getJdbcTemplate().queryForList(
+ "SELECT tags.name FROM tags INNER JOIN bl_tags " +
+ "ON (bl_tags.user_id = ? AND bl_tags.tag_id = tags.tag_id) ORDER BY tags.name",
+ String.class, uid);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<String> getPopularTags() {
+ return getJdbcTemplate().queryForList(
+ "select name from tags where noindex=0 order by stat_messages desc limit 20", String.class);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<TagStats> getTagStats() {
+ return getJdbcTemplate().query(
+ "SELECT tags.name,COUNT(DISTINCT messages.user_id) AS cnt " +
+ "FROM (messages INNER JOIN messages_tags ON (messages.ts>TIMESTAMPADD(DAY,-3,NOW()) " +
+ "AND messages.message_id=messages_tags.message_id)) " +
+ "INNER JOIN tags ON messages_tags.tag_id=tags.tag_id " +
+ "WHERE tags.tag_id NOT IN (SELECT tag_id FROM tags_ignore) " +
+ "GROUP BY tags.tag_id ORDER BY cnt DESC LIMIT 20", new TagStatsMapper());
+ }
+
+ @Transactional
+ @Override
+ public List<Tag> updateTags(final int mid, final Collection<Tag> newTags) {
+ List<Tag> currentTags = getMessageTags(mid).stream()
+ .map(TagStats::getTag).collect(Collectors.toList());
+
+ if (CollectionUtils.isEmpty(newTags))
+ return currentTags;
+
+ List<Integer> idsForDelete = newTags.stream()
+ .filter(currentTags::contains)
+ .map(tag -> tag.TID)
+ .collect(Collectors.toList());
+ if (newTags.size() - idsForDelete.size() >= 5) {
+ return currentTags;
+ }
+
+ if (!idsForDelete.isEmpty())
+ getNamedParameterJdbcTemplate().update(
+ "DELETE FROM messages_tags WHERE message_id = :mid AND tag_id in (:ids)",
+ new MapSqlParameterSource().addValue("ids", idsForDelete).addValue("mid", mid));
+
+ newTags.stream().filter(t -> !currentTags.contains(t))
+ .forEach(t -> getJdbcTemplate().update("INSERT INTO messages_tags(message_id,tag_id) VALUES (?,?)", mid, t.TID));
+
+ List<Tag> result = getMessageTags(mid).stream()
+ .map(TagStats::getTag).collect(Collectors.toList());
+ jdbcTemplate.update("UPDATE messages_txt SET tags=? WHERE message_id=?", result.stream()
+ .map(Tag::getName).collect(Collectors.joining(" ")), mid);
+ return result;
+ }
+
+ @Override
+ public Pair<String, List<Tag>> fromString(final String txt) {
+ String firstLine = txt.split("\\n", 2)[0];
+ Supplier<Stream<String>> tagsStream = () -> StreamUtils.takeWhile(Arrays.stream(firstLine.split("\\ ")),
+ t -> !t.equals("*") && t.startsWith("*"));
+ int tagsLength = tagsStream.get().collect(Collectors.joining(" ")).length();
+ String body = txt.substring(tagsLength);
+ List<Tag> tags = tagsStream.get().map(t -> getTag(t.substring(1), true))
+ .distinct().collect(Collectors.toList());
+ return Pair.of(body, tags);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<TagStats> getMessageTags(final int mid) {
+ return getJdbcTemplate().query(
+ "SELECT tags.tag_id,synonym_id,name,stat_messages FROM tags " +
+ "INNER JOIN messages_tags ON (messages_tags.message_id = ? AND messages_tags.tag_id = tags.tag_id)",
+ (rs, num) -> {
+ com.juick.Tag t = new com.juick.Tag(rs.getString(3));
+ t.TID = rs.getInt(1);
+ t.SynonymID = rs.getInt(2);
+ TagStats s = new TagStats();
+ s.setTag(t);
+ s.setUsageCount(rs.getInt(4));
+ return s;
+ }, mid);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> getMessageTagsIDs(final int mid) {
+ return getJdbcTemplate().queryForList(
+ "SELECT tag_id FROM messages_tags WHERE message_id = ?",
+ Integer.class, mid);
+ }
+
+ @Override
+ public boolean blacklistTag(User user, Tag tag) {
+ int rowcount = getNamedParameterJdbcTemplate().update("DELETE FROM bl_tags WHERE tag_id = :tid AND user_id = :uid",
+ new MapSqlParameterSource().addValue("tid", tag.TID).addValue("uid", user.getUid()));
+ return rowcount <= 0 && getNamedParameterJdbcTemplate()
+ .update("INSERT INTO bl_tags(user_id, tag_id) VALUES(:uid,:tid)",
+ new MapSqlParameterSource().addValue("tid", tag.TID)
+ .addValue("uid", user.getUid())) > 0;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public boolean isInBL(User user, Tag tag) {
+ List<Integer> list = getJdbcTemplate().queryForList(
+ "SELECT 1 FROM bl_tags WHERE user_id = ? AND tag_id = ?",
+ Integer.class, user.getUid(), tag.TID);
+ return !list.isEmpty() && list.get(0) == 1;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public boolean isSubscribed(User user, Tag tag) {
+ List<Integer> list = getJdbcTemplate().queryForList(
+ "SELECT 1 FROM subscr_tags WHERE suser_id = ? AND tag_id = ?",
+ Integer.class, user.getUid(), tag.TID);
+ return !list.isEmpty() && list.get(0) == 1;
+ }
+
+}
diff --git a/src/main/java/com/juick/service/TelegramService.java b/src/main/java/com/juick/service/TelegramService.java
new file mode 100644
index 00000000..2954370c
--- /dev/null
+++ b/src/main/java/com/juick/service/TelegramService.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.service;
+
+import com.juick.User;
+
+import java.util.List;
+
+/**
+ * Created by vt on 24/11/2016.
+ */
+public interface TelegramService {
+
+ boolean deleteAnonymous(Long id);
+
+ List<Long> getAnonymous();
+
+ int getUser(long tgId);
+
+ boolean createTelegramUser(long tgID, String tgName);
+
+ boolean deleteTelegramUser(Integer uid);
+
+ List<Long> getTelegramIdentifiers(List<User> users);
+}
diff --git a/src/main/java/com/juick/service/TelegramServiceImpl.java b/src/main/java/com/juick/service/TelegramServiceImpl.java
new file mode 100644
index 00000000..99cbabf6
--- /dev/null
+++ b/src/main/java/com/juick/service/TelegramServiceImpl.java
@@ -0,0 +1,84 @@
+/*
+ * 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.service;
+
+import com.juick.User;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+/**
+ * Created by vt on 24/11/2016.
+ */
+@Repository
+public class TelegramServiceImpl extends BaseJdbcService implements TelegramService {
+
+ @Transactional
+ @Override
+ public boolean deleteAnonymous(Long id) {
+ return getJdbcTemplate().update("DELETE FROM telegram WHERE tg_id=? AND user_id IS NULL", id) > 0;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Long> getAnonymous() {
+ return getJdbcTemplate().queryForList("SELECT tg_id FROM telegram WHERE user_id IS NULL", Long.class);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public int getUser(final long tgId) {
+ List<Integer> list = getJdbcTemplate().queryForList(
+ "SELECT id FROM users INNER JOIN telegram " +
+ "ON telegram.user_id = users.id WHERE telegram.tg_id=?", Integer.class, tgId);
+
+ return list.isEmpty() ? 0 : list.get(0);
+ }
+
+ @Transactional
+ @Override
+ public boolean createTelegramUser(final long tgID, final String tgName) {
+ return getJdbcTemplate().update(
+ "INSERT INTO telegram(tg_id, tg_name, loginhash) VALUES(?,?,?)",
+ tgID, tgName, UUID.randomUUID().toString()) > 0;
+ }
+
+ @Transactional
+ @Override
+ public boolean deleteTelegramUser(Integer uid) {
+ return getJdbcTemplate().update("DELETE FROM telegram WHERE user_id=?", uid) > 0;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Long> getTelegramIdentifiers(List<User> users) {
+ List<Integer> uids = users.stream().map(User::getUid).collect(Collectors.toList());
+ if (uids.isEmpty()) {
+ return Collections.emptyList();
+ }
+ return getNamedParameterJdbcTemplate().queryForList("" +
+ "SELECT tg_id FROM telegram WHERE user_id IN(:uids)", new MapSqlParameterSource()
+ .addValue("uids", uids), Long.class);
+ }
+}
diff --git a/src/main/java/com/juick/service/UserService.java b/src/main/java/com/juick/service/UserService.java
new file mode 100644
index 00000000..832f978a
--- /dev/null
+++ b/src/main/java/com/juick/service/UserService.java
@@ -0,0 +1,137 @@
+/*
+ * 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.service;
+
+import com.juick.Message;
+import com.juick.User;
+import com.juick.model.Auth;
+import com.juick.model.UserInfo;
+
+import javax.annotation.Nonnull;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Created by aalexeev on 11/13/16.
+ */
+public interface UserService {
+ enum ActiveStatus {
+ Inactive,
+ Active
+ }
+
+ String getSignUpHashByJID(String jid);
+
+ String getSignUpHashByTelegramID(Long telegramId, String username);
+
+ int createUser(String username, String password);
+
+ Optional<User> getUserByUID(int uid);
+
+ @Nonnull User getUserByName(String username);
+
+ @Nonnull User getUserByEmail(String email);
+
+ User getUserByJID(String jid);
+
+ List<User> getUsersByName(Collection<String> unames);
+
+ List<User> getUsersByID(Collection<Integer> uids);
+
+ List<String> getJIDsbyUID(int uid);
+
+ int getUIDbyJID(String jid);
+
+ int getUIDbyName(String uname);
+
+ int getUIDbyHash(String hash);
+
+ @Nonnull com.juick.User getUserByHash(String hash);
+
+ String getHashByUID(int uid);
+
+ int checkPassword(String username, String password);
+
+ boolean updatePassword(User user, String newPassword);
+
+ int getUserOptionInt(int uid, String option, int defaultValue);
+
+ int setUserOptionInt(int uid, String option, int value);
+
+ UserInfo getUserInfo(User user);
+
+ boolean updateUserInfo(User user, UserInfo info);
+
+ boolean getCanMedia(int uid);
+
+ boolean isInWL(int uid, int check);
+
+ boolean isInBL(int uid, int check);
+
+ boolean isInBLAny(int uid, int uid2);
+
+ boolean isReplyToBL(final User user, final Message reply);
+
+ List<Integer> checkBL(int visitor, Collection<Integer> uids);
+
+ boolean isSubscribed(int uid, int check);
+
+ List<com.juick.User> getUserReadLeastPopular(int uid, int cnt);
+
+ List<User> getUserReaders(int uid);
+
+ List<User> getUserFriends(int uid);
+
+ Integer getUserRecommendations(User user);
+
+ List<com.juick.User> getUserBLUsers(int uid);
+
+ boolean linkTwitterAccount(User user, String accessToken, String accessTokenSecret, String screenName);
+
+ int getStatsMyReaders(int uid);
+
+ int getStatsMessages(int uid);
+
+ int getStatsReplies(int uid);
+
+ boolean setActiveStatusForJID(String JID, ActiveStatus jidStatus);
+
+ List<String> getAllJIDs(User user);
+
+ List<Auth> getAuthCodes(User user);
+
+ List<String> getEmails(User user);
+
+ String getEmailHash(User user);
+
+ int deleteLoginForUser(String name);
+
+ int setLoginForUser(int uid, String loginHash);
+
+ void logout(int uid);
+
+ boolean deleteJID(int uid, String jid);
+
+ boolean unauthJID(int uid, String jid);
+
+ List<String> getActiveJIDs();
+
+ void updateLastSeen(User user);
+}
diff --git a/src/main/java/com/juick/service/UserServiceImpl.java b/src/main/java/com/juick/service/UserServiceImpl.java
new file mode 100644
index 00000000..fdc4f28c
--- /dev/null
+++ b/src/main/java/com/juick/service/UserServiceImpl.java
@@ -0,0 +1,668 @@
+/*
+ * 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.service;
+
+import com.juick.Message;
+import com.juick.User;
+import com.juick.model.AnonymousUser;
+import com.juick.model.Auth;
+import com.juick.model.UserInfo;
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.dao.EmptyResultDataAccessException;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.jdbc.support.KeyHolder;
+import org.springframework.stereotype.Repository;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Nonnull;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.sql.Timestamp;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * Created by aalexeev on 11/13/16.
+ */
+@Repository
+public class UserServiceImpl extends BaseJdbcService implements UserService {
+
+ private class UserMapper implements RowMapper<User> {
+ @Override
+ public User mapRow(ResultSet rs, int rowNum) throws SQLException {
+ User user = new User();
+
+ user.setUid(rs.getInt(1));
+ user.setName(rs.getString(2));
+ user.setCredentials(rs.getString(3));
+ user.setBanned(rs.getBoolean(4));
+ Timestamp seen = rs.getTimestamp(5);
+ if (seen != null) {
+ user.setSeen(seen.toInstant());
+ }
+ return user;
+ }
+ }
+
+ @Transactional
+ @Override
+ public String getSignUpHashByJID(final String jid) {
+ List<String> list = getJdbcTemplate().queryForList(
+ "SELECT loginhash FROM jids WHERE jid = ? AND user_id IS NULL", String.class, jid);
+
+ if (list.isEmpty()) {
+ String hash = UUID.randomUUID().toString();
+ getJdbcTemplate().update("INSERT INTO jids(jid, loginhash) VALUES (?, ?)", jid, hash);
+ return hash;
+ }
+ return list.get(0);
+ }
+
+ @Transactional
+ @Override
+ public String getSignUpHashByTelegramID(final Long telegramId, final String username) {
+ List<String> list = getJdbcTemplate().queryForList(
+ "SELECT loginhash FROM telegram WHERE tg_id = ? AND user_id IS NULL",
+ String.class,
+ telegramId);
+
+ if (list.isEmpty()) {
+ String hash = UUID.randomUUID().toString();
+ getJdbcTemplate().update(
+ "INSERT INTO telegram(tg_id, loginhash, tg_name) VALUES (?, ?, ?)", telegramId, hash, username);
+ return hash;
+ }
+ return list.get(0);
+ }
+
+ @Transactional
+ @Override
+ public int createUser(final String username, final String password) {
+ KeyHolder holder = new GeneratedKeyHolder();
+ try {
+ getJdbcTemplate().update(
+ con -> {
+ PreparedStatement stmt = con.prepareStatement(
+ "INSERT INTO users(nick,passw) VALUES (?,?)",
+ Statement.RETURN_GENERATED_KEYS);
+ stmt.setString(1, username);
+ stmt.setString(2, password);
+ return stmt;
+ },
+ holder);
+ } catch (DuplicateKeyException e) {
+ return -1;
+ }
+
+ int uid = holder.getKey().intValue();
+
+ getJdbcTemplate().update("INSERT INTO useroptions(user_id) VALUES (?)", uid);
+ getJdbcTemplate().update("INSERT INTO subscr_users(user_id, suser_id) VALUES (2, ?)", uid);
+
+ return uid;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public Optional<User> getUserByUID(final int uid) {
+ List<User> list = getJdbcTemplate().query(
+ "SELECT id, nick, passw, banned, last_seen FROM users WHERE id = ?", new UserMapper(), uid);
+
+ return list.isEmpty() ? Optional.empty() : Optional.of(list.get(0));
+ }
+
+ @Transactional(readOnly = true)
+ @Nonnull
+ @Override
+ public User getUserByName(final String username) {
+ if (StringUtils.isNotBlank(username)) {
+ List<User> list = getJdbcTemplate().query(
+ "SELECT id, nick, passw, banned, last_seen FROM users WHERE nick = ?", new UserMapper(), username);
+
+ if (!list.isEmpty())
+ return list.get(0);
+ }
+ return AnonymousUser.INSTANCE;
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ @Nonnull
+ public User getUserByEmail(String email) {
+ if (StringUtils.isNotBlank(email)) {
+ List<User> list = getJdbcTemplate().query(
+ "SELECT id, nick, passw, banned, last_seen FROM users WHERE id = (SELECT DISTINCT user_id FROM emails WHERE email = ?)",
+ new UserMapper(),
+ email);
+
+ if (!list.isEmpty())
+ return list.get(0);
+ }
+ return AnonymousUser.INSTANCE;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public User getUserByJID(final String jid) {
+ User result = null;
+
+ if (StringUtils.isNotBlank(jid)) {
+ List<User> list = getJdbcTemplate().query(
+ "SELECT id, nick, passw, banned, last_seen FROM users WHERE id = (SELECT user_id FROM jids WHERE jid = ?)",
+ new UserMapper(),
+ jid);
+
+ if (!list.isEmpty())
+ result = list.get(0);
+ }
+ return result;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<User> getUsersByName(final Collection<String> unames) {
+ if (CollectionUtils.isEmpty(unames))
+ return Collections.emptyList();
+
+ return getNamedParameterJdbcTemplate().query(
+ "SELECT id, nick, passw, banned, last_seen FROM users WHERE nick IN (:unames)",
+ new MapSqlParameterSource("unames", unames),
+ new UserMapper());
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<User> getUsersByID(final Collection<Integer> uids) {
+ if (CollectionUtils.isEmpty(uids))
+ return Collections.emptyList();
+
+ return getNamedParameterJdbcTemplate().query(
+ "SELECT id, nick, passw, banned, last_seen FROM users WHERE id IN (:ids)",
+ new MapSqlParameterSource("ids", uids),
+ new UserMapper());
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<String> getJIDsbyUID(final int uid) {
+ return getJdbcTemplate().queryForList("SELECT jid FROM jids WHERE user_id = ? AND active = 1", String.class, uid);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public int getUIDbyJID(final String jid) {
+ if (StringUtils.isNotBlank(jid)) {
+ List<Integer> list = getJdbcTemplate().queryForList(
+ "SELECT user_id FROM jids WHERE jid = ?", Integer.class, jid);
+
+ if (!list.isEmpty())
+ return list.get(0);
+ }
+ return 0;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public int getUIDbyName(final String uname) {
+ if (StringUtils.isNotBlank(uname)) {
+ List<Integer> list = getJdbcTemplate().queryForList(
+ "SELECT id FROM users WHERE nick = ?", Integer.class, uname);
+
+ if (!list.isEmpty())
+ return list.get(0);
+ }
+ return 0;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public int getUIDbyHash(final String hash) {
+ if (StringUtils.isNotBlank(hash)) {
+ List<Integer> list = getJdbcTemplate().queryForList(
+ "SELECT user_id FROM logins WHERE hash = ?", Integer.class, hash);
+
+ if (!list.isEmpty())
+ return list.get(0);
+ }
+ return 0;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public com.juick.User getUserByHash(final String hash) {
+ if (StringUtils.isNotBlank(hash)) {
+ List<User> list = getJdbcTemplate().query(
+ "SELECT logins.user_id, users.nick, users.passw, users.banned, last_seen FROM logins " +
+ "INNER JOIN users ON logins.user_id = users.id WHERE logins.hash = ?",
+ new UserMapper(),
+ hash);
+
+ if (!list.isEmpty()) {
+ User user = list.get(0);
+ user.setAuthHash(hash);
+ return user;
+ }
+ }
+ return AnonymousUser.INSTANCE;
+ }
+
+ @Transactional
+ @Override
+ public String getHashByUID(final int uid) {
+ List<String> list = getJdbcTemplate().queryForList(
+ "SELECT hash FROM logins WHERE user_id = ?", String.class, uid);
+
+ if (list.isEmpty()) {
+ String hash = RandomStringUtils.randomAlphanumeric(16).toUpperCase();
+ getJdbcTemplate().update("INSERT INTO logins(user_id, hash) VALUES (?, ?)", uid, hash);
+ return hash;
+ }
+ return list.get(0);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public int checkPassword(final String username, final String password) {
+ if (StringUtils.isNotBlank(username)) {
+ List<User> list = getJdbcTemplate().query(
+ "SELECT id, nick, passw, banned, last_seen FROM users WHERE nick = ?",
+ new UserMapper(),
+ username);
+
+ if (!list.isEmpty()) {
+ User user = list.get(0);
+ if (Objects.equals(password, user.getCredentials()))
+ return user.getUid();
+ }
+ }
+ return -1;
+ }
+
+ @Transactional
+ @Override
+ public boolean updatePassword(final User user, final String newPassword) {
+ return user != null &&
+ user.getUid() > 0 &&
+ getJdbcTemplate().update("UPDATE users SET passw = ? WHERE id = ?", newPassword, user.getUid()) > 0;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public int getUserOptionInt(final int uid, final String option, final int defaultValue) {
+ if (StringUtils.isBlank(option))
+ return defaultValue;
+
+ List<Integer> list = getJdbcTemplate().queryForList(
+ "SELECT " + option + " FROM useroptions WHERE user_id = ?", Integer.class, uid);
+
+ return list.isEmpty() ? defaultValue : list.get(0);
+ }
+
+ @Transactional
+ @Override
+ public int setUserOptionInt(final int uid, final String option, final int value) {
+ if (StringUtils.isBlank(option))
+ return 0;
+
+ return getJdbcTemplate().update("UPDATE useroptions SET " + option + "= ? WHERE user_id = ?", value, uid);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public UserInfo getUserInfo(final User user) {
+ List<UserInfo> list = getJdbcTemplate().query(
+ "SELECT fullname, country, url, descr FROM usersinfo WHERE user_id = ?",
+ ((rs, rowNum) -> {
+ UserInfo info = new UserInfo();
+ info.setFullName(rs.getString(1));
+ info.setCountry(rs.getString(2));
+ info.setUrl(rs.getString(3));
+ info.setDescription(rs.getString(4));
+ return info;
+ }),
+ user.getUid());
+
+ return list.isEmpty() ? new UserInfo() : list.get(0);
+ }
+
+ @Transactional
+ @Override
+ public boolean updateUserInfo(final User user, final UserInfo info) {
+ return getJdbcTemplate().update(
+ "INSERT INTO usersinfo(user_id, fullname, country, url, descr) VALUES (?, ?, ?, ?, ?) " +
+ "ON DUPLICATE KEY UPDATE fullname = ?, country = ?, url = ?, descr = ?",
+ user.getUid(),
+ info.getFullName(),
+ info.getCountry(),
+ info.getUrl(),
+ info.getDescription(),
+ info.getFullName(),
+ info.getCountry(),
+ info.getUrl(),
+ info.getDescription()) > 0;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public boolean getCanMedia(final int uid) {
+ List<Integer> list = getJdbcTemplate().queryForList(
+ "SELECT users.lastphoto - UNIX_TIMESTAMP() FROM users WHERE id = ?",
+ Integer.class,
+ uid);
+
+ return !list.isEmpty() && list.get(0) < 3600;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public boolean isInWL(final int uid, final int check) {
+ List<Integer> list = getJdbcTemplate().queryForList(
+ "SELECT 1 FROM wl_users WHERE user_id = ? AND wl_user_id = ?",
+ Integer.class, uid, check);
+
+ return !list.isEmpty() && list.get(0) == 1;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public boolean isInBL(final int uid, final int check) {
+ List<Integer> list = getJdbcTemplate().queryForList(
+ "SELECT 1 FROM bl_users WHERE user_id = ? AND bl_user_id = ?", Integer.class, uid, check);
+
+ return !list.isEmpty() && list.get(0) == 1;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public boolean isInBLAny(final int uid, final int uid2) {
+ List<Integer> list = getJdbcTemplate().queryForList(
+ "SELECT 1 FROM bl_users WHERE (user_id = ? AND bl_user_id = ?) "
+ + "OR (user_id = ? AND bl_user_id = ?)",
+ new Object[]{uid, uid2, uid2, uid},
+ Integer.class);
+
+ return !list.isEmpty() && list.get(0) == 1;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public boolean isReplyToBL(final User user, final Message reply) {
+ return getNamedParameterJdbcTemplate().queryForObject("WITH RECURSIVE banned(reply_id, user_id) AS (" +
+ "SELECT reply_id, user_id FROM replies " +
+ "WHERE replies.message_id = :mid " +
+ "AND EXISTS (SELECT 1 FROM bl_users b WHERE b.user_id = :uid AND b.bl_user_id = replies.user_id) " +
+ "UNION ALL SELECT replies.reply_id, replies.user_id FROM replies " +
+ "INNER JOIN banned ON banned.reply_id = replies.replyto " +
+ "WHERE replies.message_id = :mid) " +
+ "SELECT COUNT(reply_id) from replies " +
+ "INNER JOIN messages m ON m.message_id = replies.message_id " +
+ "WHERE replies.message_id = :mid " +
+ "AND replies.reply_id = :rid " +
+ "AND (EXISTS (SELECT 1 FROM banned WHERE banned.reply_id = replies.reply_id) " +
+ "OR EXISTS (SELECT 1 FROM bl_users b WHERE b.user_id = :uid AND b.bl_user_id = m.user_id)" +
+ "OR EXISTS (SELECT 1 FROM bl_users b WHERE b.bl_user_id = :uid AND b.user_id = m.user_id))",
+ new MapSqlParameterSource("uid", user.getUid())
+ .addValue("mid", reply.getMid())
+ .addValue("rid", reply.getRid()),
+ Integer.class) > 0;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Integer> checkBL(final int visitor, final Collection<Integer> uids) {
+ if (CollectionUtils.isEmpty(uids))
+ return Collections.emptyList();
+
+ return getNamedParameterJdbcTemplate().queryForList(
+ "SELECT user_id FROM bl_users WHERE bl_user_id = :visitor and user_id IN (:ids)",
+ new MapSqlParameterSource()
+ .addValue("visitor", visitor)
+ .addValue("ids", uids),
+ Integer.class);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public boolean isSubscribed(final int uid, final int check) {
+ List<Integer> list = getJdbcTemplate().queryForList(
+ "SELECT 1 FROM subscr_users WHERE suser_id = ? AND user_id = ?",
+ Integer.class, uid, check);
+
+ return !list.isEmpty() && list.get(0) == 1;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<com.juick.User> getUserReadLeastPopular(final int uid, final int cnt) {
+ return getJdbcTemplate().query(
+ "SELECT users.id,users.nick FROM (subscr_users " +
+ "INNER JOIN users_subscr ON (subscr_users.suser_id=? " +
+ "AND subscr_users.user_id=users_subscr.user_id)) INNER JOIN users " +
+ "ON subscr_users.user_id=users.id ORDER BY cnt LIMIT ?",
+ (rs, num) -> {
+ com.juick.User u = new com.juick.User();
+ u.setUid(rs.getInt(1));
+ u.setName(rs.getString(2));
+ return u;
+ },
+ uid,
+ cnt);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<User> getUserReaders(final int uid) {
+ return getJdbcTemplate().query(
+ "SELECT users.id, users.nick FROM subscr_users " +
+ "INNER JOIN users ON subscr_users.suser_id=users.id " +
+ "WHERE subscr_users.user_id=? ORDER BY users.nick",
+ (rs, num) -> {
+ com.juick.User u = new com.juick.User();
+ u.setUid(rs.getInt(1));
+ u.setName(rs.getString(2));
+ return u;
+ },
+ uid);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<User> getUserFriends(final int uid) {
+ return getJdbcTemplate().query(
+ "SELECT users.id,users.nick FROM subscr_users " +
+ "INNER JOIN users ON subscr_users.user_id=users.id " +
+ "WHERE subscr_users.suser_id=? AND users.id!=? " +
+ "ORDER BY users.nick",
+ (rs, num) -> {
+ com.juick.User u = new com.juick.User();
+ u.setUid(rs.getInt(1));
+ u.setName(rs.getString(2));
+ return u;
+ },
+ uid,
+ uid);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public Integer getUserRecommendations(User user) {
+ try {
+ return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM favorites WHERE user_id=?", Integer.class, user.getUid());
+ } catch (EmptyResultDataAccessException e) {
+ return 0;
+ }
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<com.juick.User> getUserBLUsers(final int uid) {
+ return getJdbcTemplate().query("SELECT users.id,users.nick FROM users INNER JOIN bl_users " +
+ "ON(bl_users.bl_user_id=users.id) WHERE bl_users.user_id=? ORDER BY users.nick",
+ (rs, num) -> {
+ com.juick.User u = new com.juick.User();
+ u.setUid(rs.getInt(1));
+ u.setName(rs.getString(2));
+ return u;
+ }, uid);
+ }
+
+ @Transactional
+ @Override
+ public boolean linkTwitterAccount(
+ final User user, final String accessToken, final String accessTokenSecret, final String screenName) {
+ return getJdbcTemplate().update("INSERT INTO twitter(user_id,access_token,access_token_secret,uname) " +
+ "VALUES (?,?,?,?)" +
+ " ON DUPLICATE KEY UPDATE access_token=?,access_token_secret=?,uname=?",
+ user.getUid(), accessToken, accessTokenSecret, screenName, accessToken, accessTokenSecret, screenName) > 0;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public int getStatsMyReaders(final int uid) {
+ List<Integer> list = getJdbcTemplate().queryForList("SELECT COUNT(*) FROM subscr_users WHERE user_id = ?", Integer.class, uid);
+ return list.isEmpty() ? 0 : list.get(0);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public int getStatsMessages(final int uid) {
+ List<Integer> list = getJdbcTemplate().queryForList("SELECT COUNT(*) FROM messages WHERE user_id = ?", Integer.class, uid);
+ return list.isEmpty() ? 0 : list.get(0);
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public int getStatsReplies(final int uid) {
+ List<Integer> list = getJdbcTemplate().queryForList("SELECT COUNT(*) FROM replies WHERE user_id = ?", Integer.class, uid);
+ return list.isEmpty() ? 0 : list.get(0);
+ }
+
+ @Transactional
+ @Override
+ public boolean setActiveStatusForJID(final String JID, final UserService.ActiveStatus jidStatus) {
+ User user = getUserByJID(JID);
+ if (user != null) {
+ int newStatus = jidStatus == UserService.ActiveStatus.Active ? 1 : 0;
+ return getJdbcTemplate().update(
+ "UPDATE jids SET active = ? WHERE user_id = ? AND jid = ?",
+ newStatus, user.getUid(), JID) >= 0;
+ }
+ return false;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<String> getAllJIDs(final User user) {
+ return getJdbcTemplate().queryForList(
+ "SELECT jid FROM jids WHERE user_id=?", String.class, user.getUid());
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<Auth> getAuthCodes(final User user) {
+ return getJdbcTemplate().query(
+ "SELECT account,authcode FROM auth WHERE user_id=? AND protocol='xmpp'",
+ (rs, num) -> new Auth(rs.getString(1), rs.getString(2)),
+ user.getUid());
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<String> getEmails(final User user) {
+ return getJdbcTemplate().queryForList("SELECT email FROM emails WHERE user_id=?", String.class, user.getUid());
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public String getEmailHash(final User user) {
+ List<String> list = getJdbcTemplate().queryForList(
+ "SELECT hash FROM mail WHERE user_id = ?",
+ String.class,
+ user.getUid());
+ return list.isEmpty() ? StringUtils.EMPTY : list.get(0) + "@mail.juick.com";
+ }
+
+ @Transactional
+ @Override
+ public int deleteLoginForUser(final String name) {
+ if (StringUtils.isBlank(name))
+ return 0;
+
+ return getJdbcTemplate().update(
+ "delete from logins where user_id in (select id from users where nick = ?)", name);
+ }
+
+ @Transactional
+ @Override
+ public int setLoginForUser(final int uid, final String loginHash) {
+ if (StringUtils.isEmpty(loginHash))
+ return 0;
+
+ return getNamedParameterJdbcTemplate().update(
+ "INSERT INTO logins (user_id, hash) VALUES(:uid, :hash) ON DUPLICATE KEY UPDATE hash = :hash",
+ new MapSqlParameterSource()
+ .addValue("hash", loginHash)
+ .addValue("uid", uid));
+ }
+
+ @Transactional
+ @Override
+ public void logout(int uid) {
+ getJdbcTemplate().update("DELETE FROM logins WHERE user_id=?", uid);
+ }
+
+ @Transactional
+ @Override
+ public boolean deleteJID(int uid, String jid) {
+ return getNamedParameterJdbcTemplate().update("DELETE FROM jids " +
+ "WHERE (SELECT COUNT(*) cnt FROM (select user_id, jid FROM jids j) c WHERE user_id=:uid) > 1 " +
+ "AND user_id=:uid AND jid=:jid",
+ new MapSqlParameterSource()
+ .addValue("uid", uid)
+ .addValue("jid", jid)) > 0;
+ }
+
+ @Transactional
+ @Override
+ public boolean unauthJID(int uid, String jid) {
+ return getJdbcTemplate()
+ .update("DELETE FROM auth WHERE user_id=? AND protocol='xmpp' AND account=?", uid, jid) > 0;
+ }
+
+ @Transactional(readOnly = true)
+ @Override
+ public List<String> getActiveJIDs() {
+ return getJdbcTemplate().queryForList("SELECT jid FROM jids WHERE active=1 AND loginhash IS NULL", String.class);
+ }
+
+ @Override
+ public void updateLastSeen(User user) {
+ getJdbcTemplate().update("UPDATE users SET last_seen=now() WHERE id=?", user.getUid());
+ }
+}
diff --git a/src/main/java/com/juick/service/activities/ActivityListener.java b/src/main/java/com/juick/service/activities/ActivityListener.java
new file mode 100644
index 00000000..863bda04
--- /dev/null
+++ b/src/main/java/com/juick/service/activities/ActivityListener.java
@@ -0,0 +1,19 @@
+package com.juick.service.activities;
+
+import org.springframework.context.event.EventListener;
+import org.springframework.scheduling.annotation.Async;
+
+public interface ActivityListener {
+ @Async
+ @EventListener
+ void processFollowEvent(FollowEvent event);
+ @Async
+ @EventListener
+ void undoFollowEvent(UndoFollowEvent event);
+ @Async
+ @EventListener
+ void deleteUserEvent(DeleteUserEvent event);
+ @Async
+ @EventListener
+ void deleteMessageEvent(DeleteMessageEvent event);
+}
diff --git a/src/main/java/com/juick/service/activities/DeleteMessageEvent.java b/src/main/java/com/juick/service/activities/DeleteMessageEvent.java
new file mode 100644
index 00000000..67e40f44
--- /dev/null
+++ b/src/main/java/com/juick/service/activities/DeleteMessageEvent.java
@@ -0,0 +1,21 @@
+package com.juick.service.activities;
+
+import com.juick.Message;
+import org.springframework.context.ApplicationEvent;
+
+public class DeleteMessageEvent extends ApplicationEvent {
+ private Message message;
+ /**
+ * Create a new ApplicationEvent.
+ *
+ * @param source the object on which the event initially occurred (never {@code null})
+ */
+ public DeleteMessageEvent(Object source, Message message) {
+ super(source);
+ this.message = message;
+ }
+
+ public Message getMessage() {
+ return message;
+ }
+}
diff --git a/src/main/java/com/juick/service/activities/DeleteUserEvent.java b/src/main/java/com/juick/service/activities/DeleteUserEvent.java
new file mode 100644
index 00000000..8b51da9d
--- /dev/null
+++ b/src/main/java/com/juick/service/activities/DeleteUserEvent.java
@@ -0,0 +1,20 @@
+package com.juick.service.activities;
+
+import org.springframework.context.ApplicationEvent;
+
+public class DeleteUserEvent extends ApplicationEvent {
+ private String userUri;
+ /**
+ * Create a new ApplicationEvent.
+ *
+ * @param source the object on which the event initially occurred (never {@code null})
+ */
+ public DeleteUserEvent(Object source, String userUri) {
+ super(source);
+ this.userUri = userUri;
+ }
+
+ public String getUserUri() {
+ return userUri;
+ }
+}
diff --git a/src/main/java/com/juick/service/activities/FollowEvent.java b/src/main/java/com/juick/service/activities/FollowEvent.java
new file mode 100644
index 00000000..c96613ba
--- /dev/null
+++ b/src/main/java/com/juick/service/activities/FollowEvent.java
@@ -0,0 +1,21 @@
+package com.juick.service.activities;
+
+import com.juick.server.api.activity.model.activities.Follow;
+import org.springframework.context.ApplicationEvent;
+
+public class FollowEvent extends ApplicationEvent {
+ private Follow request;
+ /**
+ * Create a new ApplicationEvent.
+ *
+ * @param source the object on which the event initially occurred (never {@code null})
+ */
+ public FollowEvent(Object source, Follow followRequest) {
+ super(source);
+ this.request = followRequest;
+ }
+
+ public Follow getRequest() {
+ return request;
+ }
+}
diff --git a/src/main/java/com/juick/service/activities/UndoFollowEvent.java b/src/main/java/com/juick/service/activities/UndoFollowEvent.java
new file mode 100644
index 00000000..2b48e6f6
--- /dev/null
+++ b/src/main/java/com/juick/service/activities/UndoFollowEvent.java
@@ -0,0 +1,26 @@
+package com.juick.service.activities;
+
+import org.springframework.context.ApplicationEvent;
+
+public class UndoFollowEvent extends ApplicationEvent {
+ private String actor;
+ private String object;
+ /**
+ * Create a new ApplicationEvent.
+ *
+ * @param source the object on which the event initially occurred (never {@code null})
+ */
+ public UndoFollowEvent(Object source, String actor, String object) {
+ super(source);
+ this.actor = actor;
+ this.object = object;
+ }
+
+ public String getActor() {
+ return actor;
+ }
+
+ public String getObject() {
+ return object;
+ }
+}
diff --git a/src/main/java/com/juick/service/component/DisconnectedEvent.java b/src/main/java/com/juick/service/component/DisconnectedEvent.java
new file mode 100644
index 00000000..552c3e66
--- /dev/null
+++ b/src/main/java/com/juick/service/component/DisconnectedEvent.java
@@ -0,0 +1,14 @@
+package com.juick.service.component;
+
+import org.springframework.context.ApplicationEvent;
+
+public class DisconnectedEvent extends ApplicationEvent {
+ /**
+ * Create a new ApplicationEvent.
+ *
+ * @param source the object on which the event initially occurred (never {@code null})
+ */
+ public DisconnectedEvent(Object source) {
+ super(source);
+ }
+}
diff --git a/src/main/java/com/juick/service/component/LikeEvent.java b/src/main/java/com/juick/service/component/LikeEvent.java
new file mode 100644
index 00000000..0d4df70c
--- /dev/null
+++ b/src/main/java/com/juick/service/component/LikeEvent.java
@@ -0,0 +1,36 @@
+package com.juick.service.component;
+
+import com.juick.Message;
+import com.juick.User;
+import org.springframework.context.ApplicationEvent;
+
+import java.util.List;
+
+public class LikeEvent extends ApplicationEvent {
+ private User user;
+ private Message message;
+ private List<User> subscribers;
+ /**
+ * Create a new ApplicationEvent.
+ *
+ * @param source the object on which the event initially occurred (never {@code null})
+ */
+ public LikeEvent(Object source, User user, Message message, List<User> subscribers) {
+ super(source);
+ this.message = message;
+ this.user = user;
+ this.subscribers = subscribers;
+ }
+
+ public User getUser() {
+ return user;
+ }
+
+ public Message getMessage() {
+ return message;
+ }
+
+ public List<User> getSubscribers() {
+ return subscribers;
+ }
+}
diff --git a/src/main/java/com/juick/service/component/MessageEvent.java b/src/main/java/com/juick/service/component/MessageEvent.java
new file mode 100644
index 00000000..82911a58
--- /dev/null
+++ b/src/main/java/com/juick/service/component/MessageEvent.java
@@ -0,0 +1,31 @@
+package com.juick.service.component;
+
+import com.juick.Message;
+import com.juick.User;
+import org.springframework.context.ApplicationEvent;
+
+import java.util.List;
+
+public class MessageEvent extends ApplicationEvent {
+ private Message message;
+ private List<User> users;
+ /**
+ * Create a new ApplicationEvent.
+ *
+ * @param source the object on which the event initially occurred (never {@code null})
+ * @param message app message
+ * @param interestedUsers users interested in notification
+ */
+ public MessageEvent(Object source, Message message, List<User> interestedUsers) {
+ super(source);
+ this.message = message;
+ this.users = interestedUsers;
+ }
+
+ public Message getMessage() {
+ return message;
+ }
+ public List<User> getUsers() {
+ return users;
+ }
+}
diff --git a/src/main/java/com/juick/service/component/MessageReadEvent.java b/src/main/java/com/juick/service/component/MessageReadEvent.java
new file mode 100644
index 00000000..b070c8cb
--- /dev/null
+++ b/src/main/java/com/juick/service/component/MessageReadEvent.java
@@ -0,0 +1,30 @@
+package com.juick.service.component;
+
+import com.juick.Message;
+import com.juick.User;
+import org.springframework.context.ApplicationEvent;
+
+import java.util.List;
+
+public class MessageReadEvent extends ApplicationEvent {
+ private User user;
+ private Message message;
+ /**
+ * Create a new ApplicationEvent.
+ *
+ * @param source the object on which the event initially occurred (never {@code null})
+ */
+ public MessageReadEvent(Object source, User user, Message message) {
+ super(source);
+ this.user = user;
+ this.message = message;
+ }
+
+ public User getUser() {
+ return user;
+ }
+
+ public Message getMessage() {
+ return message;
+ }
+}
diff --git a/src/main/java/com/juick/service/component/NotificationListener.java b/src/main/java/com/juick/service/component/NotificationListener.java
new file mode 100644
index 00000000..38d0490a
--- /dev/null
+++ b/src/main/java/com/juick/service/component/NotificationListener.java
@@ -0,0 +1,25 @@
+package com.juick.service.component;
+
+import org.springframework.context.event.EventListener;
+import org.springframework.scheduling.annotation.Async;
+
+public interface NotificationListener {
+ @Async
+ @EventListener
+ void processMessageEvent(MessageEvent messageEvent);
+ @Async
+ @EventListener
+ void processSubscribeEvent(SubscribeEvent subscribeEvent);
+ @Async
+ @EventListener
+ void processLikeEvent(LikeEvent likeEvent);
+ @Async
+ @EventListener
+ void processPingEvent(PingEvent pingEvent);
+ @Async
+ @EventListener
+ void processMessageReadEvent(MessageReadEvent messageReadEvent);
+ @Async
+ @EventListener
+ void processTopEvent(TopEvent topEvent);
+}
diff --git a/src/main/java/com/juick/service/component/PingEvent.java b/src/main/java/com/juick/service/component/PingEvent.java
new file mode 100644
index 00000000..8e3f3fa7
--- /dev/null
+++ b/src/main/java/com/juick/service/component/PingEvent.java
@@ -0,0 +1,21 @@
+package com.juick.service.component;
+
+import com.juick.User;
+import org.springframework.context.ApplicationEvent;
+
+public class PingEvent extends ApplicationEvent {
+ private User pinger;
+ /**
+ * Create a new ApplicationEvent.
+ *
+ * @param source the object on which the event initially occurred (never {@code null})
+ */
+ public PingEvent(Object source, User pinger) {
+ super(source);
+ this.pinger = pinger;
+ }
+
+ public User getPinger() {
+ return pinger;
+ }
+}
diff --git a/src/main/java/com/juick/service/component/SubscribeEvent.java b/src/main/java/com/juick/service/component/SubscribeEvent.java
new file mode 100644
index 00000000..9b644f2f
--- /dev/null
+++ b/src/main/java/com/juick/service/component/SubscribeEvent.java
@@ -0,0 +1,27 @@
+package com.juick.service.component;
+
+import com.juick.User;
+import org.springframework.context.ApplicationEvent;
+
+public class SubscribeEvent extends ApplicationEvent {
+ private User user;
+ private User toUser;
+ /**
+ * Create a new ApplicationEvent.
+ *
+ * @param source the object on which the event initially occurred (never {@code null})
+ */
+ public SubscribeEvent(Object source, User user, User toUser) {
+ super(source);
+ this.user = user;
+ this.toUser = toUser;
+ }
+
+ public User getUser() {
+ return user;
+ }
+
+ public User getToUser() {
+ return toUser;
+ }
+}
diff --git a/src/main/java/com/juick/service/component/TopEvent.java b/src/main/java/com/juick/service/component/TopEvent.java
new file mode 100644
index 00000000..b7e24957
--- /dev/null
+++ b/src/main/java/com/juick/service/component/TopEvent.java
@@ -0,0 +1,21 @@
+package com.juick.service.component;
+
+import com.juick.Message;
+import org.springframework.context.ApplicationEvent;
+
+public class TopEvent extends ApplicationEvent {
+ private Message message;
+ /**
+ * Create a new ApplicationEvent.
+ *
+ * @param source the object on which the event initially occurred (never {@code null})
+ */
+ public TopEvent(Object source, Message message) {
+ super(source);
+ this.message = message;
+ }
+
+ public Message getMessage() {
+ return message;
+ }
+}
diff --git a/src/main/java/com/juick/service/component/UserUpdatedEvent.java b/src/main/java/com/juick/service/component/UserUpdatedEvent.java
new file mode 100644
index 00000000..af2f579a
--- /dev/null
+++ b/src/main/java/com/juick/service/component/UserUpdatedEvent.java
@@ -0,0 +1,23 @@
+package com.juick.service.component;
+
+import com.juick.User;
+import org.springframework.context.ApplicationEvent;
+import org.springframework.lang.NonNull;
+
+public class UserUpdatedEvent extends ApplicationEvent {
+ private User user;
+ /**
+ * Generated when user is updated (avatar changed, etc).
+ *
+ * @param source the object on which the event initially occurred (never {@code null})
+ * @param user updated user
+ */
+ public UserUpdatedEvent(@NonNull Object source, User user) {
+ super(source);
+ this.user = user;
+ }
+
+ public User getUser() {
+ return user;
+ }
+}
diff --git a/src/main/java/com/juick/service/security/HashParamAuthenticationFilter.java b/src/main/java/com/juick/service/security/HashParamAuthenticationFilter.java
new file mode 100644
index 00000000..9215d09a
--- /dev/null
+++ b/src/main/java/com/juick/service/security/HashParamAuthenticationFilter.java
@@ -0,0 +1,103 @@
+/*
+ * 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.service.security;
+
+import com.juick.User;
+import com.juick.service.security.entities.JuickUser;
+import com.juick.service.UserService;
+import org.springframework.security.authentication.AnonymousAuthenticationToken;
+import org.springframework.security.authentication.RememberMeAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.RememberMeServices;
+import org.springframework.security.web.authentication.rememberme.AbstractRememberMeServices;
+import org.springframework.util.Assert;
+import org.springframework.web.filter.OncePerRequestFilter;
+import org.springframework.web.util.WebUtils;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+/**
+ * Created by aalexeev on 4/5/17.
+ */
+public class HashParamAuthenticationFilter extends OncePerRequestFilter {
+ public static final String PARAM_NAME = "hash";
+
+ private final UserService userService;
+ private final RememberMeServices rememberMeServices;
+
+
+ public HashParamAuthenticationFilter(
+ final UserService userService,
+ final RememberMeServices rememberMeServices) {
+ Assert.notNull(userService, "userService should not be null");
+ Assert.notNull(rememberMeServices, "rememberMeServices should not be null");
+
+ this.userService = userService;
+ this.rememberMeServices = rememberMeServices;
+ }
+
+ @Override
+ protected void doFilterInternal(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain filterChain) throws ServletException, IOException {
+
+ String hash = getHashFromRequest(request);
+
+ if (hash != null && authenticationIsRequired()) {
+ User user = userService.getUserByHash(hash);
+
+ if (!user.isAnonymous()) {
+ User userWithPassword = userService.getUserByName(user.getName());
+ userWithPassword.setAuthHash(userService.getHashByUID(userWithPassword.getUid()));
+ Authentication authentication = new RememberMeAuthenticationToken(
+ ((AbstractRememberMeServices)rememberMeServices).getKey(), new JuickUser(userWithPassword), JuickUser.USER_AUTHORITY);
+
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+
+ rememberMeServices.loginSuccess(request, response, authentication);
+ }
+ }
+
+ filterChain.doFilter(request, response);
+ }
+
+ private boolean authenticationIsRequired() {
+ Authentication existingAuth = SecurityContextHolder.getContext().getAuthentication();
+
+ return existingAuth == null ||
+ !existingAuth.isAuthenticated() ||
+ existingAuth instanceof AnonymousAuthenticationToken;
+ }
+
+ private String getHashFromRequest(HttpServletRequest request) {
+ String paramHash = request.getParameter(PARAM_NAME);
+ Cookie cookieHash = WebUtils.getCookie(request, PARAM_NAME);
+
+ if (paramHash == null && cookieHash != null) {
+ return cookieHash.getValue();
+ }
+ return paramHash;
+ }
+}
diff --git a/src/main/java/com/juick/service/security/JuickUserDetailsService.java b/src/main/java/com/juick/service/security/JuickUserDetailsService.java
new file mode 100644
index 00000000..59425fab
--- /dev/null
+++ b/src/main/java/com/juick/service/security/JuickUserDetailsService.java
@@ -0,0 +1,53 @@
+/*
+ * 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.service.security;
+
+import com.juick.service.UserService;
+import com.juick.service.security.entities.JuickUser;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.util.Assert;
+
+/**
+ * Created by aalexeev on 11/28/16.
+ */
+public class JuickUserDetailsService implements UserDetailsService {
+ private final UserService userService;
+
+ public JuickUserDetailsService(final UserService userService) {
+ Assert.notNull(userService, "UserService must be initialized");
+ this.userService = userService;
+ }
+
+ @Override
+ public UserDetails loadUserByUsername(final String username) throws UsernameNotFoundException {
+ if (StringUtils.isBlank(username))
+ throw new UsernameNotFoundException("Invalid user name " + username);
+
+ com.juick.User user = userService.getUserByName(username);
+
+ if (!user.isAnonymous()) {
+ user.setAuthHash(userService.getHashByUID(user.getUid()));
+ return new JuickUser(user);
+ }
+
+ throw new UsernameNotFoundException("The username " + username + " is not found");
+ }
+}
diff --git a/src/main/java/com/juick/service/security/NullUserDetailsService.java b/src/main/java/com/juick/service/security/NullUserDetailsService.java
new file mode 100644
index 00000000..91acefa3
--- /dev/null
+++ b/src/main/java/com/juick/service/security/NullUserDetailsService.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.service.security;
+
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+
+/**
+ * Created by aalexeev on 11/28/16.
+ */
+public class NullUserDetailsService implements UserDetailsService {
+ @Override
+ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
+ throw new UsernameNotFoundException(
+ "loadUserByUsername called for NullUserDetailsService, user " + username + "can not be found");
+ }
+}
diff --git a/src/main/java/com/juick/service/security/deprecated/CookieSimpleHashRememberMeServices.java b/src/main/java/com/juick/service/security/deprecated/CookieSimpleHashRememberMeServices.java
new file mode 100644
index 00000000..e385d7dd
--- /dev/null
+++ b/src/main/java/com/juick/service/security/deprecated/CookieSimpleHashRememberMeServices.java
@@ -0,0 +1,130 @@
+/*
+ * 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.service.security.deprecated;
+
+import com.juick.User;
+import com.juick.service.security.entities.JuickUser;
+import com.juick.service.UserService;
+import com.juick.service.security.NullUserDetailsService;
+import org.apache.commons.lang3.RandomStringUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.core.env.Environment;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.security.web.authentication.RememberMeServices;
+import org.springframework.security.web.authentication.rememberme.AbstractRememberMeServices;
+import org.springframework.security.web.authentication.rememberme.InvalidCookieException;
+import org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationException;
+import org.springframework.util.Assert;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.util.Optional;
+
+/**
+ * Created by aalexeev on 11/28/16.
+ *
+ * @deprecated not recommended use for secure reasons
+ */
+@Deprecated
+public class CookieSimpleHashRememberMeServices extends AbstractRememberMeServices implements RememberMeServices {
+ private static final Logger logger = LoggerFactory.getLogger(CookieSimpleHashRememberMeServices.class);
+
+ private static final String COOKIE_PARAM_NAME = "hash";
+
+ private final UserService userService;
+
+ public CookieSimpleHashRememberMeServices(
+ final String key, final UserService userService, final Environment environment) {
+ super(key, new NullUserDetailsService());
+
+ Assert.notNull(userService);
+ Assert.notNull(environment);
+
+ this.userService = userService;
+
+ setCookieName(COOKIE_PARAM_NAME);
+ setCookieDomain(environment.getProperty("web_domain", "localhost"));
+ setAlwaysRemember(true);
+ }
+
+ @Override
+ public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
+ super.logout(request, response, authentication);
+ userService.deleteLoginForUser(authentication.getName());
+ }
+
+ @Override
+ protected void onLoginSuccess(
+ HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
+ String username = successfulAuthentication.getName();
+
+ logger.debug("Creating new persistent login for user {}", username);
+
+ try {
+ int uid = userService.getUIDbyName(username);
+
+ Assert.isTrue(uid > 0);
+
+ String hash = RandomStringUtils.randomAlphanumeric(16).toUpperCase();
+
+ userService.setLoginForUser(uid, hash);
+
+ setCookie(new String[]{hash}, getTokenValiditySeconds(), request, response);
+ } catch (Exception e) {
+ logger.error("Failed to save cookies", e);
+ }
+ }
+
+ @Override
+ protected UserDetails processAutoLoginCookie(
+ String[] cookieTokens, HttpServletRequest request, HttpServletResponse response)
+ throws RememberMeAuthenticationException, UsernameNotFoundException {
+ String hash = cookieTokens[0];
+
+ if (StringUtils.isBlank(hash)) {
+ hash = request.getParameter("hash");
+ }
+ if (StringUtils.isBlank(hash)) {
+ throw new InvalidCookieException("Cookie is invalid and hash parameter not found");
+ }
+
+ int uid = userService.getUIDbyHash(hash);
+ if (uid <= 0)
+ throw new UsernameNotFoundException("User not found by hash, cookies" + cookieTokens);
+
+ Optional<User> userOptional = userService.getUserByUID(uid);
+
+ Assert.isTrue(userOptional.isPresent());
+
+ return new JuickUser(userService.getUserByName(userOptional.get().getName()));
+ }
+
+ @Override
+ protected String[] decodeCookie(String cookieValue) throws InvalidCookieException {
+ return new String[]{cookieValue};
+ }
+
+ @Override
+ protected String encodeCookie(String[] cookieTokens) {
+ return cookieTokens != null && cookieTokens.length > 0 ? cookieTokens[0] : StringUtils.EMPTY;
+ }
+}
diff --git a/src/main/java/com/juick/service/security/deprecated/RequestParamHashRememberMeServices.java b/src/main/java/com/juick/service/security/deprecated/RequestParamHashRememberMeServices.java
new file mode 100644
index 00000000..3631e5a4
--- /dev/null
+++ b/src/main/java/com/juick/service/security/deprecated/RequestParamHashRememberMeServices.java
@@ -0,0 +1,88 @@
+/*
+ * 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.service.security.deprecated;
+
+import com.juick.User;
+import com.juick.service.security.entities.JuickUser;
+import com.juick.service.UserService;
+import com.juick.service.security.NullUserDetailsService;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.security.web.authentication.RememberMeServices;
+import org.springframework.security.web.authentication.rememberme.AbstractRememberMeServices;
+import org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationException;
+import org.springframework.util.Assert;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * Created by aalexeev on 11/30/16.
+ *
+ * @deprecated for security reasons
+ */
+@Deprecated
+public class RequestParamHashRememberMeServices extends AbstractRememberMeServices implements RememberMeServices {
+ private static final String PARAM_NAME = "hash";
+
+ private final UserService userService;
+
+ public RequestParamHashRememberMeServices(String key, UserService userService) {
+ super(key, new NullUserDetailsService());
+
+ Assert.notNull(userService);
+ this.userService = userService;
+ setAlwaysRemember(false);
+ }
+
+ @Override
+ protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
+ // do nothing
+ }
+
+ @Override
+ protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
+ return false; // always false
+ }
+
+ @Override
+ protected void cancelCookie(HttpServletRequest request, HttpServletResponse response) {
+ // do nothing
+ }
+
+ @Override
+ protected String extractRememberMeCookie(HttpServletRequest request) {
+ return PARAM_NAME; // return any not blank value
+ }
+
+ @Override
+ protected UserDetails processAutoLoginCookie(
+ String[] cookieTokens, HttpServletRequest request, HttpServletResponse response)
+ throws RememberMeAuthenticationException, UsernameNotFoundException {
+ String hash = request.getParameter(PARAM_NAME);
+
+ if (StringUtils.isNotBlank(hash)) {
+ User user = userService.getUserByHash(hash);
+ if (!user.isAnonymous())
+ return new JuickUser(userService.getUserByName(user.getName()));
+ }
+ throw new UsernameNotFoundException("User not found by hash " + hash);
+ }
+}
diff --git a/src/main/java/com/juick/service/security/entities/JuickUser.java b/src/main/java/com/juick/service/security/entities/JuickUser.java
new file mode 100644
index 00000000..c43f112f
--- /dev/null
+++ b/src/main/java/com/juick/service/security/entities/JuickUser.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.service.security.entities;
+
+import com.juick.User;
+import com.juick.model.AnonymousUser;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Created by aalexeev on 11/21/16.
+ */
+public class JuickUser implements UserDetails {
+ static final GrantedAuthority ROLE_USER = new SimpleGrantedAuthority("ROLE_USER");
+ static final GrantedAuthority ROLE_ANONYMOUS = new SimpleGrantedAuthority("ROLE_ANONYMOUS");
+
+ public static final List<GrantedAuthority> USER_AUTHORITY = Collections.singletonList(ROLE_USER);
+ public static final List<GrantedAuthority> ANONYMOUS_AUTHORITY = Collections.singletonList(ROLE_ANONYMOUS);
+
+ public static final JuickUser ANONYMOUS_USER = new JuickUser(AnonymousUser.INSTANCE, ANONYMOUS_AUTHORITY);
+
+ private final com.juick.User user;
+ private final Collection<? extends GrantedAuthority> authorities;
+
+ public JuickUser(com.juick.User user) {
+ this(user, USER_AUTHORITY);
+ }
+
+ public JuickUser(com.juick.User user, Collection<? extends GrantedAuthority> authorities) {
+ this.user = user;
+ this.authorities = authorities;
+ }
+
+ @Override
+ public Collection<? extends GrantedAuthority> getAuthorities() {
+ return authorities;
+ }
+
+ @Override
+ public String getPassword() {
+ return "{noop}" + user.getCredentials();
+ }
+
+ @Override
+ public String getUsername() {
+ return user.getName();
+ }
+
+ @Override
+ public boolean isAccountNonExpired() {
+ return true;
+ }
+
+ @Override
+ public boolean isAccountNonLocked() {
+ return StringUtils.isNotBlank(user.getCredentials());
+ }
+
+ @Override
+ public boolean isCredentialsNonExpired() {
+ return isAccountNonLocked();
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return !user.isBanned() && isCredentialsNonExpired();
+ }
+
+ public User getUser() {
+ return user;
+ }
+}
diff --git a/src/main/java/com/juick/util/DateFormatter.java b/src/main/java/com/juick/util/DateFormatter.java
new file mode 100644
index 00000000..8f569562
--- /dev/null
+++ b/src/main/java/com/juick/util/DateFormatter.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.util;
+
+import org.apache.commons.lang3.StringUtils;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Locale;
+
+/**
+ * Created by aalexeev on 12/7/16.
+ */
+public class DateFormatter {
+ private final DateTimeFormatter formatter;
+
+ public DateFormatter(String pattern) {
+ formatter = DateTimeFormatter.ofPattern(pattern, Locale.ENGLISH);
+ }
+
+ public String format(final Instant ts) {
+ if (ts == null)
+ return null;
+
+ ZonedDateTime ldt = ZonedDateTime.ofInstant(ts, ZoneOffset.UTC);
+
+ return ldt.format(formatter);
+ }
+
+
+ public Instant parse(final String v) {
+ if (StringUtils.isBlank(v))
+ return null;
+
+ ZonedDateTime ldt = ZonedDateTime.parse(v, formatter);
+
+ return ldt.toInstant();
+ }
+}
diff --git a/src/main/java/com/juick/util/DateFormattersHolder.java b/src/main/java/com/juick/util/DateFormattersHolder.java
new file mode 100644
index 00000000..8292e68e
--- /dev/null
+++ b/src/main/java/com/juick/util/DateFormattersHolder.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.util;
+
+/**
+ * Created by aalexeev on 12/7/16.
+ */
+public class DateFormattersHolder {
+
+ private DateFormattersHolder() {
+ throw new IllegalStateException();
+ }
+
+ private static volatile DateFormatter messageFormatter;
+
+ public static DateFormatter getMessageFormatterInstance() {
+ DateFormatter localInstance = messageFormatter;
+
+ if (localInstance == null) {
+ synchronized (DateFormatter.class) {
+ localInstance = messageFormatter;
+
+ if (localInstance == null)
+ messageFormatter = localInstance = new DateFormatter("yyyy-MM-dd HH:mm:ss");
+ }
+ }
+ return localInstance;
+ }
+
+ private static volatile DateFormatter rssFormatter;
+
+ public static DateFormatter getRssFormatterInstance() {
+ DateFormatter localInstance = rssFormatter;
+
+ if (localInstance == null) {
+ synchronized (DateFormatter.class) {
+ localInstance = rssFormatter;
+
+ if (localInstance == null)
+ rssFormatter = localInstance = new DateFormatter("EEE, d MMM yyyy HH:mm:ss");
+ }
+ }
+ return localInstance;
+ }
+
+ private static volatile DateFormatter httpDateFormatter;
+
+ public static DateFormatter getHttpDateFormatter() {
+ DateFormatter localInstance = httpDateFormatter;
+ if (localInstance == null) {
+ synchronized (DateFormatter.class) {
+ localInstance = httpDateFormatter;
+ if (localInstance == null) {
+ httpDateFormatter = localInstance = new DateFormatter("EEE, dd MMM yyyy HH:mm:ss O");
+ }
+ }
+ }
+ return localInstance;
+ }
+}
diff --git a/src/main/java/com/juick/util/MessageUtils.java b/src/main/java/com/juick/util/MessageUtils.java
new file mode 100644
index 00000000..fd357c32
--- /dev/null
+++ b/src/main/java/com/juick/util/MessageUtils.java
@@ -0,0 +1,324 @@
+/*
+ * 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.util;
+
+import com.juick.Message;
+import com.juick.Tag;
+import com.juick.User;
+import org.apache.commons.codec.CharEncoding;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+/**
+ * Created by aalexeev on 11/13/16.
+ */
+public class MessageUtils {
+ private MessageUtils() {
+ throw new IllegalStateException();
+ }
+
+ public static String formatQuote(final String quote) {
+ String result = quote;
+
+ if (quote != null) {
+ if (quote.length() > 50) {
+ result = ">" + quote.substring(0, 47).replace('\n', ' ') + "...\n";
+ } else if (!quote.isEmpty()) {
+ result = ">" + quote.replace('\n', ' ') + "\n";
+ }
+ }
+
+ return result;
+ }
+
+ private final static String urlWhiteSpacePrefix = "((?<=\\s)|(?<=\\A))";
+
+ private final static String urlRegex = "((?:ht|f)tps?://(?:www\\.)?([^\\/\\s\\n\\\"]+)/?[^\\]\\s\\n\\\"\\>]*)";
+
+ private final static String urlWithWhitespacesRegex =
+ urlWhiteSpacePrefix + urlRegex;
+
+ private final static Pattern regexLinks2 = Pattern.compile("((?<=\\s)|(?<=\\A))([\\[\\{]|&lt;)((?:ht|f)tps?://(?:www\\.)?([^\\/\\s\\\"\\)\\!]+)/?(?:[^\\]\\}](?<!&gt;))*)([\\]\\}]|&gt;)");
+
+ private final static String replyNumberRegex = "((?<=\\s)|(?<=\\A))\\/(\\d+)((?=\\s)|(?=\\Z)|(?=\\p{Punct}))";
+
+ private final static String usernameRegex = "((?<=\\s)|(?<=\\A))@([\\w\\-]{2,16})((?=\\s)|(?=\\Z)|(?=\\p{Punct}))";
+ private final static Pattern usernamePattern = Pattern.compile(usernameRegex);
+
+ private final static String jidRegex = "((?<=\\s)|(?<=\\A))@([\\w\\-\\.]+@[\\w\\-\\.]+)((?=\\s)|(?=\\Z)|(?=\\p{Punct}))";
+ private final static Pattern jidPattern = Pattern.compile(jidRegex);
+
+ public static String formatMessageCode(String msg) {
+ msg = msg.replaceAll("&", "&amp;");
+ msg = msg.replaceAll("<", "&lt;");
+ msg = msg.replaceAll(">", "&gt;");
+
+ // http://juick.com/last?page=2
+ // <a href="http://juick.com/last?page=2" rel="nofollow">http://juick.com/last?page=2</a>
+ msg = msg.replaceAll(urlWithWhitespacesRegex, "$1<a href=\"$2\" rel=\"nofollow\">$2</a>");
+
+ // (http://juick.com/last?page=2)
+ // (<a href="http://juick.com/last?page=2" rel="nofollow">http://juick.com/last?page=2</a>)
+ Matcher m = regexLinks2.matcher(msg);
+ StringBuffer sb = new StringBuffer();
+ while (m.find()) {
+ String url = m.group(3).replace(" ", "%20").replaceAll("\\s+", StringUtils.EMPTY);
+ m.appendReplacement(sb, "$1$2<a href=\"" + url + "\" rel=\"nofollow\">" + url + "</a>$5");
+ }
+ m.appendTail(sb);
+ msg = sb.toString();
+
+ return "<pre>" + msg + "</pre>";
+ }
+
+ public static String formatMessage(String msg) {
+ msg = msg.replaceAll("&", "&amp;");
+ msg = msg.replaceAll("<", "&lt;");
+ msg = msg.replaceAll(">", "&gt;");
+
+ // --
+ // &mdash;
+ msg = msg.replaceAll("((?<=\\s)|(?<=\\A))\\-\\-?((?=\\s)|(?=\\Z))", "$1&mdash;$2");
+
+ // http://juick.com/last?page=2
+ // <a href="http://juick.com/last?page=2" rel="nofollow">juick.com</a>
+ msg = msg.replaceAll(urlWithWhitespacesRegex, "$1<a href=\"$2\" rel=\"nofollow\">$3</a>");
+
+ // [link text][http://juick.com/last?page=2]
+ // <a href="http://juick.com/last?page=2" rel="nofollow">link text</a>
+ msg = msg.replaceAll("\\[([^\\]]+)\\]\\[((?:ht|f)tps?://[^\\]]+)\\]", "<a href=\"$2\" rel=\"nofollow\">$1</a>");
+ msg = msg.replaceAll("\\[([^\\]]+)\\]\\(((?:ht|f)tps?://[^\\)]+)\\)", "<a href=\"$2\" rel=\"nofollow\">$1</a>");
+
+ // #12345
+ // <a href="http://juick.com/12345">#12345</a>
+ msg = msg.replaceAll("((?<=\\s)|(?<=\\A)|(?<=\\p{Punct}))#(\\d+)((?=\\s)|(?=\\Z)|(?=\\))|(?=\\.)|(?=\\,))", "$1<a href=\"https://juick.com/m/$2\">#$2</a>$3");
+
+ // #12345/65
+ // <a href="http://juick.com/12345#65">#12345/65</a>
+ msg = msg.replaceAll("((?<=\\s)|(?<=\\A)|(?<=\\p{Punct}))#(\\d+)/(\\d+)((?=\\s)|(?=\\Z)|(?=\\p{Punct}))", "$1<a href=\"https://juick.com/m/$2#$3\">#$2/$3</a>$4");
+
+ // *bold*
+ // <b>bold</b>
+ msg = msg.replaceAll("((?<=\\s)|(?<=\\A)|(?<=\\p{Punct}))\\*([^\\*\\n<>]+)\\*((?=\\s)|(?=\\Z)|(?=\\p{Punct}))", "$1<b>$2</b>$3");
+
+ // /italic/
+ // <i>italic</i>
+ msg = msg.replaceAll("((?<=\\s)|(?<=\\A))/([^\\/\\n<>]+)/((?=\\s)|(?=\\Z)|(?=\\p{Punct}))", "$1<i>$2</i>$3");
+
+ // _underline_
+ // <span class="u">underline</span>
+ msg = msg.replaceAll("((?<=\\s)|(?<=\\A))_([^\\_\\n<>]+)_((?=\\s)|(?=\\Z)|(?=\\p{Punct}))", "$1<span class=\"u\">$2</span>$3");
+
+ // /12
+ // <a href="#12">/12</a>
+ msg = msg.replaceAll(replyNumberRegex, "$1<a href=\"#$2\">/$2</a>$3");
+
+ // @username@jabber.org
+ // <a href="http://juick.com/username@jabber.org/">@username@jabber.org</a>
+ msg = msg.replaceAll(jidRegex, "$1<a href=\"https://juick.com/$2/\">@$2</a>$3");
+
+ // @username
+ // <a href="http://juick.com/username/">@username</a>
+ msg = msg.replaceAll(usernameRegex, "$1<a href=\"https://juick.com/$2/\">@$2</a>$3");
+
+ // (http://juick.com/last?page=2)
+ // (<a href="http://juick.com/last?page=2" rel="nofollow">juick.com</a>)
+ Matcher m = regexLinks2.matcher(msg);
+ StringBuffer sb = new StringBuffer();
+ while (m.find()) {
+ String url = m.group(3).replace(" ", "%20").replaceAll("\\s+", StringUtils.EMPTY);
+ m.appendReplacement(sb, "$1$2<a href=\"" + url + "\" rel=\"nofollow\">$4</a>$5");
+ }
+ m.appendTail(sb);
+ msg = sb.toString();
+
+ // > citate
+ msg = msg.replaceAll("(?:(?<=\\n)|(?<=\\A))&gt; *(.*)?(\\n|(?=\\Z))", "<q>$1</q>");
+ msg = msg.replaceAll("</q><q>", "\n");
+
+ msg = msg.replaceAll("\n", "<br/>\n");
+ return msg;
+ }
+
+ public static String formatHtml(Message jmsg) {
+ StringBuilder sb = new StringBuilder();
+ boolean isReply = jmsg.getRid() > 0;
+ String title = isReply ? "<b>Reply by @" : "<b>@";
+ String subtitle = isReply ? "<blockquote>" + jmsg.getReplyQuote() + "</blockquote>" : "<i>" + getTagsString(jmsg) + "</i>";
+ boolean isCode = jmsg.getTags().stream().anyMatch(t -> t.getName().equals("code"));
+
+ sb.append(title).append(jmsg.getUser().getName()).append(":</b></br/>")
+ .append(subtitle).append("<br/>")
+ .append(isCode ? formatMessageCode(StringUtils.defaultString(jmsg.getText()))
+ : formatMessage(StringUtils.defaultString(jmsg.getText()))).append("<br />");
+ if (StringUtils.isNotEmpty(jmsg.getAttachmentType())) {
+ // FIXME: attachment does not serialized to xml
+ if (jmsg.getAttachment() == null) {
+ if (jmsg.getRid() > 0) {
+ sb.append(String.format("<img src=\"http://i.juick.com/photos-1024/%d-%d.%s\" />", jmsg.getMid(),
+ jmsg.getRid(), jmsg.getAttachmentType()));
+ } else {
+ sb.append(String.format("<img src=\"http://i.juick.com/photos-1024/%d.%s\" />", jmsg.getMid(),
+ jmsg.getAttachmentType()));
+ }
+ } else {
+ sb.append("<img src=\"").append(jmsg.getAttachment().getMedium().getUrl()).append("\" />");
+ }
+ }
+ return sb.toString();
+ }
+
+ public static String getMessageHashTags(final Message jmsg) {
+ StringBuilder hashtags = new StringBuilder();
+ for (Tag tag : jmsg.getTags()) {
+ hashtags.append("#").append(tag).append(" ");
+ }
+ return hashtags.toString();
+ }
+ public static String getMarkdownTags(final Message jmsg) {
+ return jmsg.getTags().stream().map(t -> String.format("[%s](http://juick.com/tag/%s)", t.getName(), percentEncode(t.getName())))
+ .collect(Collectors.joining(", "));
+ }
+
+ public static String getMarkdownUser(final User user) {
+ return String.format("[%s](https://juick.com/%s/)", user.getName(), user.getName());
+ }
+
+ // TODO: check if it is really needed
+ public static String percentEncode(final String s) {
+ String ret = StringUtils.EMPTY;
+ try {
+ ret = URLEncoder.encode(s, CharEncoding.UTF_8).replace("+", "%20").replace("*", "%2A").replace("%7E", "~");
+ } catch (UnsupportedEncodingException e) {
+ }
+ return ret;
+ }
+ public static String formatMarkdownText(final Message msg) {
+ String s = StringUtils.defaultString(msg.getText()).replaceAll(replyNumberRegex, String.format("$1[/$2](https://juick.com/m/%d#$2)$3", msg.getMid()));
+ return escapeMarkdown(s);
+ }
+ public static String escapeMarkdown(final String s) {
+ return s.replace("_", "\\_").replace("*", "\\*")
+ .replace("`", "\\`");
+ }
+ public static String attachmentUrl(final Message jmsg) {
+ if (StringUtils.isEmpty(jmsg.getAttachmentType())) {
+ return StringUtils.EMPTY;
+ }
+ // FIXME: attachment does not serialized to xml
+ if (jmsg.getAttachment() == null) {
+ if (jmsg.getRid() > 0) {
+ return String.format("http://i.juick.com/photos-1024/%d-%d.%s", jmsg.getMid(),
+ jmsg.getRid(), jmsg.getAttachmentType());
+ } else {
+ return String.format("http://i.juick.com/photos-1024/%d.%s", jmsg.getMid(),
+ jmsg.getAttachmentType());
+ }
+ } else {
+ return jmsg.getAttachment().getMedium().getUrl();
+ }
+ }
+ public static boolean replyStartsWithQuote(Message msg) {
+ return msg.getRid() > 0 && StringUtils.defaultString(msg.getText()).startsWith(">");
+ }
+ public static List<Tag> parseTags(String strTags) {
+ List<Tag> tags = new ArrayList<>();
+ if (StringUtils.isNotEmpty(strTags)) {
+ Set<Tag> tagSet = new TreeSet<>(tags);
+ for (String str : strTags.split(" ")) {
+ Tag tag = new Tag(str);
+ if (!tagSet.contains(tag)) {
+ tags.add(tag);
+ tagSet.add(tag);
+ }
+ }
+ }
+ return tags;
+ }
+ public static String getTagsString(Message msg) {
+ StringBuilder builder = new StringBuilder();
+ List<Tag> tags = msg.getTags();
+ if (!tags.isEmpty()) {
+ for (Tag Tag : tags)
+ builder.append(" *").append(Tag.getName());
+
+ if (msg.FriendsOnly)
+ builder.append(" *friends");
+
+ if (msg.getPrivacy() == -2)
+ builder.append(" *private");
+ else if (msg.getPrivacy() == -1)
+ builder.append(" *friends");
+ else if (msg.getPrivacy() == 2)
+ builder.append(" *public");
+
+ if (msg.ReadOnly)
+ builder.append(" *readonly");
+ }
+ return builder.toString();
+ }
+ public static boolean isPM(Message message) {
+ return message.getMid() == 0;
+ }
+ public static boolean isReply(Message message) {
+ return message.getRid() > 0;
+ }
+
+ public static String stripNonSafeUrls(String input) {
+ // strip login urls
+ try {
+ Matcher urlMatcher = Pattern.compile(MessageUtils.urlRegex).matcher(input);
+ while (urlMatcher.find()) {
+ URI uri = URI.create(urlMatcher.group(0));
+ if (uri.getHost().equals("juick.com")) {
+ UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUri(uri);
+ uriComponentsBuilder.replaceQueryParam("hash");
+ input = input.replace(urlMatcher.group(0), uriComponentsBuilder.build().toUriString());
+ }
+ }
+ } catch (IllegalArgumentException | NullPointerException e) {
+ return input;
+ }
+ return input;
+ }
+ private static List<String> collectMatches(Pattern pattern, String input) {
+ Matcher matcher = pattern.matcher(input);
+ List<String> result = new ArrayList<>();
+ while (matcher.find()) {
+ result.add(matcher.group());
+ }
+ return result;
+ }
+ public static List<String> getMentions(Message msg) {
+ return collectMatches(usernamePattern, msg.getText());
+ }
+ public static List<String> getGlobalMentions(Message msg) {
+ return collectMatches(jidPattern, msg.getText());
+ }
+}
diff --git a/src/main/java/com/juick/util/PrettyTimeFormatter.java b/src/main/java/com/juick/util/PrettyTimeFormatter.java
new file mode 100644
index 00000000..383f4d9a
--- /dev/null
+++ b/src/main/java/com/juick/util/PrettyTimeFormatter.java
@@ -0,0 +1,53 @@
+/*
+ * 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.util;
+
+import org.ocpsoft.prettytime.PrettyTime;
+
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Created by vitalyster on 04.05.2017.
+ */
+public class PrettyTimeFormatter {
+ private static final int MAX_CACHE_SIZE = 20;
+
+ // Cache PrettyTime per locale. LRU cache to prevent memory leak.
+ private static final Map<Locale, PrettyTime> PRETTY_TIME_LOCALE_MAP =
+ new LinkedHashMap<Locale, PrettyTime>(MAX_CACHE_SIZE + 1, 1.1F, true)
+ {
+ @Override
+ protected boolean removeEldestEntry(Map.Entry<Locale, PrettyTime> eldest)
+ {
+ return size() > MAX_CACHE_SIZE;
+ }
+ };
+
+ public String format(final Locale locale, final Date value)
+ {
+ PrettyTime prettyTime;
+
+ synchronized (PRETTY_TIME_LOCALE_MAP) {
+ prettyTime = PRETTY_TIME_LOCALE_MAP.computeIfAbsent(locale, PrettyTime::new);
+ }
+ return prettyTime.format(value);
+ }
+}
diff --git a/src/main/java/com/juick/util/StreamUtils.java b/src/main/java/com/juick/util/StreamUtils.java
new file mode 100644
index 00000000..576107af
--- /dev/null
+++ b/src/main/java/com/juick/util/StreamUtils.java
@@ -0,0 +1,38 @@
+package com.juick.util;
+
+import java.util.Spliterator;
+import java.util.Spliterators;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+
+/**
+ * @deprecated switch to JDK9+ Stream API when possible
+ */
+@Deprecated
+public class StreamUtils {
+ private static <T> Spliterator<T> takeWhile(
+ Spliterator<T> splitr, Predicate<? super T> predicate) {
+ return new Spliterators.AbstractSpliterator<T>(splitr.estimateSize(), 0) {
+ boolean stillGoing = true;
+ @Override public boolean tryAdvance(Consumer<? super T> consumer) {
+ if (stillGoing) {
+ boolean hadNext = splitr.tryAdvance(elem -> {
+ if (predicate.test(elem)) {
+ consumer.accept(elem);
+ } else {
+ stillGoing = false;
+ }
+ });
+ return hadNext && stillGoing;
+ }
+ return false;
+ }
+ };
+ }
+
+ public static <T> Stream<T> takeWhile(Stream<T> stream, Predicate<? super T> predicate) {
+ return StreamSupport.stream(takeWhile(stream.spliterator(), predicate), false);
+ }
+}