diff --git a/src/domain/rageshake.ts b/src/domain/rageshake.ts new file mode 100644 index 00000000..cb06e638 --- /dev/null +++ b/src/domain/rageshake.ts @@ -0,0 +1,65 @@ +/* +Copyright 2022 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 type {BlobHandle} from "../platform/web/dom/BlobHandle"; +import type {RequestFunction} from "../platform/types/types"; + +// see https://github.com/matrix-org/rageshake#readme +type RageshakeData = { + // A textual description of the problem. Included in the details.log.gz file. + text: string | undefined; + // Application user-agent. Included in the details.log.gz file. + userAgent: string; + // Identifier for the application (eg 'riot-web'). Should correspond to a mapping configured in the configuration file for github issue reporting to work. + app: string; + // Application version. Included in the details.log.gz file. + version: string; + // Label to attach to the github issue, and include in the details file. + label: string | undefined; +}; + +export async function submitLogsToRageshakeServer(data: RageshakeData, logsBlob: BlobHandle, submitUrl: string, request: RequestFunction): Promise { + const formData = new Map(); + if (data.text) { + formData.set("text", data.text); + } + formData.set("user_agent", data.userAgent); + formData.set("app", data.app); + formData.set("version", data.version); + if (data.label) { + formData.set("label", data.label); + } + formData.set("file", {name: "logs.json", blob: logsBlob}); + const headers: Map = new Map(); + headers.set("Accept", "application/json"); + const result = request(submitUrl, { + method: "POST", + body: formData, + headers + }); + let response; + try { + response = await result.response(); + } catch (err) { + throw new Error(`Could not submit logs to ${submitUrl}, got error ${err.message}`); + } + const {status, body} = response; + if (status < 200 || status >= 300) { + throw new Error(`Could not submit logs to ${submitUrl}, got status code ${status} with body ${body}`); + } + // we don't bother with reading report_url from the body as the rageshake server doesn't always return it + // and would have to have CORS setup properly for us to be able to read it. +} diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index d844e21b..4dcdb111 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -16,6 +16,7 @@ limitations under the License. import {ViewModel} from "../../ViewModel"; import {KeyBackupViewModel} from "./KeyBackupViewModel.js"; +import {submitLogsToRageshakeServer} from "../../../domain/rageshake"; class PushNotificationStatus { constructor() { @@ -51,6 +52,7 @@ export class SettingsViewModel extends ViewModel { this.maxSentImageSizeLimit = 4000; this.pushNotifications = new PushNotificationStatus(); this._activeTheme = undefined; + this._logsFeedbackMessage = undefined; } get _session() { @@ -152,6 +154,51 @@ export class SettingsViewModel extends ViewModel { this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`); } + get canSendLogsToServer() { + return !!this.platform.config.bugReportEndpointUrl; + } + + get logsServer() { + const {bugReportEndpointUrl} = this.platform.config; + try { + if (bugReportEndpointUrl) { + return new URL(bugReportEndpointUrl).hostname; + } + } catch (e) {} + return ""; + } + + async sendLogsToServer() { + const {bugReportEndpointUrl} = this.platform.config; + if (bugReportEndpointUrl) { + this._logsFeedbackMessage = this.i18n`Sending logsā€¦`; + this.emitChange(); + try { + const logExport = await this.logger.export(); + await submitLogsToRageshakeServer( + { + app: "hydrogen", + userAgent: this.platform.description, + version: DEFINE_VERSION, + text: `Submit logs from settings for user ${this._session.userId} on device ${this._session.deviceId}`, + }, + logExport.asBlob(), + bugReportEndpointUrl, + this.platform.request + ); + this._logsFeedbackMessage = this.i18n`Logs sent succesfully!`; + this.emitChange(); + } catch (err) { + this._logsFeedbackMessage = err.message; + this.emitChange(); + } + } + } + + get logsFeedbackMessage() { + return this._logsFeedbackMessage; + } + async togglePushNotifications() { this.pushNotifications.updating = true; this.pushNotifications.enabledOnServer = null; diff --git a/src/matrix/net/common.ts b/src/matrix/net/common.ts index 4ba42395..9f0fade4 100644 --- a/src/matrix/net/common.ts +++ b/src/matrix/net/common.ts @@ -17,9 +17,12 @@ limitations under the License. import {BlobHandle} from "../../platform/web/dom/BlobHandle.js"; +export type RequestBody = BlobHandle | string | Map; + export type EncodedBody = { mimeType: string; - body: BlobHandle | string; + // the map gets transformed to a FormData object on the web + body: RequestBody } export function encodeQueryParams(queryParams?: object): string { @@ -41,6 +44,11 @@ export function encodeBody(body: BlobHandle | object): EncodedBody { mimeType: blob.mimeType, body: blob // will be unwrapped in request fn }; + } else if (body instanceof Map) { + return { + mimeType: "multipart/form-data", + body: body + } } else if (typeof body === "object") { const json = JSON.stringify(body); return { diff --git a/src/platform/types/types.ts b/src/platform/types/types.ts index da8ec8e7..1d359a09 100644 --- a/src/platform/types/types.ts +++ b/src/platform/types/types.ts @@ -15,13 +15,13 @@ limitations under the License. */ import type {RequestResult} from "../web/dom/request/fetch.js"; -import type {EncodedBody} from "../../matrix/net/common"; +import type {RequestBody} from "../../matrix/net/common"; import type {ILogItem} from "../../logging/types"; export interface IRequestOptions { uploadProgress?: (loadedBytes: number) => void; timeout?: number; - body?: EncodedBody; + body?: RequestBody; headers?: Map; cache?: boolean; method?: string; diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index dfb26b0f..e2ca2028 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -345,6 +345,10 @@ export class Platform { head.appendChild(styleTag); } + get description() { + return navigator.userAgent ?? ""; + } + dispose() { this._disposables.dispose(); } diff --git a/src/platform/web/assets/config.json b/src/platform/web/assets/config.json index 703ae1e6..fd46fcbc 100644 --- a/src/platform/web/assets/config.json +++ b/src/platform/web/assets/config.json @@ -4,5 +4,6 @@ "gatewayUrl": "https://matrix.org", "applicationServerKey": "BC-gpSdVHEXhvHSHS0AzzWrQoukv2BE7KzpoPO_FfPacqOo3l1pdqz7rSgmB04pZCWaHPz7XRe6fjLaC-WPDopM" }, - "defaultHomeServer": "matrix.org" + "defaultHomeServer": "matrix.org", + "bugReportEndpointUrl": "https://element.io/bugreports/submit" } diff --git a/src/platform/web/dom/request/common.js b/src/platform/web/dom/request/common.js index d97456aa..c073cf6b 100644 --- a/src/platform/web/dom/request/common.js +++ b/src/platform/web/dom/request/common.js @@ -27,6 +27,21 @@ export function addCacheBuster(urlStr, random = Math.random) { return urlStr + `_cacheBuster=${Math.ceil(random() * Number.MAX_SAFE_INTEGER)}`; } +export function mapAsFormData(map) { + const formData = new FormData(); + for (const [name, value] of map) { + let filename; + // Special case {name: string, blob: BlobHandle} to set a filename. + // This is the format returned by platform.openFile + if (value.blob?.nativeBlob && value.name) { + formData.set(name, value.blob.nativeBlob, value.name); + } else { + formData.set(name, value); + } + } + return formData; +} + export function tests() { return { "add cache buster": assert => { diff --git a/src/platform/web/dom/request/fetch.js b/src/platform/web/dom/request/fetch.js index adf833ef..497ad553 100644 --- a/src/platform/web/dom/request/fetch.js +++ b/src/platform/web/dom/request/fetch.js @@ -20,7 +20,7 @@ import { ConnectionError } from "../../../../matrix/error.js"; import {abortOnTimeout} from "../../../../utils/timeout"; -import {addCacheBuster} from "./common.js"; +import {addCacheBuster, mapAsFormData} from "./common.js"; import {xhrRequest} from "./xhr.js"; class RequestResult { @@ -70,6 +70,9 @@ export function createFetchRequest(createTimeout, serviceWorkerHandler) { if (body?.nativeBlob) { body = body.nativeBlob; } + if (body instanceof Map) { + body = mapAsFormData(body); + } let options = {method, body}; if (controller) { options = Object.assign(options, { diff --git a/src/platform/web/dom/request/xhr.js b/src/platform/web/dom/request/xhr.js index c3ad8ae8..fba7123e 100644 --- a/src/platform/web/dom/request/xhr.js +++ b/src/platform/web/dom/request/xhr.js @@ -18,7 +18,7 @@ import { AbortError, ConnectionError } from "../../../../matrix/error.js"; -import {addCacheBuster} from "./common.js"; +import {addCacheBuster, mapAsFormData} from "./common.js"; class RequestResult { constructor(promise, xhr) { @@ -94,6 +94,9 @@ export function xhrRequest(url, options) { if (body?.nativeBlob) { body = body.nativeBlob; } + if (body instanceof Map) { + body = mapAsFormData(body); + } xhr.send(body || null); return new RequestResult(promise, xhr); diff --git a/src/platform/web/ui/general/utils.ts b/src/platform/web/ui/general/utils.ts index f8d407e9..b310571f 100644 --- a/src/platform/web/ui/general/utils.ts +++ b/src/platform/web/ui/general/utils.ts @@ -54,3 +54,11 @@ export function insertAt(parentNode: Element, idx: number, childNode: Node): voi export function removeChildren(parentNode: Element): void { parentNode.innerHTML = ''; } + +export function disableTargetCallback(callback: (evt: Event) => Promise): (evt: Event) => Promise { + return async (evt: Event) => { + (evt.target as HTMLElement)?.setAttribute("disabled", "disabled"); + await callback(evt); + (evt.target as HTMLElement)?.removeAttribute("disabled"); + } +} diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index cf09ddb5..c4405e82 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -15,6 +15,7 @@ limitations under the License. */ import {TemplateView} from "../../general/TemplateView"; +import {disableTargetCallback} from "../../general/utils"; import {KeyBackupSettingsView} from "./KeyBackupSettingsView.js" export class SettingsView extends TemplateView { @@ -101,11 +102,17 @@ export class SettingsView extends TemplateView { return row(t, vm.i18n`Use the following theme`, this._themeOptions(t, vm)); }), ); + const logButtons = []; + if (vm.canSendLogsToServer) { + logButtons.push(t.button({onClick: disableTargetCallback(() => vm.sendLogsToServer())}, `Submit logs to ${vm.logsServer}`)); + } + logButtons.push(t.button({onClick: () => vm.exportLogs()}, "Download logs")); settingNodes.push( t.h3("Application"), row(t, vm.i18n`Version`, version), row(t, vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`), - row(t, vm.i18n`Debug logs`, t.button({onClick: () => vm.exportLogs()}, "Export")), + row(t, vm.i18n`Debug logs`, logButtons), + t.p({className: {hidden: vm => !vm.logsFeedbackMessage}}, vm => vm.logsFeedbackMessage), t.p(["Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited, the usernames of other users and the names of files you send. They do not contain messages. For more information, review our ", t.a({href: "https://element.io/privacy", target: "_blank", rel: "noopener"}, "privacy policy"), "."]), );