Add mechanisms for 3PID invite expiration and AS integration

- Integration with AS and a fallback user to decline expired invites (#120)
- Rework of the AS feature to make it more independent/re-usable
- Skeleton for admin interface via bot to manage invites (#138)
This commit is contained in:
Max Dor
2019-03-02 03:19:47 +01:00
parent de92e98f7d
commit 254dc5684f
15 changed files with 771 additions and 353 deletions

View File

@@ -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<String, EventTypeProcessor> processors = new HashMap<>();
private Map<String, CompletableFuture<String>> transactionsInProgress = new ConcurrentHashMap<>();
private Map<String, CompletableFuture<String>> 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<String> 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<String> processTransaction(String txnId, InputStream is) {
ensureEnabled();
if (StringUtils.isEmpty(txnId)) {
throw new IllegalArgumentException("Transaction ID cannot be empty");
}
synchronized (this) {
Optional<ASTransactionDao> dao = store.getTransactionResult(cfg.getListener().getLocalpart(), txnId);
Optional<ASTransactionDao> 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);