/* * Copyright (C) 2008-2020, Juick * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ package com.juick; import com.juick.model.User; import com.juick.util.formatters.PlainTextFormatter; import com.juick.model.CommandResult; import com.juick.www.api.SystemActivity; import com.juick.www.WebApp; import com.juick.util.xmpp.iq.MessageQuery; import com.juick.service.MessagesService; import com.juick.service.PMQueriesService; import com.juick.service.UserService; import com.juick.service.component.*; 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.core.io.Resource; import rocks.xmpp.addr.Jid; import rocks.xmpp.core.XmppException; import rocks.xmpp.core.session.Extension; import rocks.xmpp.core.session.XmppSession; import rocks.xmpp.core.session.XmppSessionConfiguration; import rocks.xmpp.core.session.debug.LogbackDebugger; import rocks.xmpp.core.stanza.AbstractIQHandler; 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.StanzaError; 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 javax.annotation.Nonnull; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import javax.inject.Inject; import java.io.IOException; 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.Duration; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executor; /** * @author ugnich */ public class XMPPManager implements NotificationListener { private static final Logger logger = LoggerFactory.getLogger("XMPP"); private ExternalComponent xmpp; @Inject private CommandsManager commandsManager; @Value("${xmppbot_jid:juick@localhost}") private Jid jid; @Value("${hostname:localhost}") private String componentName; @Value("${component_port:5347}") private int componentPort; @Value("${component_host:localhost}") private String componentHost; @Value("${xmpp_password:secret}") private String password; @Value("${upload_tmp_dir:#{systemEnvironment['TEMP'] ?: '/tmp'}}") private String tmpDir; @Value("classpath:juick.png") private Resource vCardImage; @Inject private MessagesService messagesService; @Inject private UserService userService; @Inject private PMQueriesService pmQueriesService; @Inject private Executor applicationTaskExecutor; @Value("${service_user:juick}") private String serviceUsername; @Inject private WebApp webApp; private User serviceUser; @PostConstruct public void init() { logger.info("xmpp component start connecting to {}", componentPort); XmppSessionConfiguration configuration = XmppSessionConfiguration.builder() .extensions(Extension.of(com.juick.model.Message.class), Extension.of(MessageQuery.class)) .debugger(LogbackDebugger.class) .defaultResponseTimeout(Duration.ofMillis(120000)) .build(); xmpp = ExternalComponent.create(componentName, password, configuration, componentHost, componentPort); ServiceDiscoveryManager serviceDiscoveryManager = xmpp.getManager(ServiceDiscoveryManager.class); serviceDiscoveryManager.addIdentity(Identity.clientBot().withName("Juick")); EntityCapabilitiesManager entityCapabilitiesManager = xmpp.getManager(EntityCapabilitiesManager.class); entityCapabilitiesManager.setNode("https://juick.com/caps"); MessageDeliveryReceiptsManager messageDeliveryReceiptsManager = xmpp.getManager(MessageDeliveryReceiptsManager.class); messageDeliveryReceiptsManager.setEnabled(true); PingManager pingManager = xmpp.getManager(PingManager.class); pingManager.setEnabled(true); SoftwareVersionManager softwareVersionManager = xmpp.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(vCardImage.getInputStream()))); } catch (MalformedURLException e) { logger.error("invalid url", e); } catch (IOException e) { logger.warn("invalid resource", e); } xmpp.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"); xmpp.send(warningMessage); return iq.createError(new StanzaError(Condition.BAD_REQUEST, "Please stop this spam")); }); xmpp.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()) { User info = userService.getUserInfo(user); VCard userVCard = new VCard(); userVCard.setFormattedName(info.getFullName()); userVCard.setNickname(user.getName()); try { userVCard.setPhoto(new VCard.Image(URI.create(webApp.getAvatarUrl(user)))); if (info.getUrl() != null) { userVCard.setUrl(new URL(info.getUrl())); } } catch (MalformedURLException e) { logger.warn("url exception", e); } return iq.createResult(userVCard); } return iq.createError(Condition.BAD_REQUEST); } }); xmpp.addInboundMessageListener(e -> { ClientMessage result = incomingMessage(e.getMessage()); if (result != null) { xmpp.send(result); } }); FileTransferManager fileTransferManager = xmpp.getManager(FileTransferManager.class); fileTransferManager.addFileTransferOfferListener(e -> { try { List allowedTypes = new ArrayList<>() {{ 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, jid.getLocal(), StringUtils.defaultString(e.getDescription()).trim(), URI.create(String.format("juick://%s", targetFilename))); if (result != null) { xmpp.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"); xmpp.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); } }); xmpp.addConnectionListener(event -> { if (event.getType().equals(rocks.xmpp.core.session.ConnectionEvent.Type.RECONNECTION_SUCCEEDED)) { logger.info("component connected"); } }); xmpp.addSessionStatusListener(event -> { logger.info("event: " + event.getStatus(), event.getThrowable()); if (event.getStatus().equals(XmppSession.Status.AUTHENTICATED)) { logger.info("Authenticated, broadcasting..."); broadcastPresence(null); } }); xmpp.addInboundPresenceListener(event -> { incomingPresence(event.getPresence()); }); applicationTaskExecutor.execute(() -> { try { xmpp.connect(); } catch (XmppException e) { logger.warn("xmpp exception", e); } }); serviceUser = userService.getUserByName(serviceUsername); } private void sendJuickMessage(com.juick.model.Message jmsg, List users) { List jids = new ArrayList<>(); for (User user : users) { jids.addAll(userService.getJIDsbyUID(user.getUid())); } com.juick.model.Message fullMsg = messagesService.getMessage(jmsg.getMid()).orElseThrow(IllegalStateException::new); 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)); xmpp.send(ClientMessage.from(msg)); } } private void sendJuickComment(com.juick.model.Message jmsg, List users) { String replyQuote; String replyTo; com.juick.model.Message replyMessage = jmsg.getReplyto() > 0 ? messagesService.getReply(jmsg.getMid(), jmsg.getReplyto()) : messagesService.getMessage(jmsg.getMid()).orElseThrow(IllegalStateException::new); replyTo = replyMessage.getUser().getName(); com.juick.model.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)); xmpp.send(ClientMessage.from(msg)); } } } @Override public void processSystemEvent(SystemEvent systemEvent) { var activity = systemEvent.getActivity(); var type = activity.getType(); if (type.equals(SystemActivity.ActivityType.message)) { processMessage(activity.getMessage(), activity.getTo()); } else if (type.equals(SystemActivity.ActivityType.like)) { if (systemEvent.getActivity().getFrom().equals(serviceUser)) { processTop(systemEvent.getActivity().getMessage()); } else { processLike(activity.getFrom(), activity.getMessage(), activity.getTo()); } } } private void processMessage(com.juick.model.Message msg, List subscribers) { 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()); } xmpp.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); } public void processLike(User liker, com.juick.model.Message jmsg, List users) { 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))); xmpp.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)); xmpp.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); xmpp.send(ClientPresence.from(p)); }); } public void processTop(com.juick.model.Message message) { 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); xmpp.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"); } xmpp.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)); xmpp.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); xmpp.send(ClientPresence.from(reply)); reply.setFrom(reply.getFrom().withResource(jid.getResource())); reply.setPriority((byte) 10); reply.setType(null); xmpp.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)); xmpp.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); xmpp.send(ClientPresence.from(reply)); } } public ClientMessage incomingMessage(Message msg) { 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(xmpp.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."); } URI attachment = URI.create(StringUtils.EMPTY); OobX oobX = msg.getExtension(OobX.class); if (oobX != null) { attachment = oobX.getUri(); } try { return incomingMessageJuick(user_from, msg.getFrom(), msg.getTo().getLocal(), StringUtils.defaultString(msg.getBody()).trim(), attachment); } catch (Exception e1) { logger.warn("message exception", e1); } } ClientMessage errorMessage = ClientMessage.from(msg); errorMessage.setError(new StanzaError(StanzaError.Type.CANCEL, Condition.ITEM_NOT_FOUND)); return errorMessage; } private ClientMessage incomingMessageJuick(User user_from, Jid from, String to, String command, @Nonnull URI attachment) { if (StringUtils.isBlank(command) && attachment.toString().isEmpty()) { return null; } messagesService.getUnread(user_from).forEach(mid -> messagesService.setRead(user_from, mid)); int commandlen = command.length(); // COMPATIBILITY if (commandlen > 7 && command.substring(0, 3).equalsIgnoreCase("PM ")) { command = command.substring(3); } if (!jid.getLocal().equals(to)) { // PM if (!StringUtils.isEmpty(command)) { commandsManager.commandPM(user_from, null, to, command); return null; } } try { CommandResult result = commandsManager.processCommand(user_from, command, 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; } 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)); xmpp.send(ClientPresence.from(presence)); } catch (IllegalArgumentException ex) { logger.warn("Invalid jid: {}", j, ex); } }); } @PreDestroy public void close() throws Exception { broadcastPresence(Presence.Type.UNAVAILABLE); if (xmpp != null) { xmpp.close(); } } }