diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index d6fdcfa0..d9949c77 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -105,7 +105,7 @@ export class RootViewModel extends ViewModel { _showLogin(loginToken) { this._setSection(() => { this._loginViewModel = new LoginViewModel(this.childOptions({ - defaultHomeServer: this.platform.config["defaultHomeServer"], + defaultHomeserver: this.platform.config["defaultHomeServer"], createSessionContainer: this._createSessionContainer, ready: sessionContainer => { // we don't want to load the session container again, diff --git a/src/domain/login/CompleteSSOLoginViewModel.js b/src/domain/login/CompleteSSOLoginViewModel.js index b7e04cc8..7821313a 100644 --- a/src/domain/login/CompleteSSOLoginViewModel.js +++ b/src/domain/login/CompleteSSOLoginViewModel.js @@ -46,7 +46,7 @@ export class CompleteSSOLoginViewModel extends ViewModel { const homeserver = await this.platform.settingsStorage.getString("sso_ongoing_login_homeserver"); let loginOptions; try { - loginOptions = await this._sessionContainer.queryLogin(homeserver); + loginOptions = await this._sessionContainer.queryLogin(homeserver).result; } catch (err) { this._showError(err.message); diff --git a/src/domain/login/LoginViewModel.js b/src/domain/login/LoginViewModel.js index cd75ca87..9cdf9290 100644 --- a/src/domain/login/LoginViewModel.js +++ b/src/domain/login/LoginViewModel.js @@ -24,7 +24,7 @@ import {SessionLoadViewModel} from "../SessionLoadViewModel.js"; export class LoginViewModel extends ViewModel { constructor(options) { super(options); - const {ready, defaultHomeServer, createSessionContainer, loginToken} = options; + const {ready, defaultHomeserver, createSessionContainer, loginToken} = options; this._createSessionContainer = createSessionContainer; this._ready = ready; this._loginToken = loginToken; @@ -35,7 +35,8 @@ export class LoginViewModel extends ViewModel { this._completeSSOLoginViewModel = null; this._loadViewModel = null; this._loadViewModelSubscription = null; - this._homeserver = defaultHomeServer; + this._homeserver = defaultHomeserver; + this._queriedHomeserver = null; this._errorMessage = ""; this._hideHomeserver = false; this._isBusy = false; @@ -48,6 +49,7 @@ export class LoginViewModel extends ViewModel { get startSSOLoginViewModel() { return this._startSSOLoginViewModel; } get completeSSOLoginViewModel(){ return this._completeSSOLoginViewModel; } get homeserver() { return this._homeserver; } + get resolvedHomeserver() { return this._loginOptions?.homeserver; } get errorMessage() { return this._errorMessage; } get showHomeserver() { return !this._hideHomeserver; } get loadViewModel() {return this._loadViewModel; } @@ -71,7 +73,7 @@ export class LoginViewModel extends ViewModel { this.emitChange("completeSSOLoginViewModel"); } else { - await this.queryHomeServer(); + await this.queryHomeserver(); } } @@ -156,13 +158,18 @@ export class LoginViewModel extends ViewModel { this.emitChange("disposeViewModels"); } - async setHomeServer(newHomeserver) { + async setHomeserver(newHomeserver) { this._homeserver = newHomeserver; - // abort ongoing query, if any + // clear everything set by queryHomeserver + this._loginOptions = null; + this._queriedHomeserver = null; + this._showError(""); + this._disposeViewModels(); this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); - this.emitChange("isFetchingLoginOptions"); + this.emitChange(); // multiple fields changing + // also clear the timeout if it is still running this.disposeTracked(this._abortHomeserverQueryTimeout); - const timeout = this.clock.createTimeout(2000); + const timeout = this.clock.createTimeout(1000); this._abortHomeserverQueryTimeout = this.track(() => timeout.abort()); try { await timeout.elapsed(); @@ -174,22 +181,30 @@ export class LoginViewModel extends ViewModel { } } this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout); - this.queryHomeServer(); + this.queryHomeserver(); } - async queryHomeServer() { - this._errorMessage = ""; - this.emitChange("errorMessage"); - // if query is called before the typing timeout hits (e.g. field lost focus), cancel the timeout so we don't query again. + async queryHomeserver() { + // don't repeat a query we've just done + if (this._homeserver === this._queriedHomeserver || this._homeserver === "") { + return; + } + this._queriedHomeserver = this._homeserver; + // given that setHomeserver already clears everything set here, + // and that is the only way to change the homeserver, + // we don't need to reset things again here. + // However, clear things set by setHomeserver: + // if query is called before the typing timeout hits (e.g. field lost focus), + // cancel the timeout so we don't query again. this._abortHomeserverQueryTimeout = this.disposeTracked(this._abortHomeserverQueryTimeout); // cancel ongoing query operation, if any this._abortQueryOperation = this.disposeTracked(this._abortQueryOperation); - this._disposeViewModels(); try { const queryOperation = this._sessionContainer.queryLogin(this._homeserver); this._abortQueryOperation = this.track(() => queryOperation.abort()); this.emitChange("isFetchingLoginOptions"); this._loginOptions = await queryOperation.result; + this.emitChange("resolvedHomeserver"); } catch (e) { if (e.name === "AbortError") { @@ -209,7 +224,7 @@ export class LoginViewModel extends ViewModel { } } else { - this._showError("Could not query login methods supported by the homeserver"); + this._showError(`Could not query login methods supported by ${this.homeserver}`); } } diff --git a/src/domain/login/PasswordLoginViewModel.js b/src/domain/login/PasswordLoginViewModel.js index f8d354a4..7ea239e6 100644 --- a/src/domain/login/PasswordLoginViewModel.js +++ b/src/domain/login/PasswordLoginViewModel.js @@ -43,15 +43,14 @@ export class PasswordLoginViewModel extends ViewModel { async login(username, password) { this._errorMessage = ""; this.emitChange("errorMessage"); - const loginMethod = this._loginOptions.password(username, password); - const status = await this._attemptLogin(loginMethod); + const status = await this._attemptLogin(this._loginOptions.password(username, password)); let error = ""; switch (status) { case LoginFailure.Credentials: error = this.i18n`Your username and/or password don't seem to be correct.`; break; case LoginFailure.Connection: - error = this.i18n`Can't connect to ${loginMethod.homeServer}.`; + error = this.i18n`Can't connect to ${this._loginOptions.homeserver}.`; break; case LoginFailure.Unknown: error = this.i18n`Something went wrong while checking your login and password.`; diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index 221e39da..7120f5fb 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -151,7 +151,7 @@ export class SettingsViewModel extends ViewModel { this.pushNotifications.enabledOnServer = null; this.pushNotifications.serverError = null; try { - this.pushNotifications.enabledOnServer = await this._session.checkPusherEnabledOnHomeServer(); + this.pushNotifications.enabledOnServer = await this._session.checkPusherEnabledOnHomeserver(); this.emitChange("pushNotifications.enabledOnServer"); } catch (err) { this.pushNotifications.serverError = err; diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 5a046117..63aece42 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -46,7 +46,7 @@ const PICKLE_KEY = "DEFAULT_KEY"; const PUSHER_KEY = "pusher"; export class Session { - // sessionInfo contains deviceId, userId and homeServer + // sessionInfo contains deviceId, userId and homeserver constructor({storage, hsApi, sessionInfo, olm, olmWorker, platform, mediaRepository}) { this._platform = platform; this._storage = storage; @@ -636,7 +636,7 @@ export class Session { return !!pusherData; } - async checkPusherEnabledOnHomeServer() { + async checkPusherEnabledOnHomeserver() { const readTxn = await this._storage.readTxn([this._storage.storeNames.session]); const pusherData = await readTxn.session.get(PUSHER_KEY); if (!pusherData) { diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index ab20a4eb..f375fdd7 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -1,5 +1,6 @@ /* Copyright 2020 Bruno Windels +Copyright 2020, 2021 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. @@ -15,6 +16,7 @@ limitations under the License. */ import {createEnum} from "../utils/enum.js"; +import {lookupHomeserver} from "./well-known.js"; import {AbortableOperation} from "../utils/AbortableOperation"; import {ObservableValue} from "../observable/ObservableValue.js"; import {HomeServerApi} from "./net/HomeServerApi.js"; @@ -28,14 +30,6 @@ import {PasswordLoginMethod} from "./login/PasswordLoginMethod.js"; import {TokenLoginMethod} from "./login/TokenLoginMethod.js"; import {SSOLoginHelper} from "./login/SSOLoginHelper.js"; -function normalizeHomeserver(homeServer) { - try { - return new URL(homeServer).origin; - } catch (err) { - return new URL(`https://${homeServer}`).origin; - } -} - export const LoadStatus = createEnum( "NotLoading", "Login", @@ -54,7 +48,6 @@ export const LoginFailure = createEnum( "Unknown", ); - export class SessionContainer { constructor({platform, olmPromise, workerPromise}) { this._platform = platform; @@ -102,33 +95,35 @@ export class SessionContainer { }); } - _parseLoginOptions(options, homeServer) { + _parseLoginOptions(options, homeserver) { /* Take server response and return new object which has two props password and sso which implements LoginMethod */ const flows = options.flows; - const result = {}; + const result = {homeserver}; for (const flow of flows) { if (flow.type === "m.login.password") { - result.password = (username, password) => new PasswordLoginMethod({homeServer, username, password}); + result.password = (username, password) => new PasswordLoginMethod({homeserver, username, password}); } else if (flow.type === "m.login.sso" && flows.find(flow => flow.type === "m.login.token")) { - result.sso = new SSOLoginHelper(homeServer); + result.sso = new SSOLoginHelper(homeserver); } else if (flow.type === "m.login.token") { - result.token = loginToken => new TokenLoginMethod({homeServer, loginToken}); + result.token = loginToken => new TokenLoginMethod({homeserver, loginToken}); } } return result; } - queryLogin(homeServer) { - const normalizedHS = normalizeHomeserver(homeServer); - const hsApi = new HomeServerApi({homeServer: normalizedHS, request: this._platform.request}); + queryLogin(homeserver) { return new AbortableOperation(async setAbortable => { + homeserver = await lookupHomeserver(homeserver, (url, options) => { + return setAbortable(this._platform.request(url, options)); + }); + const hsApi = new HomeServerApi({homeserver, request: this._platform.request}); const response = await setAbortable(hsApi.getLoginFlows()).response(); - return this._parseLoginOptions(response, normalizedHS); + return this._parseLoginOptions(response, homeserver); }); } @@ -146,14 +141,15 @@ export class SessionContainer { let sessionInfo; try { const request = this._platform.request; - const hsApi = new HomeServerApi({homeServer: loginMethod.homeServer, request}); + const hsApi = new HomeServerApi({homeserver: loginMethod.homeserver, request}); const loginData = await loginMethod.login(hsApi, "Hydrogen", log); const sessionId = this.createNewSessionId(); sessionInfo = { id: sessionId, deviceId: loginData.device_id, userId: loginData.user_id, - homeServer: loginMethod.homeServer, + homeServer: loginMethod.homeserver, // deprecate this over time + homeserver: loginMethod.homeserver, accessToken: loginData.access_token, lastUsed: clock.now() }; @@ -202,7 +198,7 @@ export class SessionContainer { createMeasure: clock.createMeasure }); const hsApi = new HomeServerApi({ - homeServer: sessionInfo.homeServer, + homeserver: sessionInfo.homeServer, accessToken: sessionInfo.accessToken, request: this._platform.request, reconnector: this._reconnector, @@ -214,7 +210,7 @@ export class SessionContainer { id: sessionInfo.id, deviceId: sessionInfo.deviceId, userId: sessionInfo.userId, - homeServer: sessionInfo.homeServer, + homeserver: sessionInfo.homeServer, }; const olm = await this._olmPromise; let olmWorker = null; @@ -224,7 +220,7 @@ export class SessionContainer { this._requestScheduler = new RequestScheduler({hsApi, clock}); this._requestScheduler.start(); const mediaRepository = new MediaRepository({ - homeServer: sessionInfo.homeServer, + homeserver: sessionInfo.homeServer, platform: this._platform, }); this._session = new Session({ diff --git a/src/matrix/login/LoginMethod.js b/src/matrix/login/LoginMethod.js index fee8e845..ece18871 100644 --- a/src/matrix/login/LoginMethod.js +++ b/src/matrix/login/LoginMethod.js @@ -15,8 +15,8 @@ limitations under the License. */ export class LoginMethod { - constructor({homeServer}) { - this.homeServer = homeServer; + constructor({homeserver}) { + this.homeserver = homeserver; } // eslint-disable-next-line no-unused-vars diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index 8641e374..4b53b28b 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -19,10 +19,10 @@ import {encodeQueryParams, encodeBody} from "./common.js"; import {HomeServerRequest} from "./HomeServerRequest.js"; export class HomeServerApi { - constructor({homeServer, accessToken, request, reconnector}) { + constructor({homeserver, accessToken, request, reconnector}) { // store these both in a closure somehow so it's harder to get at in case of XSS? // one could change the homeserver as well so the token gets sent there, so both must be protected from read/write - this._homeserver = homeServer; + this._homeserver = homeserver; this._accessToken = accessToken; this._requestFn = request; this._reconnector = reconnector; @@ -234,7 +234,7 @@ export function tests() { "superficial happy path for GET": async assert => { const hsApi = new HomeServerApi({ request: () => new MockRequest().respond(200, 42), - homeServer: "https://hs.tld" + homeserver: "https://hs.tld" }); const result = await hsApi._get("foo", null, null, null).response(); assert.strictEqual(result, 42); diff --git a/src/matrix/net/MediaRepository.js b/src/matrix/net/MediaRepository.js index f7e47cfd..3f718c85 100644 --- a/src/matrix/net/MediaRepository.js +++ b/src/matrix/net/MediaRepository.js @@ -18,8 +18,8 @@ import {encodeQueryParams} from "./common.js"; import {decryptAttachment} from "../e2ee/attachment.js"; export class MediaRepository { - constructor({homeServer, platform}) { - this._homeServer = homeServer; + constructor({homeserver, platform}) { + this._homeserver = homeserver; this._platform = platform; } @@ -27,7 +27,7 @@ export class MediaRepository { const parts = this._parseMxcUrl(url); if (parts) { const [serverName, mediaId] = parts; - const httpUrl = `${this._homeServer}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`; + const httpUrl = `${this._homeserver}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`; return httpUrl + "?" + encodeQueryParams({width: Math.round(width), height: Math.round(height), method}); } return null; @@ -37,7 +37,7 @@ export class MediaRepository { const parts = this._parseMxcUrl(url); if (parts) { const [serverName, mediaId] = parts; - return `${this._homeServer}/_matrix/media/r0/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`; + return `${this._homeserver}/_matrix/media/r0/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`; } else { return null; } diff --git a/src/matrix/well-known.js b/src/matrix/well-known.js new file mode 100644 index 00000000..00c91f27 --- /dev/null +++ b/src/matrix/well-known.js @@ -0,0 +1,53 @@ +/* +Copyright 2021 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. +*/ + +function normalizeHomeserver(homeserver) { + try { + return new URL(homeserver).origin; + } catch (err) { + return new URL(`https://${homeserver}`).origin; + } +} + +async function getWellKnownResponse(homeserver, request) { + const requestOptions = {format: "json", timeout: 30000, method: "GET"}; + try { + const wellKnownUrl = `${homeserver}/.well-known/matrix/client`; + return await request(wellKnownUrl, requestOptions).response(); + } catch (err) { + if (err.name === "ConnectionError") { + // don't fail lookup on a ConnectionError, + // there might be a missing CORS header on a 404 response or something, + // which won't be a problem necessarily with homeserver requests later on ... + return null; + } else { + throw err; + } + } +} + +export async function lookupHomeserver(homeserver, request) { + homeserver = normalizeHomeserver(homeserver); + const wellKnownResponse = await getWellKnownResponse(homeserver, request); + if (wellKnownResponse && wellKnownResponse.status === 200) { + const {body} = wellKnownResponse; + const wellKnownHomeserver = body["m.homeserver"]?.["base_url"]; + if (typeof wellKnownHomeserver === "string") { + homeserver = normalizeHomeserver(wellKnownHomeserver); + } + } + return homeserver; +} diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 08f1df06..1b0bc9e4 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -238,6 +238,12 @@ a.button-action { font-size: 1.5rem; } +.LoginView_forwardInfo { + font-size: 0.9em; + margin-left: 1em; + color: #777; +} + .CompleteSSOView_title { font-weight: 500; } diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index 498cb574..aa89ccca 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -43,9 +43,13 @@ export class LoginView extends TemplateView { placeholder: vm.i18n`Your matrix homeserver`, value: vm.homeserver, disabled, - onInput: () => vm.setHomeServer(event.target.value), - onChange: event => vm.queryHomeServer(), + onInput: event => vm.setHomeserver(event.target.value), + onChange: () => vm.queryHomeserver(), }), + t.p({className: { + LoginView_forwardInfo: true, + hidden: vm => !vm.resolvedHomeserver + }}, vm => vm.i18n`You will connect to ${vm.resolvedHomeserver}.`), t.if(vm => vm.errorMessage, (t, vm) => t.p({className: "error"}, vm.i18n(vm.errorMessage))), ] )), diff --git a/src/platform/web/ui/view-gallery.html b/src/platform/web/ui/view-gallery.html index 7887d44f..675b2b5b 100644 --- a/src/platform/web/ui/view-gallery.html +++ b/src/platform/web/ui/view-gallery.html @@ -43,7 +43,7 @@