From 2bf63fc8d2a4b84e051d28e670c7c74b039e2545 Mon Sep 17 00:00:00 2001 From: Vitaly Takmazov Date: Wed, 25 Apr 2018 13:54:25 +0300 Subject: www: no more xmpp --- .../com/juick/server/NotificationListener.java | 18 + .../main/java/com/juick/server/XMPPConnection.java | 673 +++++++++++++++++++++ .../src/main/java/com/juick/server/XMPPServer.java | 413 +++++++++++++ .../main/java/com/juick/server/api/Messages.java | 19 - .../src/main/java/com/juick/server/api/PM.java | 5 - .../src/main/java/com/juick/server/api/Post.java | 288 --------- .../main/java/com/juick/server/api/Service.java | 117 ++++ .../server/configuration/ApiAppConfiguration.java | 25 + .../server/configuration/ApiSecurityConfig.java | 124 ---- .../java/com/juick/server/xmpp/JidConverter.java | 13 + .../com/juick/server/xmpp/helpers/XMPPStatus.java | 48 ++ .../com/juick/server/xmpp/iq/MessageQuery.java | 10 + .../com/juick/server/xmpp/iq/package-info.java | 8 + .../java/com/juick/server/xmpp/router/Stream.java | 184 ++++++ .../server/xmpp/router/StreamComponentServer.java | 58 ++ .../com/juick/server/xmpp/router/StreamError.java | 44 ++ .../juick/server/xmpp/router/StreamHandler.java | 13 + .../juick/server/xmpp/router/StreamNamespaces.java | 10 + .../com/juick/server/xmpp/router/XMPPError.java | 73 +++ .../com/juick/server/xmpp/router/XMPPRouter.java | 189 ++++++ .../com/juick/server/xmpp/router/XmlUtils.java | 88 +++ .../juick/server/xmpp/s2s/BasicXmppSession.java | 69 +++ .../java/com/juick/server/xmpp/s2s/CacheEntry.java | 40 ++ .../java/com/juick/server/xmpp/s2s/Connection.java | 139 +++++ .../com/juick/server/xmpp/s2s/ConnectionIn.java | 213 +++++++ .../juick/server/xmpp/s2s/ConnectionListener.java | 15 + .../com/juick/server/xmpp/s2s/ConnectionOut.java | 167 +++++ .../java/com/juick/server/xmpp/s2s/DNSQueries.java | 65 ++ .../com/juick/server/xmpp/s2s/StanzaListener.java | 28 + .../juick/server/xmpp/s2s/util/DialbackUtils.java | 37 ++ 30 files changed, 2757 insertions(+), 436 deletions(-) create mode 100644 juick-server/src/main/java/com/juick/server/NotificationListener.java create mode 100644 juick-server/src/main/java/com/juick/server/XMPPConnection.java create mode 100644 juick-server/src/main/java/com/juick/server/XMPPServer.java delete mode 100644 juick-server/src/main/java/com/juick/server/api/Post.java create mode 100644 juick-server/src/main/java/com/juick/server/api/Service.java delete mode 100644 juick-server/src/main/java/com/juick/server/configuration/ApiSecurityConfig.java create mode 100644 juick-server/src/main/java/com/juick/server/xmpp/JidConverter.java create mode 100644 juick-server/src/main/java/com/juick/server/xmpp/helpers/XMPPStatus.java create mode 100644 juick-server/src/main/java/com/juick/server/xmpp/iq/MessageQuery.java create mode 100644 juick-server/src/main/java/com/juick/server/xmpp/iq/package-info.java create mode 100644 juick-server/src/main/java/com/juick/server/xmpp/router/Stream.java create mode 100644 juick-server/src/main/java/com/juick/server/xmpp/router/StreamComponentServer.java create mode 100644 juick-server/src/main/java/com/juick/server/xmpp/router/StreamError.java create mode 100644 juick-server/src/main/java/com/juick/server/xmpp/router/StreamHandler.java create mode 100644 juick-server/src/main/java/com/juick/server/xmpp/router/StreamNamespaces.java create mode 100644 juick-server/src/main/java/com/juick/server/xmpp/router/XMPPError.java create mode 100644 juick-server/src/main/java/com/juick/server/xmpp/router/XMPPRouter.java create mode 100644 juick-server/src/main/java/com/juick/server/xmpp/router/XmlUtils.java create mode 100644 juick-server/src/main/java/com/juick/server/xmpp/s2s/BasicXmppSession.java create mode 100644 juick-server/src/main/java/com/juick/server/xmpp/s2s/CacheEntry.java create mode 100644 juick-server/src/main/java/com/juick/server/xmpp/s2s/Connection.java create mode 100644 juick-server/src/main/java/com/juick/server/xmpp/s2s/ConnectionIn.java create mode 100644 juick-server/src/main/java/com/juick/server/xmpp/s2s/ConnectionListener.java create mode 100644 juick-server/src/main/java/com/juick/server/xmpp/s2s/ConnectionOut.java create mode 100644 juick-server/src/main/java/com/juick/server/xmpp/s2s/DNSQueries.java create mode 100644 juick-server/src/main/java/com/juick/server/xmpp/s2s/StanzaListener.java create mode 100644 juick-server/src/main/java/com/juick/server/xmpp/s2s/util/DialbackUtils.java (limited to 'juick-server/src/main/java/com/juick') diff --git a/juick-server/src/main/java/com/juick/server/NotificationListener.java b/juick-server/src/main/java/com/juick/server/NotificationListener.java new file mode 100644 index 00000000..f6330570 --- /dev/null +++ b/juick-server/src/main/java/com/juick/server/NotificationListener.java @@ -0,0 +1,18 @@ +package com.juick.server; + +import com.juick.server.component.LikeEvent; +import com.juick.server.component.MessageEvent; +import com.juick.server.component.PingEvent; +import com.juick.server.component.SubscribeEvent; +import org.springframework.context.event.EventListener; + +public interface NotificationListener { + @EventListener + void processMessageEvent(MessageEvent messageEvent); + @EventListener + void processSubscribeEvent(SubscribeEvent subscribeEvent); + @EventListener + void processLikeEvent(LikeEvent likeEvent); + @EventListener + void ProcessPingEvent(PingEvent pingEvent); +} diff --git a/juick-server/src/main/java/com/juick/server/XMPPConnection.java b/juick-server/src/main/java/com/juick/server/XMPPConnection.java new file mode 100644 index 00000000..59b33aba --- /dev/null +++ b/juick-server/src/main/java/com/juick/server/XMPPConnection.java @@ -0,0 +1,673 @@ +/* + * 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 . + */ + +package com.juick.server; + +import com.juick.User; +import com.juick.server.component.LikeEvent; +import com.juick.server.component.MessageEvent; +import com.juick.server.component.PingEvent; +import com.juick.server.component.SubscribeEvent; +import com.juick.server.helpers.CommandResult; +import com.juick.server.helpers.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.apache.commons.lang3.math.NumberUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.DependsOn; +import org.springframework.stereotype.Component; +import rocks.xmpp.addr.Jid; +import rocks.xmpp.core.XmppException; +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.model.EntityCapabilities; +import rocks.xmpp.extensions.component.accept.ExternalComponent; +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.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.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; + +/** + * @author ugnich + */ +@Component +@DependsOn("XMPPRouter") +public class XMPPConnection implements StanzaListener, NotificationListener { + + private static final Logger logger = LoggerFactory.getLogger(XMPPConnection.class); + + 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; + @Value("${img_path:#{systemEnvironment['TEMP'] ?: '/tmp'}}") + private String imgDir; + + @Inject + private MessagesService messagesService; + @Inject + private UserService userService; + @Inject + private SubscriptionService subscriptionService; + @Inject + private PMQueriesService pmQueriesService; + @Inject + private TagService tagService; + @Inject + private BasicXmppSession session; + @Inject + private ExecutorService service; + @Inject + private ApplicationEventPublisher applicationEventPublisher; + + @PostConstruct + public void init() { + logger.info("stream router start connecting to {}", componentPort); + xmpp.addStanzaListener(this); + router = ExternalComponent.create(componentName, password, session.getConfiguration(), "localhost", + componentPort); + PingManager pingManager = router.getManager(PingManager.class); + pingManager.setEnabled(true); + router.disableFeature(EntityCapabilities.NAMESPACE); + 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"); + 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.getUid() > 0) { + 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 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, + 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"); + } + }); + service.submit(() -> { + try { + router.connect(); + broadcastPresence(null); + } catch (XmppException e) { + logger.warn("xmpp exception", e); + } + }); + } + + 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 users) { + List jids = new ArrayList<>(); + + if (jmsg.FriendsOnly) { + jids = subscriptionService.getJIDSubscribedToUser(jmsg.getUser().getUid(), jmsg.FriendsOnly); + } else { + 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/" + 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 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 = 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/" + 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 subscribers = event.getUsers(); + 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 users = likeEvent.getSubscribers(); + com.juick.Message jmsg = likeEvent.getMessage(); + User liker = likeEvent.getUser(); + + 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/" + 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)); + }); + } + + 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); + } + + 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; + if (msg.getFrom().getDomain().equals("uid.juick.com")) { + user_from = userService.getUserByUID(NumberUtils.toInt(msg.getFrom().getLocal(), + 0)).orElse(new User()); + } else { + user_from = userService.getUserByJID(msg.getFrom().asBareJid().toEscapedString()); + } + if ((user_from == null || user_from.getUid() == 0) && !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 (to.getLocal().equals("pm")) { + applicationEventPublisher.publishEvent(new MessageEvent(this, jmsg, Collections.singletonList(jmsg.getTo()))); + } else { + 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.getMid()))); + } + } + } 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) { + if (xmlValue instanceof Presence) { + Presence p = (Presence) xmlValue; + if (p.getType() == null || !p.getType().equals(Presence.Type.ERROR)) { + incomingPresence(p); + } + } else if (xmlValue instanceof Message) { + Message msg = (Message) xmlValue; + ClientMessage result = incomingMessage(msg); + if (result != null) { + router.send(result); + } + } else if (xmlValue instanceof IQ) { + IQ iq = (IQ) xmlValue; + router.send(iq); + } + } + + 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/juick-server/src/main/java/com/juick/server/XMPPServer.java b/juick-server/src/main/java/com/juick/server/XMPPServer.java new file mode 100644 index 00000000..bf8ed228 --- /dev/null +++ b/juick-server/src/main/java/com/juick/server/XMPPServer.java @@ -0,0 +1,413 @@ +/* + * 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 . + */ + +package com.juick.server; + +import com.juick.server.xmpp.s2s.*; +import com.juick.service.UserService; +import com.juick.xmpp.extensions.StreamError; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +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.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringReader; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.SecureRandom; +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 + */ +@Component +public class XMPPServer implements ConnectionListener, AutoCloseable { + 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("${keystore:juick.p12}") + public String keystore; + @Value("${keystore_password:secret}") + public String keystorePassword; + @Value("${broken_ssl_hosts:}") + public String[] brokenSSLhosts; + @Value("${banned_hosts:}") + public String[] bannedHosts; + + private final List inConnections = new CopyOnWriteArrayList<>(); + private final Map> outConnections = new ConcurrentHashMap<>(); + private final List outCache = new CopyOnWriteArrayList<>(); + private final List stanzaListeners = new CopyOnWriteArrayList<>(); + private final AtomicBoolean closeFlag = new AtomicBoolean(false); + + SSLContext sc; + 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 null; + } + } + }; + private boolean tlsConfigured = false; + + + private ServerSocket listener; + + @Inject + private BasicXmppSession session; + @Inject + private UserService userService; + + @PostConstruct + public void init() throws KeyStoreException { + closeFlag.set(false); + KeyStore ks = KeyStore.getInstance("PKCS12"); + try (InputStream ksIs = new FileInputStream(keystore)) { + ks.load(ksIs, keystorePassword.toCharArray()); + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory + .getDefaultAlgorithm()); + kmf.init(ks, keystorePassword.toCharArray()); + sc = SSLContext.getInstance("TLSv1.2"); + sc.init(kmf.getKeyManagers(), trustAllCerts, new SecureRandom()); + 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); + } + }); + } + + @Override + public void close() throws Exception { + if (listener != null && !listener.isClosed()) { + listener.close(); + } + outConnections.forEach((c, s) -> { + c.logoff(); + outConnections.remove(c); + }); + inConnections.forEach(c -> { + c.closeConnection(); + inConnections.remove(c); + }); + service.shutdown(); + logger.info("XMPP server destroyed"); + } + + public void addConnectionIn(ConnectionIn c) { + c.setListener(this); + inConnections.add(c); + } + + public void addConnectionOut(ConnectionOut c, Optional 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 getConnectionOut(Jid hostname, boolean needReady) { + return outConnections.keySet().stream().filter(c -> c.to != null && + c.to.equals(hostname) && (!needReady || c.streamReady)).findFirst(); + } + + public Optional 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) { + userService.getActiveJIDs().stream().filter(j -> Jid.of(j).getDomain().equals(to)) + .forEach(j -> { + userService.setActiveStatusForJID(j, UserService.ActiveStatus.Inactive); + logger.info("{} is inactive now", j); + }); + } + }); + } + + public void startDialback(Jid from, String streamId, String dbKey) throws Exception { + Optional 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 getInConnections() { + return inConnections; + } + + public Map> getOutConnections() { + return outConnections; + } + + @Override + public boolean isTlsAvailable() { + return tlsConfigured; + } + + @Override + public void starttls(ConnectionIn connection) { + logger.debug("stream {} securing", connection.streamID); + connection.sendStanza(""); + try { + connection.setSocket(sc.getSocketFactory().createSocket(connection.getSocket(), connection.getSocket().getInetAddress().getHostAddress(), + connection.getSocket().getPort(), true)); + ((SSLSocket) connection.getSocket()).setUseClientMode(false); + ((SSLSocket) connection.getSocket()).startHandshake(); + connection.setSecured(true); + logger.debug("stream {} secured", connection.streamID); + connection.restartParser(); + } catch (XmlPullParserException | IOException sex) { + logger.warn("stream {} ssl error {}", connection.streamID, sex); + connection.sendStanza(""); + 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(), true); + ((SSLSocket) socket).startHandshake(); + connection.setSecured(true); + logger.debug("stream {} 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(""); + 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(now, c.getUpdated()).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(now, c.updated).toMinutes() > TIMEOUT_MINUTES) + .forEach(c -> { + logger.info("closing idle incoming connection from {}", c.from); + c.closeConnection(); + inConnections.remove(c); + }); + } + @PreDestroy + public void preDestroy() { + closeFlag.set(true); + } +} diff --git a/juick-server/src/main/java/com/juick/server/api/Messages.java b/juick-server/src/main/java/com/juick/server/api/Messages.java index c6600e2b..7eb86284 100644 --- a/juick-server/src/main/java/com/juick/server/api/Messages.java +++ b/juick-server/src/main/java/com/juick/server/api/Messages.java @@ -18,11 +18,9 @@ package com.juick.server.api; import com.juick.Message; -import com.juick.Status; import com.juick.Tag; import com.juick.User; import com.juick.server.util.HttpBadRequestException; -import com.juick.server.util.HttpForbiddenException; import com.juick.server.util.UserUtils; import com.juick.service.MessagesService; import com.juick.service.TagService; @@ -33,7 +31,6 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.*; -import springfox.documentation.annotations.ApiIgnore; import javax.inject.Inject; import java.io.IOException; @@ -167,20 +164,4 @@ public class Messages { } throw new HttpBadRequestException(); } - @ApiIgnore - @RequestMapping("/messages/set_privacy") - @ResponseBody - public ResponseEntity doSetPrivacy( - @RequestParam(defaultValue = "0") int mid) { - User visitor = UserUtils.getCurrentUser(); - int vuid = visitor.getUid(); - if (vuid == 0) { - throw new HttpForbiddenException(); - } - com.juick.User user = messagesService.getMessageAuthor(mid); - if (user != null && user.getUid() == vuid && messagesService.setMessagePrivacy(mid)) { - return ResponseEntity.ok(Status.OK); - } - throw new HttpForbiddenException(); - } } diff --git a/juick-server/src/main/java/com/juick/server/api/PM.java b/juick-server/src/main/java/com/juick/server/api/PM.java index a09ecc2d..3649bb5e 100644 --- a/juick-server/src/main/java/com/juick/server/api/PM.java +++ b/juick-server/src/main/java/com/juick/server/api/PM.java @@ -18,7 +18,6 @@ package com.juick.server.api; import com.juick.User; -import com.juick.server.ServerManager; import com.juick.server.component.MessageEvent; import com.juick.server.helpers.AnonymousUser; import com.juick.server.helpers.PrivateChats; @@ -31,8 +30,6 @@ 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 rocks.xmpp.addr.Jid; -import rocks.xmpp.core.stanza.model.Message; import javax.inject.Inject; import java.util.Collections; @@ -48,8 +45,6 @@ public class PM { @Inject private PMQueriesService pmQueriesService; @Inject - private ServerManager serverManager; - @Inject private ApplicationEventPublisher applicationEventPublisher; @RequestMapping(value = "/pm", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) diff --git a/juick-server/src/main/java/com/juick/server/api/Post.java b/juick-server/src/main/java/com/juick/server/api/Post.java deleted file mode 100644 index 486e9c0f..00000000 --- a/juick-server/src/main/java/com/juick/server/api/Post.java +++ /dev/null @@ -1,288 +0,0 @@ -/* - * 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 . - */ - -package com.juick.server.api; - -import com.juick.Reaction; -import com.juick.Status; -import com.juick.User; -import com.juick.server.CommandsManager; -import com.juick.server.EmailManager; -import com.juick.server.XMPPConnection; -import com.juick.server.helpers.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.codec.digest.DigestUtils; -import org.apache.commons.io.IOUtils; -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.http.MediaType; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; -import springfox.documentation.annotations.ApiIgnore; - -import javax.inject.Inject; -import javax.mail.Session; -import javax.mail.internet.InternetAddress; -import javax.mail.internet.MimeMessage; -import javax.validation.constraints.NotNull; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.net.URI; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.nio.file.Paths; -import java.util.*; - -/** - * 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 XMPPConnection xmppConnection; - @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; - @Value("${api_user:juick}") - private String serviceUser; - @Inject - CommandsManager commandsManager; - - @RequestMapping(value = "/post", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) - @ResponseStatus(value = HttpStatus.OK) - public void doPostMessage( - @RequestParam 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 == null || body.length() < 1 || 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) { - try { - URL imgUrl = new URL(img); - attachmentFName = HttpUtils.downloadImage(imgUrl, tmpDir); - } catch (Exception e) { - logger.error("DOWNLOAD ERROR", e); - throw new HttpBadRequestException(); - } - } - commandsManager.processCommand(visitor, body, attachmentFName); - } - @PostMapping("/upload") - public String doUploadFile(@RequestParam(required = true) MultipartFile attach) { - return HttpUtils.receiveMultiPartFile(attach, tmpDir).toString(); - } - - @RequestMapping(value = "/comment", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_UTF8_VALUE) - public com.juick.Message doPostComment( - @RequestParam(defaultValue = "0") int mid, - @RequestParam(defaultValue = "0") int rid, - @RequestParam 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 == null || body.length() < 1 || 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(); - } - } - - return commandsManager.processCommand(visitor, String.format("#%d/%d %s", mid, rid, body), attachmentFName).getNewMessage().get(); - } - - Session session = Session.getDefaultInstance(new Properties()); - - @ApiIgnore - @PostMapping("/mail") - @ResponseStatus(value = HttpStatus.OK) - public void processMail(InputStream data) throws Exception { - if (UserUtils.getCurrentUser().getName().equals(serviceUser)) { - MimeMessage msg = new MimeMessage(session, data); - logger.info("got msg {}", msg.toString()); - 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 { - logger.info("not registered: {}", from); - } - } else { - throw new HttpForbiddenException(); - } - } - - @PostMapping("/like") - @ResponseStatus(value = HttpStatus.OK) - public Status doPostRecomm(@RequestParam Integer mid) throws Exception { - com.juick.User visitor = UserUtils.getCurrentUser(); - if (visitor.getUid() == 0) { - 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()); - } - - @GetMapping("/reactions") - @ResponseStatus(value = HttpStatus.OK) - public List reactionsList() { - return messagesService.listReactions(); - } - - @PostMapping("/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.getUid() == 0) { - 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; - } -} diff --git a/juick-server/src/main/java/com/juick/server/api/Service.java b/juick-server/src/main/java/com/juick/server/api/Service.java new file mode 100644 index 00000000..f67f6986 --- /dev/null +++ b/juick-server/src/main/java/com/juick/server/api/Service.java @@ -0,0 +1,117 @@ +package com.juick.server.api; + +import com.juick.User; +import com.juick.server.CommandsManager; +import com.juick.server.EmailManager; +import com.juick.server.util.HttpForbiddenException; +import com.juick.server.util.UserUtils; +import com.juick.service.UserService; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.io.IOUtils; +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.PostMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +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.Properties; +import java.util.Scanner; +import java.util.UUID; + +@Controller +public class Service { + private static Logger logger = LoggerFactory.getLogger(Post.class); + @Inject + private UserService userService; + + @Inject + CommandsManager commandsManager; + @Value("${api_user:juick}") + private String serviceUser; + @Value("${upload_tmp_dir:#{systemEnvironment['TEMP'] ?: '/tmp'}}") + private String tmpDir; + Session session = Session.getDefaultInstance(new Properties()); + + @ApiIgnore + @PostMapping("/mail") + @ResponseStatus(value = HttpStatus.OK) + public void processMail(InputStream data) throws Exception { + if (UserUtils.getCurrentUser().getName().equals(serviceUser)) { + MimeMessage msg = new MimeMessage(session, data); + logger.info("got msg {}", msg.toString()); + 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 { + logger.info("not registered: {}", from); + } + } else { + throw new HttpForbiddenException(); + } + } +} diff --git a/juick-server/src/main/java/com/juick/server/configuration/ApiAppConfiguration.java b/juick-server/src/main/java/com/juick/server/configuration/ApiAppConfiguration.java index 973c31fd..91f5446a 100644 --- a/juick-server/src/main/java/com/juick/server/configuration/ApiAppConfiguration.java +++ b/juick-server/src/main/java/com/juick/server/configuration/ApiAppConfiguration.java @@ -21,7 +21,13 @@ import com.google.common.base.Predicates; import com.juick.server.WebsocketManager; import com.juick.server.api.rss.MessagesView; import com.juick.server.api.rss.RepliesView; +import com.juick.server.xmpp.JidConverter; +import com.juick.server.xmpp.iq.MessageQuery; +import com.juick.server.xmpp.s2s.BasicXmppSession; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.*; +import org.springframework.core.convert.ConversionService; +import org.springframework.format.support.DefaultFormattingConversionService; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -31,6 +37,9 @@ import org.springframework.web.socket.config.annotation.EnableWebSocket; 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 rocks.xmpp.core.session.Extension; +import rocks.xmpp.core.session.XmppSessionConfiguration; +import rocks.xmpp.core.session.debug.LogbackDebugger; import springfox.documentation.builders.PathSelectors; import springfox.documentation.builders.RequestHandlerSelectors; import springfox.documentation.service.ApiInfo; @@ -88,4 +97,20 @@ public class ApiAppConfiguration implements WebMvcConfigurer, WebSocketConfigure AbstractRssFeedView repliesView() { return new RepliesView(); } + @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) + .build(); + return BasicXmppSession.create(hostname, configuration); + } + @Bean + public static ConversionService conversionService() { + DefaultFormattingConversionService cs = new DefaultFormattingConversionService(); + cs.addConverter(new JidConverter()); + return cs; + } } diff --git a/juick-server/src/main/java/com/juick/server/configuration/ApiSecurityConfig.java b/juick-server/src/main/java/com/juick/server/configuration/ApiSecurityConfig.java deleted file mode 100644 index a065f79e..00000000 --- a/juick-server/src/main/java/com/juick/server/configuration/ApiSecurityConfig.java +++ /dev/null @@ -1,124 +0,0 @@ -/* - * 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 . - */ - -package com.juick.server.configuration; - -import com.juick.service.UserService; -import com.juick.service.security.JuickUserDetailsService; -import com.juick.service.security.NotAuthorizedAuthenticationEntryPoint; -import com.juick.service.security.deprecated.RequestParamHashRememberMeServices; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; -import org.springframework.security.authentication.dao.DaoAuthenticationProvider; -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.web.authentication.RememberMeServices; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; - -import javax.inject.Inject; -import java.util.Arrays; -import java.util.Collections; -import java.util.concurrent.TimeUnit; - -/** - * Created by aalexeev on 11/21/16. - */ -@Configuration -@EnableWebSecurity -public class ApiSecurityConfig extends WebSecurityConfigurerAdapter { - @Value("${auth_remember_me_key:secret}") - private String rememberMeKey; - @Inject - private UserService userService; - - ApiSecurityConfig() { - super(true); - } - - @Override - protected void configure(HttpSecurity http) throws Exception { - http.authorizeRequests() - .antMatchers(HttpMethod.OPTIONS).permitAll() - .antMatchers("/", "/messages", "/users", "/thread", "/tags", "/tlgmbtwbhk", "/fbwbhk", - "/skypebotendpoint").permitAll() - .anyRequest().hasRole("USER") - .and().httpBasic().authenticationEntryPoint(getJuickAuthenticationEntryPoint()) - .and().anonymous() - .and().cors().configurationSource(corsConfigurationSource()) - .and().servletApi() - .and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) - .and().exceptionHandling().authenticationEntryPoint(getJuickAuthenticationEntryPoint()) - .and() - .rememberMe() - .alwaysRemember(true) - .tokenValiditySeconds((int) TimeUnit.DAYS.toSeconds(6 * 30)) - .rememberMeServices(rememberMeServices()) - .key(rememberMeKey) - .and().authenticationProvider(authenticationProvider()) - .headers().defaultsDisabled().cacheControl(); - } - - @Bean - public DaoAuthenticationProvider authenticationProvider() { - DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider(); - - authenticationProvider.setUserDetailsService(userDetailsService()); - - return authenticationProvider; - } - - @Bean - public JuickUserDetailsService userDetailsService() { - return new JuickUserDetailsService(userService); - } - - @Bean - public RememberMeServices rememberMeServices() throws Exception { - return new RequestParamHashRememberMeServices(rememberMeKey, userService); - } - - @Bean - public NotAuthorizedAuthenticationEntryPoint getJuickAuthenticationEntryPoint() { - return new NotAuthorizedAuthenticationEntryPoint(); - } - - @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("/**", configuration); - - return source; - } - @Override - public void configure(WebSecurity web) throws Exception { - web.ignoring().antMatchers("/v2/api-docs", "/configuration/ui", "/swagger-resources/**", - "/configuration/**", "/swagger-ui.html", "/webjars/**", "/ws/**", "/rss/**"); - } -} diff --git a/juick-server/src/main/java/com/juick/server/xmpp/JidConverter.java b/juick-server/src/main/java/com/juick/server/xmpp/JidConverter.java new file mode 100644 index 00000000..e9a9707e --- /dev/null +++ b/juick-server/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 { + @Nullable + @Override + public Jid convert(String jidStr) { + return Jid.of(jidStr); + } +} diff --git a/juick-server/src/main/java/com/juick/server/xmpp/helpers/XMPPStatus.java b/juick-server/src/main/java/com/juick/server/xmpp/helpers/XMPPStatus.java new file mode 100644 index 00000000..7978ceb3 --- /dev/null +++ b/juick-server/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 . + */ + +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 inbound; + private Set outbound; + + public List getInbound() { + return inbound; + } + + public void setInbound(List inbound) { + this.inbound = inbound; + } + + public Set getOutbound() { + return outbound; + } + + public void setOutbound(Set outbound) { + this.outbound = outbound; + } +} diff --git a/juick-server/src/main/java/com/juick/server/xmpp/iq/MessageQuery.java b/juick-server/src/main/java/com/juick/server/xmpp/iq/MessageQuery.java new file mode 100644 index 00000000..7500cbf8 --- /dev/null +++ b/juick-server/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/juick-server/src/main/java/com/juick/server/xmpp/iq/package-info.java b/juick-server/src/main/java/com/juick/server/xmpp/iq/package-info.java new file mode 100644 index 00000000..dada8289 --- /dev/null +++ b/juick-server/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/juick-server/src/main/java/com/juick/server/xmpp/router/Stream.java b/juick-server/src/main/java/com/juick/server/xmpp/router/Stream.java new file mode 100644 index 00000000..7532443c --- /dev/null +++ b/juick-server/src/main/java/com/juick/server/xmpp/router/Stream.java @@ -0,0 +1,184 @@ +/* + * 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 . + */ +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; + } + + 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(); + } + + 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) { + } + } + 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(); + } +} diff --git a/juick-server/src/main/java/com/juick/server/xmpp/router/StreamComponentServer.java b/juick-server/src/main/java/com/juick/server/xmpp/router/StreamComponentServer.java new file mode 100644 index 00000000..5e2f6f82 --- /dev/null +++ b/juick-server/src/main/java/com/juick/server/xmpp/router/StreamComponentServer.java @@ -0,0 +1,58 @@ +package com.juick.server.xmpp.router; + +import com.juick.xmpp.extensions.Handshake; +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("", 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(); + } +} diff --git a/juick-server/src/main/java/com/juick/server/xmpp/router/StreamError.java b/juick-server/src/main/java/com/juick/server/xmpp/router/StreamError.java new file mode 100644 index 00000000..7eacfc94 --- /dev/null +++ b/juick-server/src/main/java/com/juick/server/xmpp/router/StreamError.java @@ -0,0 +1,44 @@ +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; + + public StreamError() {} + + public StreamError(String condition) { + this.condition = condition; + } + + public static StreamError parse(XmlPullParser parser) throws IOException, XmlPullParserException { + StreamError streamError = new StreamError(); + while (parser.next() == XmlPullParser.START_TAG) { + final String tag = parser.getName(); + final String xmlns = parser.getNamespace(); + if (xmlns.equals(StreamNamespaces.NS_XMPP_STREAMS)) { + streamError.condition = tag; + } else { + XmlUtils.skip(parser); + } + } + return streamError; + } + + public String getCondition() { + return condition; + } + + @Override + public String toString() { + return String.format("<%s xmlns='%s'/>", condition, StreamNamespaces.NS_XMPP_STREAMS); + } +} diff --git a/juick-server/src/main/java/com/juick/server/xmpp/router/StreamHandler.java b/juick-server/src/main/java/com/juick/server/xmpp/router/StreamHandler.java new file mode 100644 index 00000000..43836c2d --- /dev/null +++ b/juick-server/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(); + void fail(final Exception ex); + boolean filter(Jid from, Jid to); + void stanzaReceived(String stanza); +} diff --git a/juick-server/src/main/java/com/juick/server/xmpp/router/StreamNamespaces.java b/juick-server/src/main/java/com/juick/server/xmpp/router/StreamNamespaces.java new file mode 100644 index 00000000..1b9b1965 --- /dev/null +++ b/juick-server/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/juick-server/src/main/java/com/juick/server/xmpp/router/XMPPError.java b/juick-server/src/main/java/com/juick/server/xmpp/router/XMPPError.java new file mode 100644 index 00000000..0cf9a3bc --- /dev/null +++ b/juick-server/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 . + */ +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(""); + } else { + str.append("/>"); + } + str.append(""); + } else { + str.append("/>"); + } + + return str.toString(); + } +} diff --git a/juick-server/src/main/java/com/juick/server/xmpp/router/XMPPRouter.java b/juick-server/src/main/java/com/juick/server/xmpp/router/XMPPRouter.java new file mode 100644 index 00000000..6edecf05 --- /dev/null +++ b/juick-server/src/main/java/com/juick/server/xmpp/router/XMPPRouter.java @@ -0,0 +1,189 @@ +package com.juick.server.xmpp.router; + +import com.juick.server.XMPPServer; +import com.juick.server.xmpp.s2s.BasicXmppSession; +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.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.ExecutorService; + +@Component +public class XMPPRouter implements StreamHandler { + private static final Logger logger = LoggerFactory.getLogger(XMPPRouter.class); + + @Inject + private ExecutorService service; + + private final List connections = Collections.synchronizedList(new ArrayList<>()); + + 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 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; + } + } + } + } + if (connOut != null) { + connOut.send(xml); + return; + } + xmppServer.sendOut(Jid.of(hostname), xml); + + } + + public List 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)); + } + } + + @Override + public void ready() { + + } + + @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/juick-server/src/main/java/com/juick/server/xmpp/router/XmlUtils.java b/juick-server/src/main/java/com/juick/server/xmpp/router/XmlUtils.java new file mode 100644 index 00000000..7579489f --- /dev/null +++ b/juick-server/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 . + */ +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(""); + return ret.toString(); + } +} diff --git a/juick-server/src/main/java/com/juick/server/xmpp/s2s/BasicXmppSession.java b/juick-server/src/main/java/com/juick/server/xmpp/s2s/BasicXmppSession.java new file mode 100644 index 00000000..647f2717 --- /dev/null +++ b/juick-server/src/main/java/com/juick/server/xmpp/s2s/BasicXmppSession.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 . + */ + +package com.juick.server.xmpp.s2s; + +import rocks.xmpp.addr.Jid; +import rocks.xmpp.core.XmppException; +import rocks.xmpp.core.session.ConnectionConfiguration; +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, ConnectionConfiguration... connectionConfigurations) { + super(xmppServiceDomain, configuration, connectionConfigurations); + } + + 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/juick-server/src/main/java/com/juick/server/xmpp/s2s/CacheEntry.java b/juick-server/src/main/java/com/juick/server/xmpp/s2s/CacheEntry.java new file mode 100644 index 00000000..33e875bd --- /dev/null +++ b/juick-server/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 . + */ + +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/juick-server/src/main/java/com/juick/server/xmpp/s2s/Connection.java b/juick-server/src/main/java/com/juick/server/xmpp/s2s/Connection.java new file mode 100644 index 00000000..6bf61169 --- /dev/null +++ b/juick-server/src/main/java/com/juick/server/xmpp/s2s/Connection.java @@ -0,0 +1,139 @@ +/* + * 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 . + */ + +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_STREAM = "http://etherx.jabber.org/streams"; + XmlPullParserFactory factory = XmlPullParserFactory.newInstance(); + XmlPullParser parser = factory.newPullParser(); + OutputStreamWriter writer; + private boolean secured = 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 += ">...\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(""); + } 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; + } +} diff --git a/juick-server/src/main/java/com/juick/server/xmpp/s2s/ConnectionIn.java b/juick-server/src/main/java/com/juick/server/xmpp/s2s/ConnectionIn.java new file mode 100644 index 00000000..9ee81d4d --- /dev/null +++ b/juick-server/src/main/java/com/juick/server/xmpp/s2s/ConnectionIn.java @@ -0,0 +1,213 @@ +/* + * 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 . + */ + +package com.juick.server.xmpp.s2s; + +import com.juick.server.XMPPServer; +import com.juick.xmpp.extensions.StreamError; +import com.juick.xmpp.utils.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; + +/** + * @author ugnich + */ +public class ConnectionIn extends Connection implements Runnable { + + final public List 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(""); + logger.debug("stream from {} {} dialback verify valid", vfrom, streamID); + } else { + sendStanza(""); + logger.warn("stream from {} {} dialback verify invalid", vfrom, streamID); + } + } else if (tag.equals("presence") && checkFromTo(parser)) { + 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)) { + 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")) { + listener.starttls(this); + } else if (isSecured() && tag.equals("stream") && parser.getNamespace().equals(NS_STREAM)) { + sendOpenStream(null, true); + } else if (tag.equals("error")) { + StreamError streamError = StreamError.parse(parser); + logger.debug("Stream error from {}: {}", streamID, streamError.getCondition()); + 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 = ""; + if (xmppversionnew) { + openStream += ""; + if (listener != null && listener.isTlsAvailable() && !isSecured() && !Arrays.asList(xmpp.brokenSSLhosts).contains(from)) { + openStream += ""; + } + openStream += ""; + } + sendStanza(openStream); + } + + public void sendDialbackResult(Jid sfrom, String type) { + sendStanza(""); + if (type.equals("valid")) { + from.add(sfrom); + logger.debug("stream from {} {} ready", sfrom, streamID); + } + } + + 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; + } + } + } + return false; + } + public void setListener(ConnectionListener listener) { + this.listener = listener; + } +} diff --git a/juick-server/src/main/java/com/juick/server/xmpp/s2s/ConnectionListener.java b/juick-server/src/main/java/com/juick/server/xmpp/s2s/ConnectionListener.java new file mode 100644 index 00000000..fde7a0e7 --- /dev/null +++ b/juick-server/src/main/java/com/juick/server/xmpp/s2s/ConnectionListener.java @@ -0,0 +1,15 @@ +package com.juick.server.xmpp.s2s; + +import com.juick.xmpp.extensions.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/juick-server/src/main/java/com/juick/server/xmpp/s2s/ConnectionOut.java b/juick-server/src/main/java/com/juick/server/xmpp/s2s/ConnectionOut.java new file mode 100644 index 00000000..e3bd53e9 --- /dev/null +++ b/juick-server/src/main/java/com/juick/server/xmpp/s2s/ConnectionOut.java @@ -0,0 +1,167 @@ +/* + * 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 . + */ + +package com.juick.server.xmpp.s2s; + +import com.juick.server.xmpp.s2s.util.DialbackUtils; +import com.juick.xmpp.Stream; +import com.juick.xmpp.extensions.StreamError; +import com.juick.xmpp.extensions.StreamFeatures; +import com.juick.xmpp.utils.XmlUtils; +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; + +/** + * @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; + + 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(""); + } + + void processDialback() throws Exception { + if (checkSID != null) { + sendDialbackVerify(checkSID, dbKey); + } + send("" + + dbKey + ""); + } + + @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(""); + } else { + processDialback(); + } + } else if (tag.equals("proceed") && parser.getNamespace().equals(NS_TLS)) { + listener.proceed(this); + } 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, true); + 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("" + + key + ""); + } + 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; + } +} diff --git a/juick-server/src/main/java/com/juick/server/xmpp/s2s/DNSQueries.java b/juick-server/src/main/java/com/juick/server/xmpp/s2s/DNSQueries.java new file mode 100644 index 00000000..1367d333 --- /dev/null +++ b/juick-server/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 . + */ + +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 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/juick-server/src/main/java/com/juick/server/xmpp/s2s/StanzaListener.java b/juick-server/src/main/java/com/juick/server/xmpp/s2s/StanzaListener.java new file mode 100644 index 00000000..6932298f --- /dev/null +++ b/juick-server/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 . + */ + +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/juick-server/src/main/java/com/juick/server/xmpp/s2s/util/DialbackUtils.java b/juick-server/src/main/java/com/juick/server/xmpp/s2s/util/DialbackUtils.java new file mode 100644 index 00000000..d25dbad8 --- /dev/null +++ b/juick-server/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 . + */ + +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); + } +} -- cgit v1.2.3