diff --git a/src/main/java/io/kamax/mxisd/HttpMxisd.java b/src/main/java/io/kamax/mxisd/HttpMxisd.java index b6e34bc..0df6252 100644 --- a/src/main/java/io/kamax/mxisd/HttpMxisd.java +++ b/src/main/java/io/kamax/mxisd/HttpMxisd.java @@ -26,6 +26,7 @@ import io.kamax.mxisd.http.undertow.handler.OptionsHandler; import io.kamax.mxisd.http.undertow.handler.SaneHandler; import io.kamax.mxisd.http.undertow.handler.as.v1.AsNotFoundHandler; import io.kamax.mxisd.http.undertow.handler.as.v1.AsTransactionHandler; +import io.kamax.mxisd.http.undertow.handler.as.v1.AsUserHandler; import io.kamax.mxisd.http.undertow.handler.auth.RestAuthHandler; import io.kamax.mxisd.http.undertow.handler.auth.v1.LoginGetHandler; import io.kamax.mxisd.http.undertow.handler.auth.v1.LoginHandler; @@ -66,8 +67,11 @@ public class HttpMxisd { m.start(); HttpHandler helloHandler = SaneHandler.around(new HelloHandler()); - HttpHandler asNotFoundHandler = SaneHandler.around(new AsNotFoundHandler(m.getAs())); + + HttpHandler asUserHandler = SaneHandler.around(new AsUserHandler(m.getAs())); HttpHandler asTxnHandler = SaneHandler.around(new AsTransactionHandler(m.getAs())); + HttpHandler asNotFoundHandler = SaneHandler.around(new AsNotFoundHandler(m.getAs())); + HttpHandler storeInvHandler = SaneHandler.around(new StoreInviteHandler(m.getConfig().getServer(), m.getInvitationManager(), m.getKeyManager())); HttpHandler sessValidateHandler = SaneHandler.around(new SessionValidateHandler(m.getSession(), m.getConfig().getServer(), m.getConfig().getView())); @@ -117,11 +121,12 @@ public class HttpMxisd { .post(RoomInviteHandler.Path, SaneHandler.around(new RoomInviteHandler(m.getHttpClient(), m.getClientDns(), m.getInvitationManager()))) // Application Service endpoints - .get("/_matrix/app/v1/users/**", asNotFoundHandler) - .get("/users/**", asNotFoundHandler) // Legacy endpoint + .get(AsUserHandler.Path, asUserHandler) .get("/_matrix/app/v1/rooms/**", asNotFoundHandler) - .get("/rooms/**", asNotFoundHandler) // Legacy endpoint .put(AsTransactionHandler.Path, asTxnHandler) + + .get("/users/{" + AsUserHandler.ID + "}", asUserHandler) // Legacy endpoint + .get("/rooms/**", asNotFoundHandler) // Legacy endpoint .put("/transactions/{" + AsTransactionHandler.ID + "}", asTxnHandler) // Legacy endpoint // Banned endpoints diff --git a/src/main/java/io/kamax/mxisd/Mxisd.java b/src/main/java/io/kamax/mxisd/Mxisd.java index 6eb47ed..4908eae 100644 --- a/src/main/java/io/kamax/mxisd/Mxisd.java +++ b/src/main/java/io/kamax/mxisd/Mxisd.java @@ -59,7 +59,9 @@ import java.util.ServiceLoader; public class Mxisd { + public static final String Name = StringUtils.defaultIfBlank(Mxisd.class.getPackage().getImplementationTitle(), "mxisd"); public static final String Version = StringUtils.defaultIfBlank(Mxisd.class.getPackage().getImplementationVersion(), "UNKNOWN"); + public static final String Agent = Name + "/" + Version; private MxisdConfig cfg; @@ -89,7 +91,7 @@ public class Mxisd { private void build() { httpClient = HttpClients.custom() - .setUserAgent("mxisd/" + Version) + .setUserAgent(Agent) .setMaxConnPerRoute(Integer.MAX_VALUE) .setMaxConnTotal(Integer.MAX_VALUE) .build(); diff --git a/src/main/java/io/kamax/mxisd/as/AppSvcManager.java b/src/main/java/io/kamax/mxisd/as/AppSvcManager.java index 79c7a01..6d6260f 100644 --- a/src/main/java/io/kamax/mxisd/as/AppSvcManager.java +++ b/src/main/java/io/kamax/mxisd/as/AppSvcManager.java @@ -23,11 +23,16 @@ package io.kamax.mxisd.as; import com.google.gson.JsonObject; import io.kamax.matrix.MatrixID; import io.kamax.matrix._MatrixID; +import io.kamax.matrix.client.MatrixClientContext; +import io.kamax.matrix.client.as.MatrixApplicationServiceClient; import io.kamax.matrix.event.EventKey; import io.kamax.matrix.json.GsonUtil; +import io.kamax.mxisd.as.processor.MembershipEventProcessor; +import io.kamax.mxisd.as.processor.MessageEventProcessor; import io.kamax.mxisd.backend.sql.synapse.Synapse; -import io.kamax.mxisd.config.MatrixConfig; +import io.kamax.mxisd.config.AppServiceConfig; import io.kamax.mxisd.config.MxisdConfig; +import io.kamax.mxisd.exception.ConfigurationException; import io.kamax.mxisd.exception.HttpMatrixException; import io.kamax.mxisd.exception.NotAllowedException; import io.kamax.mxisd.notification.NotificationManager; @@ -36,6 +41,7 @@ import io.kamax.mxisd.storage.IStorage; import io.kamax.mxisd.storage.ormlite.dao.ASTransactionDao; import io.kamax.mxisd.util.GsonParser; import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -54,65 +60,145 @@ import java.util.concurrent.ConcurrentHashMap; public class AppSvcManager { - private transient final Logger log = LoggerFactory.getLogger(AppSvcManager.class); + private static final Logger log = LoggerFactory.getLogger(AppSvcManager.class); - private final GsonParser parser; + private final AppServiceConfig cfg; + private final IStorage store; + private final GsonParser parser = new GsonParser(); - private MatrixConfig cfg; - private IStorage store; + private MatrixApplicationServiceClient client; private Map processors = new HashMap<>(); + private Map> transactionsInProgress = new ConcurrentHashMap<>(); - private Map> transactionsInProgress; - - public AppSvcManager(MxisdConfig cfg, IStorage store, ProfileManager profiler, NotificationManager notif, Synapse synapse) { - this.cfg = cfg.getMatrix(); + public AppSvcManager(MxisdConfig mxisdCfg, IStorage store, ProfileManager profiler, NotificationManager notif, Synapse synapse) { + this.cfg = mxisdCfg.getAppsvc(); this.store = store; - parser = new GsonParser(); - transactionsInProgress = new ConcurrentHashMap<>(); + /* + We process the configuration to make sure all is fine and setting default values if needed + */ - processors.put("m.room.member", new MembershipProcessor(cfg.getMatrix(), profiler, notif, synapse)); + // By default, the feature is enabled + cfg.setEnabled(ObjectUtils.defaultIfNull(cfg.isEnabled(), false)); - processConfig(); + if (!cfg.isEnabled()) { + return; + } + + if (Objects.isNull(cfg.getEndpoint().getToAS().getUrl())) { + throw new ConfigurationException("App Service: Endpoint: To AS: URL"); + } + + if (Objects.isNull(cfg.getEndpoint().getToAS().getToken())) { + throw new ConfigurationException("App Service: Endpoint: To AS: Token", "Must be set, even if to an empty string"); + } + + if (Objects.isNull(cfg.getEndpoint().getToHS().getUrl())) { + throw new ConfigurationException("App Service: Endpoint: To HS: URL"); + } + + if (Objects.isNull(cfg.getEndpoint().getToHS().getToken())) { + throw new ConfigurationException("App Service: Endpoint: To HS: Token", "Must be set, even if to an empty string"); + } + + // We set a default status for each feature individually + cfg.getFeature().getAdmin().setEnabled(ObjectUtils.defaultIfNull(cfg.getFeature().getAdmin().getEnabled(), cfg.isEnabled())); + cfg.getFeature().setCleanExpiredInvite(ObjectUtils.defaultIfNull(cfg.getFeature().getCleanExpiredInvite(), cfg.isEnabled())); + cfg.getFeature().setInviteById(ObjectUtils.defaultIfNull(cfg.getFeature().getInviteById(), false)); + + if (cfg.getFeature().getAdmin().getEnabled()) { + if (StringUtils.isBlank(cfg.getUser().getMain())) { + throw new ConfigurationException("App Service admin feature is enabled, but no main user configured"); + } + + if (cfg.getUser().getMain().startsWith("@") || cfg.getUser().getMain().contains(":")) { + throw new ConfigurationException("App Service: Users: Main ID: Is not a localpart"); + } + } + + if (cfg.getFeature().getCleanExpiredInvite()) { + if (StringUtils.isBlank(cfg.getUser().getInviteExpired())) { + throw new ConfigurationException("App Service user for Expired Invite is not set"); + } + + if (cfg.getUser().getMain().startsWith("@") || cfg.getUser().getMain().contains(":")) { + throw new ConfigurationException("App Service: Users: Expired Invite ID: Is not a localpart"); + } + } + + MatrixClientContext mxContext = new MatrixClientContext(); + mxContext.setDomain(mxisdCfg.getMatrix().getDomain()); + mxContext.setToken(cfg.getEndpoint().getToHS().getToken()); + mxContext.setHsBaseUrl(cfg.getEndpoint().getToHS().getUrl()); + client = new MatrixApplicationServiceClient(mxContext); + + processors.put("m.room.member", new MembershipEventProcessor(client, mxisdCfg, profiler, notif, synapse)); + processors.put("m.room.message", new MessageEventProcessor(client)); + + processSynapseConfig(mxisdCfg); } - private void processConfig() { - String synapseRegFile = cfg.getListener().getSynapse().getRegistrationFile(); - if (StringUtils.isNotBlank(synapseRegFile)) { - SynapseRegistrationYaml syncCfg = SynapseRegistrationYaml.parse(cfg.getListener()); + private void processSynapseConfig(MxisdConfig cfg) { + String synapseRegFile = cfg.getAppsvc().getRegistration().getSynapse().getFile(); - Representer rep = new Representer(); - rep.getPropertyUtils().setBeanAccess(BeanAccess.FIELD); - Yaml yaml = new Yaml(rep); - String synCfgRaw = yaml.dump(syncCfg); + if (StringUtils.isBlank(synapseRegFile)) { + log.info("No synapse registration file path given - skipping generation..."); + return; + } - try { - IOUtils.write(synCfgRaw, new FileOutputStream(synapseRegFile), StandardCharsets.UTF_8); - } catch (IOException e) { - throw new RuntimeException("Unable to write synapse appservice registration file", e); - } + SynapseRegistrationYaml syncCfg = SynapseRegistrationYaml.parse(cfg.getAppsvc(), cfg.getMatrix().getDomain()); + + Representer rep = new Representer(); + rep.getPropertyUtils().setBeanAccess(BeanAccess.FIELD); + Yaml yaml = new Yaml(rep); + + // SnakeYAML set the type of object on the first line, which can fail to be parsed on synapse + // We therefore need to split the resulting string, remove the first line, and then write it + List lines = new ArrayList<>(Arrays.asList(yaml.dump(syncCfg).split("\\R+"))); + if (StringUtils.equals(lines.get(0), "!!" + SynapseRegistrationYaml.class.getCanonicalName())) { + lines.remove(0); + } + + try (FileOutputStream os = new FileOutputStream(synapseRegFile)) { + IOUtils.writeLines(lines, System.lineSeparator(), os, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new RuntimeException("Unable to write synapse appservice registration file", e); + } + } + + private void ensureEnabled() { + if (!cfg.isEnabled()) { + throw new HttpMatrixException(503, "M_NOT_AVAILABLE", "This feature is disabled"); } } public AppSvcManager withToken(String token) { + ensureEnabled(); + if (StringUtils.isBlank(token)) { throw new HttpMatrixException(401, "M_UNAUTHORIZED", "No HS token"); } - if (!StringUtils.equals(cfg.getListener().getToken().getHs(), token)) { + if (!StringUtils.equals(cfg.getEndpoint().getToAS().getToken(), token)) { throw new NotAllowedException("Invalid HS token"); } return this; } + public void processUser(String userId) { + client.createUser(MatrixID.asAcceptable(userId).getLocalPart()); + } + public CompletableFuture processTransaction(String txnId, InputStream is) { + ensureEnabled(); + if (StringUtils.isEmpty(txnId)) { throw new IllegalArgumentException("Transaction ID cannot be empty"); } synchronized (this) { - Optional dao = store.getTransactionResult(cfg.getListener().getLocalpart(), txnId); + Optional dao = store.getTransactionResult(cfg.getUser().getMain(), txnId); if (dao.isPresent()) { log.info("AS Transaction {} already processed - returning computed result", txnId); return CompletableFuture.completedFuture(dao.get().getResult()); @@ -143,7 +229,7 @@ public class AppSvcManager { try { log.info("Saving transaction details to store"); - store.insertTransactionResult(cfg.getListener().getLocalpart(), txnId, end, result); + store.insertTransactionResult(cfg.getUser().getMain(), txnId, end, result); } finally { log.debug("Removing CompletedFuture from transaction map"); transactionsInProgress.remove(txnId); diff --git a/src/main/java/io/kamax/mxisd/as/MembershipProcessor.java b/src/main/java/io/kamax/mxisd/as/MembershipProcessor.java deleted file mode 100644 index 2e9b3cf..0000000 --- a/src/main/java/io/kamax/mxisd/as/MembershipProcessor.java +++ /dev/null @@ -1,110 +0,0 @@ -/* - * mxisd - Matrix Identity Server Daemon - * Copyright (C) 2019 Kamax Sarl - * - * https://www.kamax.io/ - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public 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 io.kamax.mxisd.as; - -import com.google.gson.JsonObject; -import io.kamax.matrix.MatrixID; -import io.kamax.matrix.ThreePidMedium; -import io.kamax.matrix._MatrixID; -import io.kamax.matrix._ThreePid; -import io.kamax.matrix.event.EventKey; -import io.kamax.mxisd.backend.sql.synapse.Synapse; -import io.kamax.mxisd.config.MatrixConfig; -import io.kamax.mxisd.notification.NotificationManager; -import io.kamax.mxisd.profile.ProfileManager; -import org.apache.commons.lang.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -public class MembershipProcessor implements EventTypeProcessor { - - private final static Logger log = LoggerFactory.getLogger(MembershipProcessor.class); - - private final MatrixConfig cfg; - private ProfileManager profiler; - private NotificationManager notif; - private Synapse synapse; - - public MembershipProcessor(MatrixConfig cfg, ProfileManager profiler, NotificationManager notif, Synapse synapse) { - this.cfg = cfg; - this.profiler = profiler; - this.notif = notif; - this.synapse = synapse; - } - - @Override - public void process(JsonObject ev, _MatrixID sender, String roomId) { - JsonObject content = EventKey.Content.findObj(ev).orElseGet(() -> { - log.debug("No content found, falling back to full object"); - return ev; - }); - - if (!StringUtils.equals("invite", EventKey.Membership.getStringOrNull(content))) { - log.debug("This is not an invite event, skipping"); - return; - } - - String inviteeId = EventKey.StateKey.getStringOrNull(ev); - if (StringUtils.isBlank(inviteeId)) { - log.warn("Invalid event: No invitee ID, skipping"); - return; - } - - _MatrixID invitee = MatrixID.asAcceptable(inviteeId); - if (!StringUtils.equals(invitee.getDomain(), cfg.getDomain())) { - log.debug("Ignoring invite for {}: not a local user"); - return; - } - - log.info("Got invite from {} to {}", sender.getId(), inviteeId); - - boolean wasSent = false; - List<_ThreePid> tpids = profiler.getThreepids(invitee).stream() - .filter(tpid -> ThreePidMedium.Email.is(tpid.getMedium())) - .collect(Collectors.toList()); - log.info("Found {} email(s) in identity store for {}", tpids.size(), inviteeId); - - for (_ThreePid tpid : tpids) { - log.info("Found Email to notify about room invitation: {}", tpid.getAddress()); - Map properties = new HashMap<>(); - profiler.getDisplayName(sender).ifPresent(name -> properties.put("sender_display_name", name)); - try { - synapse.getRoomName(roomId).ifPresent(name -> properties.put("room_name", name)); - } catch (RuntimeException e) { - log.warn("Could not fetch room name", e); - log.info("Unable to fetch room name: Did you integrate your Homeserver as documented?"); - } - - IMatrixIdInvite inv = new MatrixIdInvite(roomId, sender, invitee, tpid.getMedium(), tpid.getAddress(), properties); - notif.sendForInvite(inv); - log.info("Notification for invite of {} sent to {}", inviteeId, tpid.getAddress()); - wasSent = true; - } - - log.info("Was notification sent? {}", wasSent); - } - -} diff --git a/src/main/java/io/kamax/mxisd/as/SynapseRegistrationYaml.java b/src/main/java/io/kamax/mxisd/as/SynapseRegistrationYaml.java index 370fedb..5ab3988 100644 --- a/src/main/java/io/kamax/mxisd/as/SynapseRegistrationYaml.java +++ b/src/main/java/io/kamax/mxisd/as/SynapseRegistrationYaml.java @@ -20,7 +20,7 @@ package io.kamax.mxisd.as; -import io.kamax.mxisd.config.ListenerConfig; +import io.kamax.mxisd.config.AppServiceConfig; import java.net.URL; import java.util.ArrayList; @@ -29,20 +29,28 @@ import java.util.Objects; public class SynapseRegistrationYaml { - public static SynapseRegistrationYaml parse(ListenerConfig cfg) { + public static SynapseRegistrationYaml parse(AppServiceConfig cfg, String domain) { SynapseRegistrationYaml yaml = new SynapseRegistrationYaml(); - yaml.setId("appservice-mxisd"); - yaml.setUrl(cfg.getUrl()); - yaml.setAsToken(cfg.getToken().getAs()); - yaml.setHsToken(cfg.getToken().getHs()); - yaml.setSenderLocalpart(cfg.getLocalpart()); - cfg.getUsers().forEach(template -> { + yaml.setId(cfg.getRegistration().getSynapse().getId()); + yaml.setUrl(cfg.getEndpoint().getToAS().getUrl()); + yaml.setAsToken(cfg.getEndpoint().getToHS().getToken()); + yaml.setHsToken(cfg.getEndpoint().getToAS().getToken()); + yaml.setSenderLocalpart(cfg.getUser().getMain()); + + if (cfg.getFeature().getCleanExpiredInvite()) { Namespace ns = new Namespace(); - ns.setRegex(template.getTemplate()); ns.setExclusive(true); + ns.setRegex("@" + cfg.getUser().getInviteExpired() + ":" + domain); yaml.getNamespaces().getUsers().add(ns); - }); + } + + if (cfg.getFeature().getInviteById()) { + Namespace ns = new Namespace(); + ns.setExclusive(false); + ns.setRegex("@*:" + domain); + yaml.getNamespaces().getUsers().add(ns); + } return yaml; } diff --git a/src/main/java/io/kamax/mxisd/as/processor/MembershipEventProcessor.java b/src/main/java/io/kamax/mxisd/as/processor/MembershipEventProcessor.java new file mode 100644 index 0000000..009611a --- /dev/null +++ b/src/main/java/io/kamax/mxisd/as/processor/MembershipEventProcessor.java @@ -0,0 +1,176 @@ +/* + * mxisd - Matrix Identity Server Daemon + * Copyright (C) 2019 Kamax Sarl + * + * https://www.kamax.io/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public 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 io.kamax.mxisd.as.processor; + +import com.google.gson.JsonObject; +import io.kamax.matrix.MatrixID; +import io.kamax.matrix.ThreePidMedium; +import io.kamax.matrix._MatrixID; +import io.kamax.matrix._ThreePid; +import io.kamax.matrix.client.as.MatrixApplicationServiceClient; +import io.kamax.matrix.event.EventKey; +import io.kamax.matrix.hs._MatrixRoom; +import io.kamax.mxisd.as.EventTypeProcessor; +import io.kamax.mxisd.as.IMatrixIdInvite; +import io.kamax.mxisd.as.MatrixIdInvite; +import io.kamax.mxisd.backend.sql.synapse.Synapse; +import io.kamax.mxisd.config.MxisdConfig; +import io.kamax.mxisd.notification.NotificationManager; +import io.kamax.mxisd.profile.ProfileManager; +import org.apache.commons.lang.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class MembershipEventProcessor implements EventTypeProcessor { + + private final static Logger log = LoggerFactory.getLogger(MembershipEventProcessor.class); + + private MatrixApplicationServiceClient client; + + private final MxisdConfig cfg; + private ProfileManager profiler; + private NotificationManager notif; + private Synapse synapse; + + public MembershipEventProcessor( + MatrixApplicationServiceClient client, + MxisdConfig cfg, + ProfileManager profiler, + NotificationManager notif, + Synapse synapse + ) { + this.client = client; + this.cfg = cfg; + this.profiler = profiler; + this.notif = notif; + this.synapse = synapse; + } + + @Override + public void process(JsonObject ev, _MatrixID sender, String roomId) { + JsonObject content = EventKey.Content.findObj(ev).orElseGet(() -> { + log.debug("No content found, falling back to full object"); + return ev; + }); + + String targetId = EventKey.StateKey.getStringOrNull(ev); + if (StringUtils.isBlank(targetId)) { + log.warn("Invalid event: No invitee ID, skipping"); + return; + } + + _MatrixID target = MatrixID.asAcceptable(targetId); + if (!StringUtils.equals(target.getDomain(), cfg.getMatrix().getDomain())) { + log.debug("Ignoring invite for {}: not a local user"); + return; + } + + log.info("Got membership event from {} to {} for room {}", sender.getId(), targetId, roomId); + + boolean isForMainUser = StringUtils.equals(target.getLocalPart(), cfg.getAppsvc().getUser().getMain()); + boolean isForExpInvUser = StringUtils.equals(target.getLocalPart(), cfg.getAppsvc().getUser().getInviteExpired()); + boolean isUs = isForMainUser || isForExpInvUser; + + if (StringUtils.equals("join", EventKey.Membership.getStringOrNull(content))) { + if (!isForMainUser) { + log.warn("We joined the room {} for another identity as the main user, which is not supported. Leaving...", roomId); + + client.getUser(target.getLocalPart()).getRoom(roomId).tryLeave().ifPresent(err -> { + log.warn("Could not decline invite to room {}: {} - {}", roomId, err.getErrcode(), err.getError()); + }); + } + } else if (StringUtils.equals("invite", EventKey.Membership.getStringOrNull(content))) { + if (isForMainUser) { + processForMainUser(roomId, sender); + } else if (isForExpInvUser) { + processForExpiredInviteUser(roomId, target); + } else { + processForUserIdInvite(roomId, sender, target); + } + } else if (StringUtils.equals("leave", EventKey.Membership.getStringOrNull(content))) { + _MatrixRoom room = client.getRoom(roomId); + if (!isUs && room.getJoinedUsers().size() == 1) { + // TODO we need to find out if this is only us remaining and leave the room if so, using the right client for it + } + } else { + log.debug("This is not an supported type of membership event, skipping"); + } + } + + private void processForMainUser(String roomId, _MatrixID sender) { + List roles = profiler.getRoles(sender); + if (Collections.disjoint(roles, cfg.getAppsvc().getFeature().getAdmin().getAllowedRoles())) { + log.info("Sender does not have any of the required roles, denying"); + client.getRoom(roomId).tryLeave().ifPresent(err -> { + log.warn("Could not decline invite to room {}: {} - {}", roomId, err.getErrcode(), err.getError()); + }); + } else { + client.getRoom(roomId).tryJoin().ifPresent(err -> { + log.warn("Could not join room {}: {} - {}", roomId, err.getErrcode(), err.getError()); + client.getRoom(roomId).tryLeave().ifPresent(err1 -> { + log.warn("Could not decline invite to room {} after failed join: {} - {}", roomId, err1.getErrcode(), err1.getError()); + }); + }); + } + } + + private void processForExpiredInviteUser(String roomId, _MatrixID invitee) { + client.getUser(invitee.getLocalPart()).getRoom(roomId).tryLeave().ifPresent(err -> { + log.warn("Could not decline invite to room {}: {} - {}", roomId, err.getErrcode(), err.getError()); + }); + } + + private void processForUserIdInvite(String roomId, _MatrixID sender, _MatrixID invitee) { + String inviteeId = invitee.getId(); + + boolean wasSent = false; + List<_ThreePid> tpids = profiler.getThreepids(invitee).stream() + .filter(tpid -> ThreePidMedium.Email.is(tpid.getMedium())) + .collect(Collectors.toList()); + log.info("Found {} email(s) in identity store for {}", tpids.size(), inviteeId); + + for (_ThreePid tpid : tpids) { + log.info("Found Email to notify about room invitation: {}", tpid.getAddress()); + Map properties = new HashMap<>(); + profiler.getDisplayName(sender).ifPresent(name -> properties.put("sender_display_name", name)); + try { + synapse.getRoomName(roomId).ifPresent(name -> properties.put("room_name", name)); + } catch (RuntimeException e) { + log.warn("Could not fetch room name", e); + log.info("Unable to fetch room name: Did you integrate your Homeserver as documented?"); + } + + IMatrixIdInvite inv = new MatrixIdInvite(roomId, sender, invitee, tpid.getMedium(), tpid.getAddress(), properties); + notif.sendForInvite(inv); + log.info("Notification for invite of {} sent to {}", inviteeId, tpid.getAddress()); + wasSent = true; + } + + log.info("Was notification sent? {}", wasSent); + } + +} diff --git a/src/main/java/io/kamax/mxisd/as/processor/MessageEventProcessor.java b/src/main/java/io/kamax/mxisd/as/processor/MessageEventProcessor.java new file mode 100644 index 0000000..8053943 --- /dev/null +++ b/src/main/java/io/kamax/mxisd/as/processor/MessageEventProcessor.java @@ -0,0 +1,83 @@ +/* + * mxisd - Matrix Identity Server Daemon + * Copyright (C) 2019 Kamax Sarl + * + * https://www.kamax.io/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public 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 io.kamax.mxisd.as.processor; + +import com.google.gson.JsonObject; +import io.kamax.matrix._MatrixID; +import io.kamax.matrix._MatrixUserProfile; +import io.kamax.matrix.client.as.MatrixApplicationServiceClient; +import io.kamax.matrix.hs._MatrixRoom; +import io.kamax.matrix.json.event.MatrixJsonRoomMessageEvent; +import io.kamax.mxisd.as.EventTypeProcessor; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.stream.Collectors; + +public class MessageEventProcessor implements EventTypeProcessor { + + private static final Logger log = LoggerFactory.getLogger(MessageEventProcessor.class); + + private final MatrixApplicationServiceClient client; + + public MessageEventProcessor(MatrixApplicationServiceClient client) { + this.client = client; + } + + @Override + public void process(JsonObject ev, _MatrixID sender, String roomId) { + _MatrixRoom room = client.getRoom(roomId); + List<_MatrixID> joinedUsers = room.getJoinedUsers().stream().map(_MatrixUserProfile::getId).collect(Collectors.toList()); + boolean joinedWithMainUser = joinedUsers.contains(client.getWhoAmI()); + boolean isAdminPrivate = joinedWithMainUser && joinedUsers.size() == 2; + + MatrixJsonRoomMessageEvent msgEv = new MatrixJsonRoomMessageEvent(ev); + if (StringUtils.equals("m.notice", msgEv.getBodyType())) { + log.info("Ignoring automated message"); + return; + } + + if (!StringUtils.equals("m.text", msgEv.getBodyType())) { + log.info("Unsupported message event type: {}", msgEv.getBodyType()); + return; + } + + String command = msgEv.getBody(); + if (!isAdminPrivate) { + if (StringUtils.equals(command, "!mxisd")) { + // TODO show help + } + if (!StringUtils.startsWith(command, "!mxisd ")) { + // Not for us + return; + } + + command = command.substring("!mxisd ".length()); + } + + if (StringUtils.equals("ping", command)) { + room.sendText("Pong!"); + } + } + +} diff --git a/src/main/java/io/kamax/mxisd/config/AppServiceConfig.java b/src/main/java/io/kamax/mxisd/config/AppServiceConfig.java new file mode 100644 index 0000000..24f39d3 --- /dev/null +++ b/src/main/java/io/kamax/mxisd/config/AppServiceConfig.java @@ -0,0 +1,287 @@ +/* + * mxisd - Matrix Identity Server Daemon + * Copyright (C) 2018 Kamax Sarl + * + * https://www.kamax.io/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public 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 io.kamax.mxisd.config; + +import io.kamax.mxisd.Mxisd; +import io.kamax.mxisd.exception.ConfigurationException; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class AppServiceConfig { + + public static class Users { + + private String main = "mxisd"; + private String inviteExpired = "_mxisd_invite-expired"; + + public String getMain() { + return main; + } + + public void setMain(String main) { + this.main = main; + } + + public String getInviteExpired() { + return inviteExpired; + } + + public void setInviteExpired(String inviteExpired) { + this.inviteExpired = inviteExpired; + } + + public void build() { + // no-op + } + + } + + public static class Endpoint { + + private String url; + private String token; + + private transient URL cUrl; + + public URL getUrl() { + return cUrl; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public void build() { + if (Objects.isNull(url)) { + return; + } + + try { + cUrl = new URL(url); + } catch (MalformedURLException e) { + throw new ConfigurationException("AppService endpoint(s) URL definition"); + } + } + + } + + public static class Endpoints { + + private Endpoint toAS = new Endpoint(); + private Endpoint toHS = new Endpoint(); + + public Endpoint getToAS() { + return toAS; + } + + public void setToAS(Endpoint toAS) { + this.toAS = toAS; + } + + public Endpoint getToHS() { + return toHS; + } + + public void setToHS(Endpoint toHS) { + this.toHS = toHS; + } + + public void build() { + toAS.build(); + toHS.build(); + } + + } + + public static class Synapse { + + private String id = "appservice-" + Mxisd.Name; + private String file; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getFile() { + return file; + } + + public void setFile(String file) { + this.file = file; + } + + public void build() { + // no-op + } + + } + + public static class Registration { + + private Synapse synapse = new Synapse(); + + public Synapse getSynapse() { + return synapse; + } + + public void setSynapse(Synapse synapse) { + this.synapse = synapse; + } + + public void build() { + synapse.build(); + } + + } + + public static class AdminFeature { + + private Boolean enabled; + private List allowedRoles = new ArrayList<>(); + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public List getAllowedRoles() { + return allowedRoles; + } + + public void setAllowedRoles(List allowedRoles) { + this.allowedRoles = allowedRoles; + } + + public void build() { + // no-op + } + + } + + public static class Features { + + private AdminFeature admin = new AdminFeature(); + private Boolean inviteById; + private Boolean cleanExpiredInvite; + + public AdminFeature getAdmin() { + return admin; + } + + public void setAdmin(AdminFeature admin) { + this.admin = admin; + } + + public Boolean getInviteById() { + return inviteById; + } + + public void setInviteById(Boolean inviteById) { + this.inviteById = inviteById; + } + + public Boolean getCleanExpiredInvite() { + return cleanExpiredInvite; + } + + public void setCleanExpiredInvite(Boolean cleanExpiredInvite) { + this.cleanExpiredInvite = cleanExpiredInvite; + } + + public void build() { + admin.build(); + } + + } + + private Boolean enabled; + private Features feature = new Features(); + private Endpoints endpoint = new Endpoints(); + private Registration registration = new Registration(); + private Users user = new Users(); + + public Boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Features getFeature() { + return feature; + } + + public void setFeature(Features feature) { + this.feature = feature; + } + + public Endpoints getEndpoint() { + return endpoint; + } + + public void setEndpoint(Endpoints endpoint) { + this.endpoint = endpoint; + } + + public Registration getRegistration() { + return registration; + } + + public void setRegistration(Registration registration) { + this.registration = registration; + } + + public Users getUser() { + return user; + } + + public void setUser(Users user) { + this.user = user; + } + + public void build() { + endpoint.build(); + feature.build(); + registration.build(); + user.build(); + } + +} diff --git a/src/main/java/io/kamax/mxisd/config/ListenerConfig.java b/src/main/java/io/kamax/mxisd/config/ListenerConfig.java deleted file mode 100644 index 14d9937..0000000 --- a/src/main/java/io/kamax/mxisd/config/ListenerConfig.java +++ /dev/null @@ -1,178 +0,0 @@ -/* - * mxisd - Matrix Identity Server Daemon - * Copyright (C) 2018 Kamax Sarl - * - * https://www.kamax.io/ - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public 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 io.kamax.mxisd.config; - -import io.kamax.mxisd.exception.ConfigurationException; -import org.apache.commons.lang.StringUtils; - -import java.net.MalformedURLException; -import java.net.URL; -import java.util.ArrayList; -import java.util.List; - -public class ListenerConfig { - - public static class Synpase { - - private String registrationFile; - - public String getRegistrationFile() { - return registrationFile; - } - - public void setRegistrationFile(String registrationFile) { - this.registrationFile = registrationFile; - } - - } - - public static class UserTemplate { - - private String type = "regex"; - private String template; - - public String getType() { - return type; - } - - public void setType(String type) { - this.type = type; - } - - public String getTemplate() { - return template; - } - - public void setTemplate(String template) { - this.template = template; - } - - } - - public static class Token { - - private String as; - private String hs; - - public String getAs() { - return as; - } - - public void setAs(String as) { - this.as = as; - } - - public String getHs() { - return hs; - } - - public void setHs(String hs) { - this.hs = hs; - } - - } - - private String id = "appservice-mxisd"; - private String url; - private String localpart = "mxisd"; - private Token token = new Token(); - private List users = new ArrayList<>(); - private Synpase synapse = new Synpase(); - - private transient URL csUrl; - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public URL getUrl() { - return csUrl; - } - - public void setUrl(String url) { - this.url = url; - } - - public String getLocalpart() { - return localpart; - } - - public void setLocalpart(String localpart) { - this.localpart = localpart; - } - - public Token getToken() { - return token; - } - - public void setToken(Token token) { - this.token = token; - } - - public List getUsers() { - return users; - } - - public void setUsers(List users) { - this.users = users; - } - - public Synpase getSynapse() { - return synapse; - } - - public void setSynapse(Synpase synapse) { - this.synapse = synapse; - } - - public void build() { - try { - if (StringUtils.isBlank(url)) { - return; - } - - csUrl = new URL(url); - - if (org.apache.commons.lang3.StringUtils.isBlank(getId())) { - throw new IllegalArgumentException("Matrix Listener ID is not set"); - } - - if (StringUtils.isBlank(getLocalpart())) { - throw new IllegalArgumentException("localpart for matrix listener is not set"); - } - - if (StringUtils.isBlank(getToken().getAs())) { - throw new IllegalArgumentException("AS token is not set"); - } - - if (StringUtils.isBlank(getToken().getHs())) { - throw new IllegalArgumentException("HS token is not set"); - } - } catch (MalformedURLException e) { - throw new ConfigurationException(e); - } - } - -} diff --git a/src/main/java/io/kamax/mxisd/config/MatrixConfig.java b/src/main/java/io/kamax/mxisd/config/MatrixConfig.java index 0fe9db1..c66751a 100644 --- a/src/main/java/io/kamax/mxisd/config/MatrixConfig.java +++ b/src/main/java/io/kamax/mxisd/config/MatrixConfig.java @@ -63,7 +63,6 @@ public class MatrixConfig { private String domain; private Identity identity = new Identity(); - private ListenerConfig listener = new ListenerConfig(); public String getDomain() { return domain; @@ -81,14 +80,6 @@ public class MatrixConfig { this.identity = identity; } - public ListenerConfig getListener() { - return listener; - } - - public void setListener(ListenerConfig listener) { - this.listener = listener; - } - public void build() { log.info("--- Matrix config ---"); @@ -99,8 +90,6 @@ public class MatrixConfig { log.info("Domain: {}", getDomain()); log.info("Identity:"); log.info("\tServers: {}", GsonUtil.get().toJson(identity.getServers())); - - listener.build(); } } diff --git a/src/main/java/io/kamax/mxisd/config/MxisdConfig.java b/src/main/java/io/kamax/mxisd/config/MxisdConfig.java index 3fb5ffa..4a0dc4b 100644 --- a/src/main/java/io/kamax/mxisd/config/MxisdConfig.java +++ b/src/main/java/io/kamax/mxisd/config/MxisdConfig.java @@ -83,6 +83,7 @@ public class MxisdConfig { } + private AppServiceConfig appsvc = new AppServiceConfig(); private AuthenticationConfig auth = new AuthenticationConfig(); private DirectoryConfig directory = new DirectoryConfig(); private Dns dns = new Dns(); @@ -108,6 +109,14 @@ public class MxisdConfig { private ViewConfig view = new ViewConfig(); private WordpressConfig wordpress = new WordpressConfig(); + public AppServiceConfig getAppsvc() { + return appsvc; + } + + public void setAppsvc(AppServiceConfig appsvc) { + this.appsvc = appsvc; + } + public AuthenticationConfig getAuth() { return auth; } @@ -306,6 +315,7 @@ public class MxisdConfig { log.debug("server.name is empty, using matrix.domain"); } + getAppsvc().build(); getAuth().build(); getDirectory().build(); getExec().build(); diff --git a/src/main/java/io/kamax/mxisd/http/undertow/handler/as/v1/AsUserHandler.java b/src/main/java/io/kamax/mxisd/http/undertow/handler/as/v1/AsUserHandler.java new file mode 100644 index 0000000..a37af1f --- /dev/null +++ b/src/main/java/io/kamax/mxisd/http/undertow/handler/as/v1/AsUserHandler.java @@ -0,0 +1,46 @@ +/* + * mxisd - Matrix Identity Server Daemon + * Copyright (C) 2019 Kamax Sarl + * + * https://www.kamax.io/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public 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 io.kamax.mxisd.http.undertow.handler.as.v1; + +import io.kamax.mxisd.as.AppSvcManager; +import io.undertow.server.HttpServerExchange; + +import java.util.LinkedList; + +public class AsUserHandler extends ApplicationServiceHandler { + + public static final String ID = "userId"; + public static final String Path = "/_matrix/app/v1/users/{" + ID + "}"; + + private final AppSvcManager app; + + public AsUserHandler(AppSvcManager app) { + this.app = app; + } + + @Override + public void handleRequest(HttpServerExchange exchange) { + String userId = exchange.getQueryParameters().getOrDefault(ID, new LinkedList<>()).peekFirst(); + app.withToken(getToken(exchange)).processUser(userId); + respondJson(exchange, "{}"); + } + +} diff --git a/src/main/java/io/kamax/mxisd/http/undertow/handler/status/VersionHandler.java b/src/main/java/io/kamax/mxisd/http/undertow/handler/status/VersionHandler.java index 5118789..ab4cf6a 100644 --- a/src/main/java/io/kamax/mxisd/http/undertow/handler/status/VersionHandler.java +++ b/src/main/java/io/kamax/mxisd/http/undertow/handler/status/VersionHandler.java @@ -34,7 +34,7 @@ public class VersionHandler extends BasicHttpHandler { public VersionHandler() { JsonObject server = new JsonObject(); - server.addProperty("name", "mxisd"); + server.addProperty("name", Mxisd.Name); server.addProperty("version", Mxisd.Version); body = GsonUtil.getPrettyForLog(GsonUtil.makeObj("server", server)); diff --git a/src/main/java/io/kamax/mxisd/invitation/InvitationManager.java b/src/main/java/io/kamax/mxisd/invitation/InvitationManager.java index 362f867..8dfcc2a 100644 --- a/src/main/java/io/kamax/mxisd/invitation/InvitationManager.java +++ b/src/main/java/io/kamax/mxisd/invitation/InvitationManager.java @@ -186,7 +186,12 @@ public class InvitationManager { } if (StringUtils.isBlank(cfg.getInvite().getExpiration().getResolveTo())) { - throw new ConfigurationException("Invitation expiration resolution target cannot be empty/blank"); + String localpart = cfg.getAppsvc().getUser().getInviteExpired(); + if (StringUtils.isBlank(localpart)) { + throw new ConfigurationException("Could not compute the Invitation expiration resolution target from App service user: not set"); + } + + cfg.getInvite().getExpiration().setResolveTo(MatrixID.asAcceptable(localpart, cfg.getMatrix().getDomain()).getId()); } try { @@ -395,8 +400,8 @@ public class InvitationManager { Instant ts = Instant.ofEpochMilli(Long.parseLong(tsRaw)); Instant targetTs = ts.plusSeconds(cfg.getExpiration().getAfter() * 60); Instant now = Instant.now(); - log.debug("Invite {} - Created at {} - Expire at {} - Current time is {}", reply.getId(), ts, targetTs, now); - if (targetTs.isBefore(Instant.now())) { + log.debug("Invite {} - Created at {} - Expires at {} - Current time is {}", reply.getId(), ts, targetTs, now); + if (targetTs.isAfter(now)) { log.debug("Invite {} has not expired yet, skipping", reply.getId()); continue; } @@ -494,6 +499,7 @@ public class InvitationManager { Instant resolvedAt = Instant.now(); boolean couldPublish = false; + boolean shouldArchive = true; try { log.info("Posting onBind event to {}", req.getURI()); CloseableHttpResponse response = client.execute(req); @@ -501,7 +507,12 @@ public class InvitationManager { log.info("Answer code: {}", statusCode); if (statusCode >= 300 && statusCode != 403) { log.info("Answer body: {}", IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8)); - log.warn("HS returned an error. Invite can be found in historical storage for manual re-processing"); + log.warn("HS returned an error."); + + shouldArchive = statusCode != 502; + if (shouldArchive) { + log.info("Invite can be found in historical storage for manual re-processing"); + } } else { couldPublish = true; if (statusCode == 403) { @@ -512,10 +523,12 @@ public class InvitationManager { } catch (IOException e) { log.warn("Unable to tell HS {} about invite being mapped", domain, e); } finally { - synchronized (this) { - storage.insertHistoricalInvite(reply, mxid, resolvedAt, couldPublish); - removeInvite(reply); - log.info("Moved invite {} to historical table", reply.getId()); + if (shouldArchive) { + synchronized (this) { + storage.insertHistoricalInvite(reply, mxid, resolvedAt, couldPublish); + removeInvite(reply); + log.info("Moved invite {} to historical table", reply.getId()); + } } } }).start(); diff --git a/src/main/java/io/kamax/mxisd/threepid/connector/email/EmailSmtpConnector.java b/src/main/java/io/kamax/mxisd/threepid/connector/email/EmailSmtpConnector.java index 24d840f..af3c3f6 100644 --- a/src/main/java/io/kamax/mxisd/threepid/connector/email/EmailSmtpConnector.java +++ b/src/main/java/io/kamax/mxisd/threepid/connector/email/EmailSmtpConnector.java @@ -22,6 +22,7 @@ package io.kamax.mxisd.threepid.connector.email; import com.sun.mail.smtp.SMTPTransport; import io.kamax.matrix.ThreePidMedium; +import io.kamax.mxisd.Mxisd; import io.kamax.mxisd.config.threepid.connector.EmailSmtpConfig; import io.kamax.mxisd.exception.FeatureNotAvailable; import io.kamax.mxisd.exception.InternalServerError; @@ -92,7 +93,7 @@ public class EmailSmtpConnector implements EmailConnector { try { InternetAddress sender = new InternetAddress(senderAddress, senderName); MimeMessage msg = new MimeMessage(session, IOUtils.toInputStream(content, StandardCharsets.UTF_8)); - msg.setHeader("X-Mailer", "mxisd"); // FIXME set version + msg.setHeader("X-Mailer", Mxisd.Agent); msg.setSentDate(new Date()); msg.setFrom(sender); msg.setRecipients(Message.RecipientType.TO, recipient);