Merge pull request #107 from vector-im/bwindels/rate-limiting-for-all
Rate-limiting for all events
This commit is contained in:
commit
fe8c63b5f2
12 changed files with 258 additions and 245 deletions
|
@ -81,7 +81,7 @@ async function loadOlmWorker(paths) {
|
||||||
// see https://github.com/rollup/plugins/tree/master/packages/multi-entry
|
// see https://github.com/rollup/plugins/tree/master/packages/multi-entry
|
||||||
export async function main(container, paths, legacyExtras) {
|
export async function main(container, paths, legacyExtras) {
|
||||||
try {
|
try {
|
||||||
// TODO: add .legacy to body in (legacy)platform.createAndMountRootView; and use body:not(.legacy) if needed for modern stuff
|
// TODO: add .legacy to .hydrogen (container) in (legacy)platform.createAndMountRootView; and use .hydrogen:not(.legacy) if needed for modern stuff
|
||||||
const isIE11 = !!window.MSInputMethodContext && !!document.documentMode;
|
const isIE11 = !!window.MSInputMethodContext && !!document.documentMode;
|
||||||
if (isIE11) {
|
if (isIE11) {
|
||||||
document.body.className += " ie11";
|
document.body.className += " ie11";
|
||||||
|
@ -104,7 +104,7 @@ export async function main(container, paths, legacyExtras) {
|
||||||
} else {
|
} else {
|
||||||
request = xhrRequest;
|
request = xhrRequest;
|
||||||
}
|
}
|
||||||
const sessionInfoStorage = new SessionInfoStorage("brawl_sessions_v1");
|
const sessionInfoStorage = new SessionInfoStorage("hydrogen_sessions_v1");
|
||||||
const storageFactory = new StorageFactory();
|
const storageFactory = new StorageFactory();
|
||||||
|
|
||||||
const olmPromise = loadOlm(paths.olm);
|
const olmPromise = loadOlm(paths.olm);
|
||||||
|
|
|
@ -1,149 +0,0 @@
|
||||||
/*
|
|
||||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {Platform} from "../Platform.js";
|
|
||||||
import {HomeServerError, ConnectionError} from "./error.js";
|
|
||||||
|
|
||||||
export class RateLimitingBackoff {
|
|
||||||
constructor() {
|
|
||||||
this._remainingRateLimitedRequest = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async waitAfterLimitExceeded(retryAfterMs) {
|
|
||||||
// this._remainingRateLimitedRequest = 5;
|
|
||||||
// if (typeof retryAfterMs !== "number") {
|
|
||||||
// } else {
|
|
||||||
// }
|
|
||||||
if (!retryAfterMs) {
|
|
||||||
retryAfterMs = 5000;
|
|
||||||
}
|
|
||||||
await Platform.delay(retryAfterMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// do we have to know about succeeding requests?
|
|
||||||
// we can just
|
|
||||||
|
|
||||||
async waitForNextSend() {
|
|
||||||
// this._remainingRateLimitedRequest = Math.max(0, this._remainingRateLimitedRequest - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
this represents a slot to do one rate limited api call.
|
|
||||||
because rate-limiting is handled here, it should only
|
|
||||||
try to do one call, so the SendScheduler can safely
|
|
||||||
retry if the call ends up being rate limited.
|
|
||||||
This is also why we have this abstraction it hsApi is not
|
|
||||||
passed straight to SendQueue when it is its turn to send.
|
|
||||||
e.g. we wouldn't want to repeat the callback in SendQueue that could
|
|
||||||
have other side-effects before the call to hsApi that we wouldn't want
|
|
||||||
repeated (setting up progress handlers for file uploads,
|
|
||||||
... a UI update to say it started sending?
|
|
||||||
... updating storage would probably only happen once the call succeeded
|
|
||||||
... doing multiple hsApi calls for e.g. a file upload before sending a image message (they should individually be retried)
|
|
||||||
) maybe it is a bit overengineering, but lets stick with it for now.
|
|
||||||
At least the above is a clear definition why we have this class
|
|
||||||
*/
|
|
||||||
//class SendSlot -- obsolete
|
|
||||||
|
|
||||||
export class SendScheduler {
|
|
||||||
constructor({hsApi, backoff}) {
|
|
||||||
this._hsApi = hsApi;
|
|
||||||
this._sendRequests = [];
|
|
||||||
this._sendScheduled = false;
|
|
||||||
this._stopped = false;
|
|
||||||
this._waitTime = 0;
|
|
||||||
this._backoff = backoff;
|
|
||||||
/*
|
|
||||||
we should have some sort of flag here that we enable
|
|
||||||
after all the rooms have been notified that they can resume
|
|
||||||
sending, so that from session, we can say scheduler.enable();
|
|
||||||
this way, when we have better scheduling, it won't be first come,
|
|
||||||
first serve, when there are a lot of events in different rooms to send,
|
|
||||||
but we can apply some priorization of who should go first
|
|
||||||
*/
|
|
||||||
// this._enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
// TODO: abort current requests and set offline
|
|
||||||
}
|
|
||||||
|
|
||||||
start() {
|
|
||||||
this._stopped = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isStarted() {
|
|
||||||
return !this._stopped;
|
|
||||||
}
|
|
||||||
|
|
||||||
// this should really be per roomId to avoid head-of-line blocking
|
|
||||||
//
|
|
||||||
// takes a callback instead of returning a promise with the slot
|
|
||||||
// to make sure the scheduler doesn't get blocked by a slot that is not consumed
|
|
||||||
request(sendCallback) {
|
|
||||||
let request;
|
|
||||||
const promise = new Promise((resolve, reject) => request = {resolve, reject, sendCallback});
|
|
||||||
this._sendRequests.push(request);
|
|
||||||
if (!this._sendScheduled && !this._stopped) {
|
|
||||||
this._sendLoop();
|
|
||||||
}
|
|
||||||
return promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
async _sendLoop() {
|
|
||||||
while (this._sendRequests.length) {
|
|
||||||
const request = this._sendRequests.shift();
|
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
// this can throw!
|
|
||||||
result = await this._doSend(request.sendCallback);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ConnectionError) {
|
|
||||||
// we're offline, everybody will have
|
|
||||||
// to re-request slots when we come back online
|
|
||||||
this._stopped = true;
|
|
||||||
for (const r of this._sendRequests) {
|
|
||||||
r.reject(err);
|
|
||||||
}
|
|
||||||
this._sendRequests = [];
|
|
||||||
}
|
|
||||||
console.error("error for request", err);
|
|
||||||
request.reject(err);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
request.resolve(result);
|
|
||||||
}
|
|
||||||
// do next here instead of in _doSend
|
|
||||||
}
|
|
||||||
|
|
||||||
async _doSend(sendCallback) {
|
|
||||||
this._sendScheduled = false;
|
|
||||||
await this._backoff.waitForNextSend();
|
|
||||||
// loop is left by return or throw
|
|
||||||
while (true) { // eslint-disable-line no-constant-condition
|
|
||||||
try {
|
|
||||||
return await sendCallback(this._hsApi);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof HomeServerError && err.errcode === "M_LIMIT_EXCEEDED") {
|
|
||||||
await this._backoff.waitAfterLimitExceeded(err.retry_after_ms);
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -16,7 +16,6 @@ limitations under the License.
|
||||||
|
|
||||||
import {Room} from "./room/Room.js";
|
import {Room} from "./room/Room.js";
|
||||||
import { ObservableMap } from "../observable/index.js";
|
import { ObservableMap } from "../observable/index.js";
|
||||||
import { SendScheduler, RateLimitingBackoff } from "./SendScheduler.js";
|
|
||||||
import {User} from "./User.js";
|
import {User} from "./User.js";
|
||||||
import {DeviceMessageHandler} from "./DeviceMessageHandler.js";
|
import {DeviceMessageHandler} from "./DeviceMessageHandler.js";
|
||||||
import {Account as E2EEAccount} from "./e2ee/Account.js";
|
import {Account as E2EEAccount} from "./e2ee/Account.js";
|
||||||
|
@ -42,14 +41,14 @@ const PICKLE_KEY = "DEFAULT_KEY";
|
||||||
|
|
||||||
export class Session {
|
export class Session {
|
||||||
// sessionInfo contains deviceId, userId and homeServer
|
// sessionInfo contains deviceId, userId and homeServer
|
||||||
constructor({clock, storage, hsApi, sessionInfo, olm, olmWorker, cryptoDriver}) {
|
constructor({clock, storage, hsApi, sessionInfo, olm, olmWorker, cryptoDriver, mediaRepository}) {
|
||||||
this._clock = clock;
|
this._clock = clock;
|
||||||
this._storage = storage;
|
this._storage = storage;
|
||||||
this._hsApi = hsApi;
|
this._hsApi = hsApi;
|
||||||
|
this._mediaRepository = mediaRepository;
|
||||||
this._syncInfo = null;
|
this._syncInfo = null;
|
||||||
this._sessionInfo = sessionInfo;
|
this._sessionInfo = sessionInfo;
|
||||||
this._rooms = new ObservableMap();
|
this._rooms = new ObservableMap();
|
||||||
this._sendScheduler = new SendScheduler({hsApi, backoff: new RateLimitingBackoff()});
|
|
||||||
this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params);
|
this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params);
|
||||||
this._user = new User(sessionInfo.userId);
|
this._user = new User(sessionInfo.userId);
|
||||||
this._deviceMessageHandler = new DeviceMessageHandler({storage});
|
this._deviceMessageHandler = new DeviceMessageHandler({storage});
|
||||||
|
@ -266,13 +265,8 @@ export class Session {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
get isStarted() {
|
|
||||||
return this._sendScheduler.isStarted;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
this._olmWorker?.dispose();
|
this._olmWorker?.dispose();
|
||||||
this._sendScheduler.stop();
|
|
||||||
this._sessionBackup?.dispose();
|
this._sessionBackup?.dispose();
|
||||||
for (const room of this._rooms.values()) {
|
for (const room of this._rooms.values()) {
|
||||||
room.dispose();
|
room.dispose();
|
||||||
|
@ -296,7 +290,6 @@ export class Session {
|
||||||
const operations = await opsTxn.operations.getAll();
|
const operations = await opsTxn.operations.getAll();
|
||||||
const operationsByScope = groupBy(operations, o => o.scope);
|
const operationsByScope = groupBy(operations, o => o.scope);
|
||||||
|
|
||||||
this._sendScheduler.start();
|
|
||||||
for (const [, room] of this._rooms) {
|
for (const [, room] of this._rooms) {
|
||||||
let roomOperationsByType;
|
let roomOperationsByType;
|
||||||
const roomOperations = operationsByScope.get(room.id);
|
const roomOperations = operationsByScope.get(room.id);
|
||||||
|
@ -331,7 +324,7 @@ export class Session {
|
||||||
storage: this._storage,
|
storage: this._storage,
|
||||||
emitCollectionChange: this._roomUpdateCallback,
|
emitCollectionChange: this._roomUpdateCallback,
|
||||||
hsApi: this._hsApi,
|
hsApi: this._hsApi,
|
||||||
sendScheduler: this._sendScheduler,
|
mediaRepository: this._mediaRepository,
|
||||||
pendingEvents,
|
pendingEvents,
|
||||||
user: this._user,
|
user: this._user,
|
||||||
createRoomEncryption: this._createRoomEncryption,
|
createRoomEncryption: this._createRoomEncryption,
|
||||||
|
|
|
@ -19,6 +19,8 @@ import {ObservableValue} from "../observable/ObservableValue.js";
|
||||||
import {HomeServerApi} from "./net/HomeServerApi.js";
|
import {HomeServerApi} from "./net/HomeServerApi.js";
|
||||||
import {Reconnector, ConnectionStatus} from "./net/Reconnector.js";
|
import {Reconnector, ConnectionStatus} from "./net/Reconnector.js";
|
||||||
import {ExponentialRetryDelay} from "./net/ExponentialRetryDelay.js";
|
import {ExponentialRetryDelay} from "./net/ExponentialRetryDelay.js";
|
||||||
|
import {MediaRepository} from "./net/MediaRepository.js";
|
||||||
|
import {RequestScheduler} from "./net/RequestScheduler.js";
|
||||||
import {HomeServerError, ConnectionError, AbortError} from "./error.js";
|
import {HomeServerError, ConnectionError, AbortError} from "./error.js";
|
||||||
import {Sync, SyncStatus} from "./Sync.js";
|
import {Sync, SyncStatus} from "./Sync.js";
|
||||||
import {Session} from "./Session.js";
|
import {Session} from "./Session.js";
|
||||||
|
@ -49,7 +51,7 @@ export class SessionContainer {
|
||||||
this._request = request;
|
this._request = request;
|
||||||
this._storageFactory = storageFactory;
|
this._storageFactory = storageFactory;
|
||||||
this._sessionInfoStorage = sessionInfoStorage;
|
this._sessionInfoStorage = sessionInfoStorage;
|
||||||
|
this._sessionStartedByReconnector = false;
|
||||||
this._status = new ObservableValue(LoadStatus.NotLoading);
|
this._status = new ObservableValue(LoadStatus.NotLoading);
|
||||||
this._error = null;
|
this._error = null;
|
||||||
this._loginFailure = null;
|
this._loginFailure = null;
|
||||||
|
@ -58,6 +60,7 @@ export class SessionContainer {
|
||||||
this._sync = null;
|
this._sync = null;
|
||||||
this._sessionId = null;
|
this._sessionId = null;
|
||||||
this._storage = null;
|
this._storage = null;
|
||||||
|
this._requestScheduler = null;
|
||||||
this._olmPromise = olmPromise;
|
this._olmPromise = olmPromise;
|
||||||
this._workerPromise = workerPromise;
|
this._workerPromise = workerPromise;
|
||||||
this._cryptoDriver = cryptoDriver;
|
this._cryptoDriver = cryptoDriver;
|
||||||
|
@ -132,6 +135,7 @@ export class SessionContainer {
|
||||||
}
|
}
|
||||||
|
|
||||||
async _loadSessionInfo(sessionInfo, isNewLogin) {
|
async _loadSessionInfo(sessionInfo, isNewLogin) {
|
||||||
|
this._sessionStartedByReconnector = false;
|
||||||
this._status.set(LoadStatus.Loading);
|
this._status.set(LoadStatus.Loading);
|
||||||
this._reconnector = new Reconnector({
|
this._reconnector = new Reconnector({
|
||||||
onlineStatus: this._onlineStatus,
|
onlineStatus: this._onlineStatus,
|
||||||
|
@ -158,18 +162,30 @@ export class SessionContainer {
|
||||||
if (this._workerPromise) {
|
if (this._workerPromise) {
|
||||||
olmWorker = await this._workerPromise;
|
olmWorker = await this._workerPromise;
|
||||||
}
|
}
|
||||||
this._session = new Session({storage: this._storage,
|
this._requestScheduler = new RequestScheduler({hsApi, clock: this._clock});
|
||||||
sessionInfo: filteredSessionInfo, hsApi, olm,
|
this._requestScheduler.start();
|
||||||
clock: this._clock, olmWorker, cryptoDriver: this._cryptoDriver});
|
this._session = new Session({
|
||||||
|
storage: this._storage,
|
||||||
|
sessionInfo: filteredSessionInfo,
|
||||||
|
hsApi: this._requestScheduler.hsApi,
|
||||||
|
olm,
|
||||||
|
clock: this._clock,
|
||||||
|
olmWorker,
|
||||||
|
cryptoDriver: this._cryptoDriver,
|
||||||
|
mediaRepository: new MediaRepository(sessionInfo.homeServer)
|
||||||
|
});
|
||||||
await this._session.load();
|
await this._session.load();
|
||||||
this._status.set(LoadStatus.SessionSetup);
|
this._status.set(LoadStatus.SessionSetup);
|
||||||
await this._session.beforeFirstSync(isNewLogin);
|
await this._session.beforeFirstSync(isNewLogin);
|
||||||
|
|
||||||
this._sync = new Sync({hsApi, storage: this._storage, session: this._session});
|
this._sync = new Sync({hsApi: this._requestScheduler.hsApi, storage: this._storage, session: this._session});
|
||||||
// notify sync and session when back online
|
// notify sync and session when back online
|
||||||
this._reconnectSubscription = this._reconnector.connectionStatus.subscribe(state => {
|
this._reconnectSubscription = this._reconnector.connectionStatus.subscribe(state => {
|
||||||
if (state === ConnectionStatus.Online) {
|
if (state === ConnectionStatus.Online) {
|
||||||
|
// needs to happen before sync and session or it would abort all requests
|
||||||
|
this._requestScheduler.start();
|
||||||
this._sync.start();
|
this._sync.start();
|
||||||
|
this._sessionStartedByReconnector = true;
|
||||||
this._session.start(this._reconnector.lastVersionsResponse);
|
this._session.start(this._reconnector.lastVersionsResponse);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -181,11 +197,7 @@ export class SessionContainer {
|
||||||
// restored the connection, it would have already
|
// restored the connection, it would have already
|
||||||
// started to session, so check first
|
// started to session, so check first
|
||||||
// to prevent an extra /versions request
|
// to prevent an extra /versions request
|
||||||
|
if (!this._sessionStartedByReconnector) {
|
||||||
// TODO: this doesn't look logical, but works. Why?
|
|
||||||
// I think because isStarted is true by default. That's probably not what we intend.
|
|
||||||
// I think there is a bug here, in that even if the reconnector already started the session, we'd still do this.
|
|
||||||
if (this._session.isStarted) {
|
|
||||||
const lastVersionsResponse = await hsApi.versions({timeout: 10000}).response();
|
const lastVersionsResponse = await hsApi.versions({timeout: 10000}).response();
|
||||||
this._session.start(lastVersionsResponse);
|
this._session.start(lastVersionsResponse);
|
||||||
}
|
}
|
||||||
|
@ -251,6 +263,9 @@ export class SessionContainer {
|
||||||
this._reconnectSubscription();
|
this._reconnectSubscription();
|
||||||
this._reconnectSubscription = null;
|
this._reconnectSubscription = null;
|
||||||
}
|
}
|
||||||
|
if (this._requestScheduler) {
|
||||||
|
this._requestScheduler.stop();
|
||||||
|
}
|
||||||
if (this._sync) {
|
if (this._sync) {
|
||||||
this._sync.stop();
|
this._sync.stop();
|
||||||
}
|
}
|
||||||
|
|
|
@ -120,11 +120,11 @@ export class Sync {
|
||||||
this._status.set(SyncStatus.Syncing);
|
this._status.set(SyncStatus.Syncing);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
this._status.set(SyncStatus.Stopped);
|
||||||
if (!(err instanceof AbortError)) {
|
if (!(err instanceof AbortError)) {
|
||||||
console.warn("stopping sync because of error");
|
console.warn("stopping sync because of error");
|
||||||
console.error(err);
|
console.error(err);
|
||||||
this._error = err;
|
this._error = err;
|
||||||
this._status.set(SyncStatus.Stopped);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this._status.get() !== SyncStatus.Stopped) {
|
if (this._status.get() !== SyncStatus.Stopped) {
|
||||||
|
|
|
@ -159,10 +159,6 @@ export class RoomEncryption {
|
||||||
}
|
}
|
||||||
|
|
||||||
async _requestMissingSessionFromBackup(senderKey, sessionId, source) {
|
async _requestMissingSessionFromBackup(senderKey, sessionId, source) {
|
||||||
if (!this._sessionBackup) {
|
|
||||||
this._notifyMissingMegolmSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// if the message came from sync, wait 10s to see if the room key arrives,
|
// if the message came from sync, wait 10s to see if the room key arrives,
|
||||||
// and only after that proceed to request from backup
|
// and only after that proceed to request from backup
|
||||||
if (source === DecryptionSource.Sync) {
|
if (source === DecryptionSource.Sync) {
|
||||||
|
@ -171,6 +167,11 @@ export class RoomEncryption {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// show prompt to enable secret storage
|
||||||
|
if (!this._sessionBackup) {
|
||||||
|
this._notifyMissingMegolmSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await this._sessionBackup.getSession(this._room.id, sessionId);
|
const session = await this._sessionBackup.getSession(this._room.id, sessionId);
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -14,11 +15,8 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {HomeServerError} from "../error.js";
|
||||||
HomeServerError,
|
import {encodeQueryParams} from "./common.js";
|
||||||
ConnectionError,
|
|
||||||
AbortError
|
|
||||||
} from "../error.js";
|
|
||||||
|
|
||||||
class RequestWrapper {
|
class RequestWrapper {
|
||||||
constructor(method, url, requestResult) {
|
constructor(method, url, requestResult) {
|
||||||
|
@ -45,18 +43,6 @@ class RequestWrapper {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeQueryParams(queryParams) {
|
|
||||||
return Object.entries(queryParams || {})
|
|
||||||
.filter(([, value]) => value !== undefined)
|
|
||||||
.map(([name, value]) => {
|
|
||||||
if (typeof value === "object") {
|
|
||||||
value = JSON.stringify(value);
|
|
||||||
}
|
|
||||||
return `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
|
|
||||||
})
|
|
||||||
.join("&");
|
|
||||||
}
|
|
||||||
|
|
||||||
export class HomeServerApi {
|
export class HomeServerApi {
|
||||||
constructor({homeServer, accessToken, request, createTimeout, reconnector}) {
|
constructor({homeServer, accessToken, request, createTimeout, reconnector}) {
|
||||||
// store these both in a closure somehow so it's harder to get at in case of XSS?
|
// store these both in a closure somehow so it's harder to get at in case of XSS?
|
||||||
|
@ -66,7 +52,6 @@ export class HomeServerApi {
|
||||||
this._requestFn = request;
|
this._requestFn = request;
|
||||||
this._createTimeout = createTimeout;
|
this._createTimeout = createTimeout;
|
||||||
this._reconnector = reconnector;
|
this._reconnector = reconnector;
|
||||||
this._mediaRepository = new MediaRepository(homeServer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_url(csPath) {
|
_url(csPath) {
|
||||||
|
@ -196,45 +181,6 @@ export class HomeServerApi {
|
||||||
roomKeyForRoomAndSession(version, roomId, sessionId, options = null) {
|
roomKeyForRoomAndSession(version, roomId, sessionId, options = null) {
|
||||||
return this._get(`/room_keys/keys/${encodeURIComponent(roomId)}/${encodeURIComponent(sessionId)}`, {version}, null, options);
|
return this._get(`/room_keys/keys/${encodeURIComponent(roomId)}/${encodeURIComponent(sessionId)}`, {version}, null, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
get mediaRepository() {
|
|
||||||
return this._mediaRepository;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MediaRepository {
|
|
||||||
constructor(homeserver) {
|
|
||||||
this._homeserver = homeserver;
|
|
||||||
}
|
|
||||||
|
|
||||||
mxcUrlThumbnail(url, width, height, method) {
|
|
||||||
const parts = this._parseMxcUrl(url);
|
|
||||||
if (parts) {
|
|
||||||
const [serverName, mediaId] = parts;
|
|
||||||
const httpUrl = `${this._homeserver}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
|
|
||||||
return httpUrl + "?" + encodeQueryParams({width, height, method});
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
mxcUrl(url) {
|
|
||||||
const parts = this._parseMxcUrl(url);
|
|
||||||
if (parts) {
|
|
||||||
const [serverName, mediaId] = parts;
|
|
||||||
return `${this._homeserver}/_matrix/media/r0/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_parseMxcUrl(url) {
|
|
||||||
const prefix = "mxc://";
|
|
||||||
if (url.startsWith(prefix)) {
|
|
||||||
return url.substr(prefix.length).split("/", 2);
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tests() {
|
export function tests() {
|
||||||
|
|
52
src/matrix/net/MediaRepository.js
Normal file
52
src/matrix/net/MediaRepository.js
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {encodeQueryParams} from "./common.js";
|
||||||
|
|
||||||
|
export class MediaRepository {
|
||||||
|
constructor(homeserver) {
|
||||||
|
this._homeserver = homeserver;
|
||||||
|
}
|
||||||
|
|
||||||
|
mxcUrlThumbnail(url, width, height, method) {
|
||||||
|
const parts = this._parseMxcUrl(url);
|
||||||
|
if (parts) {
|
||||||
|
const [serverName, mediaId] = parts;
|
||||||
|
const httpUrl = `${this._homeserver}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
|
||||||
|
return httpUrl + "?" + encodeQueryParams({width, height, method});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
mxcUrl(url) {
|
||||||
|
const parts = this._parseMxcUrl(url);
|
||||||
|
if (parts) {
|
||||||
|
const [serverName, mediaId] = parts;
|
||||||
|
return `${this._homeserver}/_matrix/media/r0/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_parseMxcUrl(url) {
|
||||||
|
const prefix = "mxc://";
|
||||||
|
if (url.startsWith(prefix)) {
|
||||||
|
return url.substr(prefix.length).split("/", 2);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
130
src/matrix/net/RequestScheduler.js
Normal file
130
src/matrix/net/RequestScheduler.js
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {AbortError} from "../../utils/error.js";
|
||||||
|
import {HomeServerError} from "../error.js";
|
||||||
|
import {HomeServerApi} from "./HomeServerApi.js";
|
||||||
|
import {ExponentialRetryDelay} from "./ExponentialRetryDelay.js";
|
||||||
|
|
||||||
|
class Request {
|
||||||
|
constructor(methodName, args) {
|
||||||
|
this._methodName = methodName;
|
||||||
|
this._args = args;
|
||||||
|
this._responsePromise = new Promise((resolve, reject) => {
|
||||||
|
this._resolve = resolve;
|
||||||
|
this._reject = reject;
|
||||||
|
});
|
||||||
|
this._requestResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
abort() {
|
||||||
|
if (this._requestResult) {
|
||||||
|
this._requestResult.abort();
|
||||||
|
} else {
|
||||||
|
this._reject(new AbortError());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response() {
|
||||||
|
return this._responsePromise;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HomeServerApiWrapper {
|
||||||
|
constructor(scheduler) {
|
||||||
|
this._scheduler = scheduler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add request-wrapping methods to prototype
|
||||||
|
for (const methodName of Object.getOwnPropertyNames(HomeServerApi.prototype)) {
|
||||||
|
if (methodName !== "constructor" && !methodName.startsWith("_")) {
|
||||||
|
HomeServerApiWrapper.prototype[methodName] = function(...args) {
|
||||||
|
return this._scheduler._hsApiRequest(methodName, args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RequestScheduler {
|
||||||
|
constructor({hsApi, clock}) {
|
||||||
|
this._hsApi = hsApi;
|
||||||
|
this._clock = clock;
|
||||||
|
this._requests = new Set();
|
||||||
|
this._isRateLimited = false;
|
||||||
|
this._isDrainingRateLimit = false;
|
||||||
|
this._stopped = true;
|
||||||
|
this._wrapper = new HomeServerApiWrapper(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
get hsApi() {
|
||||||
|
return this._wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this._stopped = true;
|
||||||
|
for (const request of this._requests) {
|
||||||
|
request.abort();
|
||||||
|
}
|
||||||
|
this._requests.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this._stopped = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_hsApiRequest(name, args) {
|
||||||
|
const request = new Request(name, args);
|
||||||
|
this._doSend(request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _doSend(request) {
|
||||||
|
this._requests.add(request);
|
||||||
|
try {
|
||||||
|
let retryDelay;
|
||||||
|
while (!this._stopped) {
|
||||||
|
try {
|
||||||
|
const requestResult = this._hsApi[request._methodName].apply(this._hsApi, request._args);
|
||||||
|
// so the request can be aborted
|
||||||
|
request._requestResult = requestResult;
|
||||||
|
const response = await requestResult.response();
|
||||||
|
request._resolve(response);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HomeServerError && err.errcode === "M_LIMIT_EXCEEDED") {
|
||||||
|
if (Number.isSafeInteger(err.retry_after_ms)) {
|
||||||
|
await this._clock.createTimeout(err.retry_after_ms).elapsed();
|
||||||
|
} else {
|
||||||
|
if (!retryDelay) {
|
||||||
|
retryDelay = new ExponentialRetryDelay(this._clock.createTimeout);
|
||||||
|
}
|
||||||
|
await retryDelay.waitForRetry();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
request._reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this._stopped) {
|
||||||
|
request.abort();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this._requests.delete(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
28
src/matrix/net/common.js
Normal file
28
src/matrix/net/common.js
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function encodeQueryParams(queryParams) {
|
||||||
|
return Object.entries(queryParams || {})
|
||||||
|
.filter(([, value]) => value !== undefined)
|
||||||
|
.map(([name, value]) => {
|
||||||
|
if (typeof value === "object") {
|
||||||
|
value = JSON.stringify(value);
|
||||||
|
}
|
||||||
|
return `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
|
||||||
|
})
|
||||||
|
.join("&");
|
||||||
|
}
|
|
@ -31,16 +31,17 @@ import {DecryptionSource} from "../e2ee/common.js";
|
||||||
const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
|
const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
|
||||||
|
|
||||||
export class Room extends EventEmitter {
|
export class Room extends EventEmitter {
|
||||||
constructor({roomId, storage, hsApi, emitCollectionChange, sendScheduler, pendingEvents, user, createRoomEncryption, getSyncToken, clock}) {
|
constructor({roomId, storage, hsApi, mediaRepository, emitCollectionChange, pendingEvents, user, createRoomEncryption, getSyncToken, clock}) {
|
||||||
super();
|
super();
|
||||||
this._roomId = roomId;
|
this._roomId = roomId;
|
||||||
this._storage = storage;
|
this._storage = storage;
|
||||||
this._hsApi = hsApi;
|
this._hsApi = hsApi;
|
||||||
|
this._mediaRepository = mediaRepository;
|
||||||
this._summary = new RoomSummary(roomId, user.id);
|
this._summary = new RoomSummary(roomId, user.id);
|
||||||
this._fragmentIdComparer = new FragmentIdComparer([]);
|
this._fragmentIdComparer = new FragmentIdComparer([]);
|
||||||
this._syncWriter = new SyncWriter({roomId, fragmentIdComparer: this._fragmentIdComparer});
|
this._syncWriter = new SyncWriter({roomId, fragmentIdComparer: this._fragmentIdComparer});
|
||||||
this._emitCollectionChange = emitCollectionChange;
|
this._emitCollectionChange = emitCollectionChange;
|
||||||
this._sendQueue = new SendQueue({roomId, storage, sendScheduler, pendingEvents});
|
this._sendQueue = new SendQueue({roomId, storage, hsApi, pendingEvents});
|
||||||
this._timeline = null;
|
this._timeline = null;
|
||||||
this._user = user;
|
this._user = user;
|
||||||
this._changedMembersDuringSync = null;
|
this._changedMembersDuringSync = null;
|
||||||
|
@ -517,7 +518,7 @@ export class Room extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
get mediaRepository() {
|
get mediaRepository() {
|
||||||
return this._hsApi.mediaRepository;
|
return this._mediaRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @package */
|
/** @package */
|
||||||
|
|
|
@ -20,11 +20,11 @@ import {PendingEvent} from "./PendingEvent.js";
|
||||||
import {makeTxnId} from "../../common.js";
|
import {makeTxnId} from "../../common.js";
|
||||||
|
|
||||||
export class SendQueue {
|
export class SendQueue {
|
||||||
constructor({roomId, storage, sendScheduler, pendingEvents}) {
|
constructor({roomId, storage, hsApi, pendingEvents}) {
|
||||||
pendingEvents = pendingEvents || [];
|
pendingEvents = pendingEvents || [];
|
||||||
this._roomId = roomId;
|
this._roomId = roomId;
|
||||||
this._storage = storage;
|
this._storage = storage;
|
||||||
this._sendScheduler = sendScheduler;
|
this._hsApi = hsApi;
|
||||||
this._pendingEvents = new SortedArray((a, b) => a.queueIndex - b.queueIndex);
|
this._pendingEvents = new SortedArray((a, b) => a.queueIndex - b.queueIndex);
|
||||||
if (pendingEvents.length) {
|
if (pendingEvents.length) {
|
||||||
console.info(`SendQueue for room ${roomId} has ${pendingEvents.length} pending events`, pendingEvents);
|
console.info(`SendQueue for room ${roomId} has ${pendingEvents.length} pending events`, pendingEvents);
|
||||||
|
@ -51,22 +51,18 @@ export class SendQueue {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (pendingEvent.needsEncryption) {
|
if (pendingEvent.needsEncryption) {
|
||||||
const {type, content} = await this._sendScheduler.request(async hsApi => {
|
const {type, content} = await this._roomEncryption.encrypt(
|
||||||
return await this._roomEncryption.encrypt(pendingEvent.eventType, pendingEvent.content, hsApi);
|
pendingEvent.eventType, pendingEvent.content, this._hsApi);
|
||||||
});
|
|
||||||
pendingEvent.setEncrypted(type, content);
|
pendingEvent.setEncrypted(type, content);
|
||||||
await this._tryUpdateEvent(pendingEvent);
|
await this._tryUpdateEvent(pendingEvent);
|
||||||
}
|
}
|
||||||
console.log("really sending now");
|
console.log("really sending now");
|
||||||
const response = await this._sendScheduler.request(hsApi => {
|
const response = await this._hsApi.send(
|
||||||
console.log("got sendScheduler slot");
|
|
||||||
return hsApi.send(
|
|
||||||
pendingEvent.roomId,
|
pendingEvent.roomId,
|
||||||
pendingEvent.eventType,
|
pendingEvent.eventType,
|
||||||
pendingEvent.txnId,
|
pendingEvent.txnId,
|
||||||
pendingEvent.content
|
pendingEvent.content
|
||||||
).response();
|
).response();
|
||||||
});
|
|
||||||
pendingEvent.remoteId = response.event_id;
|
pendingEvent.remoteId = response.event_id;
|
||||||
//
|
//
|
||||||
console.log("writing remoteId now");
|
console.log("writing remoteId now");
|
||||||
|
|
Reference in a new issue