mirror of
https://github.com/OutlineFoundation/outline-server.git
synced 2026-08-04 14:37:34 +00:00
1315 lines
50 KiB
TypeScript
1315 lines
50 KiB
TypeScript
// Copyright 2018 The Outline Authors
|
|
//
|
|
// 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 * as Sentry from '@sentry/electron/renderer';
|
|
import * as semver from 'semver';
|
|
|
|
import * as digitalocean_api from '../cloud/digitalocean_api';
|
|
import * as path_api from '../infrastructure/path_api';
|
|
import {sleep} from '../infrastructure/sleep';
|
|
import * as accounts from '../model/accounts';
|
|
import * as digitalocean from '../model/digitalocean';
|
|
import * as gcp from '../model/gcp';
|
|
import * as server_model from '../model/server';
|
|
|
|
import {DisplayDataAmount, displayDataAmountToBytes} from './data_formatting';
|
|
import {filterOptions, getShortName} from './location_formatting';
|
|
import {parseManualServerConfig} from './management_urls';
|
|
import {HttpError} from '../cloud/gcp_api';
|
|
|
|
import type {CloudLocation} from '../model/location';
|
|
import type {AppRoot, ServerListEntry} from './ui_components/app-root';
|
|
import type {FeedbackDetail} from './ui_components/outline-feedback-dialog';
|
|
import type {DisplayAccessKey, ServerView} from './ui_components/outline-server-view';
|
|
import {CustomError} from '../infrastructure/custom_error';
|
|
|
|
// The Outline DigitalOcean team's referral code:
|
|
// https://www.digitalocean.com/help/referral-program/
|
|
//const UNUSED_DIGITALOCEAN_REFERRAL_CODE = '5ddb4219b716';
|
|
|
|
const CHANGE_KEYS_PORT_VERSION = '1.0.0';
|
|
const DATA_LIMITS_VERSION = '1.1.0';
|
|
const CHANGE_HOSTNAME_VERSION = '1.2.0';
|
|
const KEY_SETTINGS_VERSION = '1.6.0';
|
|
const MAX_ACCESS_KEY_DATA_LIMIT_BYTES = 50 * 10 ** 9; // 50GB
|
|
const CANCELLED_ERROR = new Error('Cancelled');
|
|
export const LAST_DISPLAYED_SERVER_STORAGE_KEY = 'lastDisplayedServer';
|
|
|
|
// todo (#1311): we are referencing `@sentry/electron` which won't work for
|
|
// web_app. It's ok for now cuz we don't need to enable Sentry in
|
|
// web_app, but a better solution is to have separate two entry
|
|
// points: electron_main (uses `@sentry/electron`) and web_main
|
|
// (uses `@sentry/browser`).
|
|
// For all other Sentry config see the main process.
|
|
Sentry.init({
|
|
beforeBreadcrumb:
|
|
typeof redactSentryBreadcrumbUrl === 'function' ? redactSentryBreadcrumbUrl : null,
|
|
});
|
|
|
|
function displayDataAmountToDataLimit(
|
|
dataAmount: DisplayDataAmount
|
|
): server_model.DataLimit | null {
|
|
if (!dataAmount) {
|
|
return null;
|
|
}
|
|
|
|
return {bytes: displayDataAmountToBytes(dataAmount)};
|
|
}
|
|
|
|
// Compute the suggested data limit based on the server's transfer capacity and number of access
|
|
// keys.
|
|
async function computeDefaultDataLimit(
|
|
server: server_model.Server,
|
|
accessKeys?: server_model.AccessKey[]
|
|
): Promise<server_model.DataLimit> {
|
|
try {
|
|
// Assume non-managed servers have a data transfer capacity of 1TB.
|
|
let serverTransferCapacity: server_model.DataAmount = {terabytes: 1};
|
|
if (isManagedServer(server)) {
|
|
serverTransferCapacity =
|
|
server.getHost().getMonthlyOutboundTransferLimit() ?? serverTransferCapacity;
|
|
}
|
|
if (!accessKeys) {
|
|
accessKeys = await server.listAccessKeys();
|
|
}
|
|
let dataLimitBytes = (serverTransferCapacity.terabytes * 10 ** 12) / (accessKeys.length || 1);
|
|
if (dataLimitBytes > MAX_ACCESS_KEY_DATA_LIMIT_BYTES) {
|
|
dataLimitBytes = MAX_ACCESS_KEY_DATA_LIMIT_BYTES;
|
|
}
|
|
return {bytes: dataLimitBytes};
|
|
} catch (e) {
|
|
console.error(`Failed to compute default access key data limit: ${e}`);
|
|
return {bytes: MAX_ACCESS_KEY_DATA_LIMIT_BYTES};
|
|
}
|
|
}
|
|
|
|
// Returns whether the user has seen a notification for the updated feature metrics data collection
|
|
// policy.
|
|
function hasSeenFeatureMetricsNotification(): boolean {
|
|
return (
|
|
!!window.localStorage.getItem('dataLimitsHelpBubble-dismissed') &&
|
|
!!window.localStorage.getItem('dataLimits-feature-collection-notification')
|
|
);
|
|
}
|
|
|
|
async function showHelpBubblesOnce(serverView: ServerView) {
|
|
if (!window.localStorage.getItem('getConnectedHelpBubble-dismissed')) {
|
|
await serverView.showGetConnectedHelpBubble();
|
|
window.localStorage.setItem('getConnectedHelpBubble-dismissed', 'true');
|
|
}
|
|
if (!window.localStorage.getItem('addAccessKeyHelpBubble-dismissed')) {
|
|
await serverView.showAddAccessKeyHelpBubble();
|
|
window.localStorage.setItem('addAccessKeyHelpBubble-dismissed', 'true');
|
|
}
|
|
if (
|
|
!window.localStorage.getItem('dataLimitsHelpBubble-dismissed') &&
|
|
serverView.supportsDefaultDataLimit
|
|
) {
|
|
await serverView.showDataLimitsHelpBubble();
|
|
window.localStorage.setItem('dataLimitsHelpBubble-dismissed', 'true');
|
|
}
|
|
}
|
|
|
|
function isManagedServer(
|
|
testServer: server_model.Server
|
|
): testServer is server_model.ManagedServer {
|
|
return !!(testServer as server_model.ManagedServer).getHost;
|
|
}
|
|
|
|
function isManualServer(testServer: server_model.Server): testServer is server_model.ManualServer {
|
|
return !!(testServer as server_model.ManualServer).forget;
|
|
}
|
|
|
|
// Error thrown when a shadowbox server cannot be reached (e.g. due to Firewall)
|
|
class UnreachableServerError extends CustomError {
|
|
constructor(message?: string) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
export class App {
|
|
private digitalOceanAccount: digitalocean.Account;
|
|
private gcpAccount: gcp.Account;
|
|
private selectedServer: server_model.Server;
|
|
private idServerMap = new Map<string, server_model.Server>();
|
|
|
|
constructor(
|
|
private appRoot: AppRoot,
|
|
private readonly version: string,
|
|
private manualServerRepository: server_model.ManualServerRepository,
|
|
private cloudAccounts: accounts.CloudAccounts
|
|
) {
|
|
appRoot.setAttribute('outline-version', this.version);
|
|
|
|
appRoot.addEventListener('ConnectDigitalOceanAccountRequested', (_: CustomEvent) => {
|
|
this.handleConnectDigitalOceanAccountRequest();
|
|
});
|
|
appRoot.addEventListener('CreateDigitalOceanServerRequested', (_: CustomEvent) => {
|
|
const digitalOceanAccount = this.cloudAccounts.getDigitalOceanAccount();
|
|
if (digitalOceanAccount) {
|
|
this.showDigitalOceanCreateServer(digitalOceanAccount);
|
|
} else {
|
|
console.error('Access token not found for server creation');
|
|
this.handleConnectDigitalOceanAccountRequest();
|
|
}
|
|
});
|
|
appRoot.addEventListener('ConnectGcpAccountRequested', async (_: CustomEvent) =>
|
|
this.handleConnectGcpAccountRequest()
|
|
);
|
|
appRoot.addEventListener('CreateGcpServerRequested', async (_: CustomEvent) => {
|
|
this.appRoot.getAndShowGcpCreateServerApp().start(this.gcpAccount);
|
|
});
|
|
appRoot.addEventListener('GcpServerCreated', (event: CustomEvent) => {
|
|
const server = event.detail.server;
|
|
this.addServer(this.gcpAccount.getId(), server);
|
|
this.showServer(server);
|
|
});
|
|
appRoot.addEventListener('DigitalOceanSignOutRequested', (_: CustomEvent) => {
|
|
this.disconnectDigitalOceanAccount();
|
|
this.showIntro();
|
|
});
|
|
appRoot.addEventListener('GcpSignOutRequested', (_: CustomEvent) => {
|
|
this.disconnectGcpAccount();
|
|
this.showIntro();
|
|
});
|
|
|
|
appRoot.addEventListener('SetUpDigitalOceanServerRequested', (event: CustomEvent) => {
|
|
this.createDigitalOceanServer(event.detail.region);
|
|
});
|
|
|
|
appRoot.addEventListener('DeleteServerRequested', (event: CustomEvent) => {
|
|
this.deleteServer(event.detail.serverId);
|
|
});
|
|
|
|
appRoot.addEventListener('ForgetServerRequested', (event: CustomEvent) => {
|
|
this.forgetServer(event.detail.serverId);
|
|
});
|
|
|
|
appRoot.addEventListener('AddAccessKeyRequested', (_: CustomEvent) => {
|
|
this.addAccessKey();
|
|
});
|
|
|
|
appRoot.addEventListener('RemoveAccessKeyRequested', (event: CustomEvent) => {
|
|
this.removeAccessKey(event.detail.accessKeyId);
|
|
});
|
|
|
|
appRoot.addEventListener(
|
|
'OpenPerKeyDataLimitDialogRequested',
|
|
this.openPerKeyDataLimitDialog.bind(this)
|
|
);
|
|
|
|
appRoot.addEventListener('RenameAccessKeyRequested', (event: CustomEvent) => {
|
|
this.renameAccessKey(event.detail.accessKeyId, event.detail.newName, event.detail.entry);
|
|
});
|
|
|
|
appRoot.addEventListener('SetDefaultDataLimitRequested', (event: CustomEvent) => {
|
|
this.setDefaultDataLimit(displayDataAmountToDataLimit(event.detail.limit));
|
|
});
|
|
|
|
appRoot.addEventListener('RemoveDefaultDataLimitRequested', (_: CustomEvent) => {
|
|
this.removeDefaultDataLimit();
|
|
});
|
|
|
|
appRoot.addEventListener('ChangePortForNewAccessKeysRequested', (event: CustomEvent) => {
|
|
this.setPortForNewAccessKeys(event.detail.validatedInput, event.detail.ui);
|
|
});
|
|
|
|
appRoot.addEventListener('ChangeHostnameForAccessKeysRequested', (event: CustomEvent) => {
|
|
this.setHostnameForAccessKeys(event.detail.validatedInput, event.detail.ui);
|
|
});
|
|
|
|
// The UI wants us to validate a server management URL.
|
|
// "Reply" by setting a field on the relevant template.
|
|
appRoot.addEventListener('ManualServerEdited', (event: CustomEvent) => {
|
|
let isValid = true;
|
|
try {
|
|
parseManualServerConfig(event.detail.userInput);
|
|
} catch (e) {
|
|
isValid = false;
|
|
}
|
|
const manualServerEntryEl = appRoot.getManualServerEntry();
|
|
manualServerEntryEl.enableDoneButton = isValid;
|
|
});
|
|
|
|
appRoot.addEventListener('ManualServerEntered', (event: CustomEvent) => {
|
|
const userInput = event.detail.userInput;
|
|
const manualServerEntryEl = appRoot.getManualServerEntry();
|
|
this.createManualServer(userInput)
|
|
.then(() => {
|
|
// Clear fields on outline-manual-server-entry (e.g. dismiss the connecting popup).
|
|
manualServerEntryEl.clear();
|
|
})
|
|
.catch((e: Error) => {
|
|
// Remove the progress indicator.
|
|
manualServerEntryEl.showConnection = false;
|
|
// Display either error dialog or feedback depending on error type.
|
|
if (e instanceof UnreachableServerError) {
|
|
const errorTitle = appRoot.localize('error-server-unreachable-title');
|
|
const errorMessage = appRoot.localize('error-server-unreachable');
|
|
this.appRoot.showManualServerError(errorTitle, errorMessage);
|
|
} else {
|
|
// TODO(alalama): with UI validation, this code path never gets executed. Remove?
|
|
let errorMessage = '';
|
|
if (e.message) {
|
|
errorMessage += `${e.message}\n`;
|
|
}
|
|
if (userInput) {
|
|
errorMessage += userInput;
|
|
}
|
|
appRoot.openManualInstallFeedback(errorMessage);
|
|
}
|
|
});
|
|
});
|
|
|
|
appRoot.addEventListener('EnableMetricsRequested', (_: CustomEvent) => {
|
|
this.setMetricsEnabled(true);
|
|
});
|
|
|
|
appRoot.addEventListener('DisableMetricsRequested', (_: CustomEvent) => {
|
|
this.setMetricsEnabled(false);
|
|
});
|
|
|
|
appRoot.addEventListener('SubmitFeedback', (event: CustomEvent) => {
|
|
const detail: FeedbackDetail = event.detail;
|
|
try {
|
|
Sentry.captureEvent({
|
|
message: detail.userFeedback,
|
|
user: {email: detail.userEmail},
|
|
tags: {category: detail.feedbackCategory, cloudProvider: detail.cloudProvider},
|
|
});
|
|
appRoot.showNotification(appRoot.localize('notification-feedback-thanks'));
|
|
} catch (e) {
|
|
console.error(`Failed to submit feedback: ${e}`);
|
|
appRoot.showError(appRoot.localize('error-feedback'));
|
|
}
|
|
});
|
|
|
|
appRoot.addEventListener('SetLanguageRequested', (event: CustomEvent) => {
|
|
this.setAppLanguage(event.detail.languageCode, event.detail.languageDir);
|
|
});
|
|
|
|
appRoot.addEventListener('ServerRenameRequested', (event: CustomEvent) => {
|
|
this.renameServer(event.detail.newName);
|
|
});
|
|
|
|
appRoot.addEventListener('CancelServerCreationRequested', (_: CustomEvent) => {
|
|
this.cancelServerCreation(this.selectedServer);
|
|
});
|
|
|
|
appRoot.addEventListener('OpenImageRequested', (event: CustomEvent) => {
|
|
openImage(event.detail.imagePath);
|
|
});
|
|
|
|
appRoot.addEventListener('OpenShareDialogRequested', (event: CustomEvent) => {
|
|
const accessKey = event.detail.accessKey;
|
|
this.appRoot.openShareDialog(accessKey, this.getS3InviteUrl(accessKey));
|
|
});
|
|
|
|
appRoot.addEventListener('OpenGetConnectedDialogRequested', (event: CustomEvent) => {
|
|
this.appRoot.openGetConnectedDialog(this.getS3InviteUrl(event.detail.accessKey, true));
|
|
});
|
|
|
|
appRoot.addEventListener('ShowServerRequested', (event: CustomEvent) => {
|
|
const server = this.getServerById(event.detail.displayServerId);
|
|
if (server) {
|
|
this.showServer(server);
|
|
} else {
|
|
// This should never happen if we are managine the list correctly.
|
|
console.error(
|
|
`Could not find server for display server ID ${event.detail.displayServerId}`
|
|
);
|
|
}
|
|
});
|
|
|
|
onUpdateDownloaded(this.displayAppUpdateNotification.bind(this));
|
|
}
|
|
|
|
// Shows the intro screen with overview and options to sign in or sign up.
|
|
private showIntro() {
|
|
this.appRoot.showIntro();
|
|
}
|
|
|
|
private displayAppUpdateNotification() {
|
|
this.appRoot.showNotification(this.appRoot.localize('notification-app-update'), 60000);
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
this.showIntro();
|
|
|
|
// Load connected accounts and servers.
|
|
await Promise.all([
|
|
this.loadDigitalOceanAccount(this.cloudAccounts.getDigitalOceanAccount()),
|
|
this.loadGcpAccount(this.cloudAccounts.getGcpAccount()),
|
|
this.loadManualServers(),
|
|
]);
|
|
|
|
// Show last displayed server, if any.
|
|
const serverIdToSelect = localStorage.getItem(LAST_DISPLAYED_SERVER_STORAGE_KEY);
|
|
if (serverIdToSelect) {
|
|
const serverToShow = this.getServerById(serverIdToSelect);
|
|
if (serverToShow) {
|
|
this.showServer(serverToShow);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async loadDigitalOceanAccount(
|
|
digitalOceanAccount: digitalocean.Account
|
|
): Promise<server_model.ManagedServer[]> {
|
|
if (!digitalOceanAccount) {
|
|
return [];
|
|
}
|
|
let showedWarning = false;
|
|
try {
|
|
this.digitalOceanAccount = digitalOceanAccount;
|
|
this.appRoot.digitalOceanAccount = {
|
|
id: this.digitalOceanAccount.getId(),
|
|
name: await this.digitalOceanAccount.getName(),
|
|
};
|
|
const status = await this.digitalOceanAccount.getStatus();
|
|
if (status.warning) {
|
|
this.showDigitalOceanWarning(status);
|
|
showedWarning = true;
|
|
}
|
|
const servers = await this.digitalOceanAccount.listServers();
|
|
for (const server of servers) {
|
|
this.addServer(this.digitalOceanAccount.getId(), server);
|
|
}
|
|
return servers;
|
|
} catch (error) {
|
|
// TODO(fortuna): Handle expired token.
|
|
if (!showedWarning) {
|
|
this.appRoot.showError(this.appRoot.localize('error-do-account-info'));
|
|
}
|
|
console.error('Failed to load DigitalOcean Account:', error);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
private showDigitalOceanWarning(status: digitalocean.Status) {
|
|
this.appRoot.showError(this.appRoot.localize('error-do-warning', 'message', status.warning));
|
|
}
|
|
|
|
private async loadGcpAccount(gcpAccount: gcp.Account): Promise<server_model.ManagedServer[]> {
|
|
if (!gcpAccount) {
|
|
return [];
|
|
}
|
|
|
|
this.gcpAccount = gcpAccount;
|
|
this.appRoot.gcpAccount = {
|
|
id: this.gcpAccount.getId(),
|
|
name: await this.gcpAccount.getName(),
|
|
};
|
|
|
|
const result = [];
|
|
const gcpProjects = await this.gcpAccount.listProjects();
|
|
for (const gcpProject of gcpProjects) {
|
|
try {
|
|
const servers = await this.gcpAccount.listServers(gcpProject.id);
|
|
for (const server of servers) {
|
|
this.addServer(this.gcpAccount.getId(), server);
|
|
result.push(server);
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof HttpError && e.getStatusCode() === 403) {
|
|
// listServers() throws an HTTP 403 if the outline project has been
|
|
// created but the billing account has been removed, which can
|
|
// easily happen after the free trial period expires. This is
|
|
// harmless, because a project with no billing cannot contain any
|
|
// servers, and the GCP server creation flow will check and correct
|
|
// the billing account setup.
|
|
console.warn(`Ignoring HTTP 403 for GCP project "${gcpProject.id}"`);
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private async loadManualServers() {
|
|
for (const server of await this.manualServerRepository.listServers()) {
|
|
this.addServer(null, server);
|
|
}
|
|
}
|
|
|
|
private makeServerListEntry(accountId: string, server: server_model.Server): ServerListEntry {
|
|
return {
|
|
id: server.getId(),
|
|
accountId,
|
|
name: this.makeDisplayName(server),
|
|
isSynced: !!server.getName(),
|
|
};
|
|
}
|
|
|
|
private makeDisplayName(server: server_model.Server): string {
|
|
let name = server.getName() ?? server.getHostnameForAccessKeys();
|
|
if (!name) {
|
|
let cloudLocation = null;
|
|
// Newly created servers will not have a name.
|
|
if (isManagedServer(server)) {
|
|
cloudLocation = server.getHost().getCloudLocation();
|
|
}
|
|
name = this.makeLocalizedServerName(cloudLocation);
|
|
}
|
|
return name;
|
|
}
|
|
|
|
private addServer(accountId: string, server: server_model.Server): void {
|
|
console.log('Loading server', server);
|
|
this.idServerMap.set(server.getId(), server);
|
|
const serverEntry = this.makeServerListEntry(accountId, server);
|
|
this.appRoot.serverList = this.appRoot.serverList.concat([serverEntry]);
|
|
|
|
if (isManagedServer(server)) {
|
|
this.setServerProgressView(server);
|
|
}
|
|
|
|
// Once the server is added to the list, do the rest asynchronously.
|
|
setTimeout(async () => {
|
|
// Wait for server config to load, then update the server view and list.
|
|
if (isManagedServer(server)) {
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
for await (const _ of server.monitorInstallProgress()) {
|
|
/* empty */
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof server_model.ServerInstallCanceledError) {
|
|
// User clicked "Cancel" on the loading screen.
|
|
return;
|
|
}
|
|
console.log('Server creation failed', error);
|
|
this.appRoot.showError(this.appRoot.localize('error-server-creation'));
|
|
}
|
|
}
|
|
await this.updateServerView(server);
|
|
// This has to run after updateServerView because it depends on the isHealthy() call.
|
|
// TODO(fortuna): Better handle state changes.
|
|
this.updateServerEntry(server);
|
|
}, 0);
|
|
}
|
|
|
|
private removeServer(serverId: string): void {
|
|
this.idServerMap.delete(serverId);
|
|
this.appRoot.serverList = this.appRoot.serverList.filter((ds) => ds.id !== serverId);
|
|
if (this.appRoot.selectedServerId === serverId) {
|
|
this.appRoot.selectedServerId = '';
|
|
this.selectedServer = null;
|
|
localStorage.removeItem(LAST_DISPLAYED_SERVER_STORAGE_KEY);
|
|
}
|
|
}
|
|
|
|
private updateServerEntry(server: server_model.Server): void {
|
|
this.appRoot.serverList = this.appRoot.serverList.map((ds) =>
|
|
ds.id === server.getId() ? this.makeServerListEntry(ds.accountId, server) : ds
|
|
);
|
|
}
|
|
|
|
private getServerById(serverId: string): server_model.Server {
|
|
return this.idServerMap.get(serverId);
|
|
}
|
|
|
|
// Returns a promise that resolves when the account is active.
|
|
// Throws CANCELLED_ERROR on cancellation, and the error on failure.
|
|
private async ensureActiveDigitalOceanAccount(
|
|
digitalOceanAccount: digitalocean.Account
|
|
): Promise<void> {
|
|
let cancelled = false;
|
|
let activatingAccount = false;
|
|
|
|
// TODO(fortuna): Provide a cancel action instead of sign out.
|
|
const signOutAction = () => {
|
|
cancelled = true;
|
|
this.disconnectDigitalOceanAccount();
|
|
};
|
|
const oauthUi = this.appRoot.getDigitalOceanOauthFlow(signOutAction);
|
|
for (;;) {
|
|
const status = await this.digitalOceanRetry(async () => {
|
|
if (cancelled) {
|
|
throw CANCELLED_ERROR;
|
|
}
|
|
return await digitalOceanAccount.getStatus();
|
|
});
|
|
if (status.needsBillingInfo) {
|
|
oauthUi.showBilling();
|
|
} else if (status.needsEmailVerification) {
|
|
oauthUi.showEmailVerification();
|
|
} else {
|
|
if (status.warning) {
|
|
this.showDigitalOceanWarning(status);
|
|
}
|
|
bringToFront();
|
|
if (activatingAccount) {
|
|
// Show the 'account active' screen for a few seconds if the account was activated
|
|
// during this session.
|
|
oauthUi.showAccountActive();
|
|
await sleep(1500);
|
|
}
|
|
return;
|
|
}
|
|
this.appRoot.showDigitalOceanOauthFlow();
|
|
activatingAccount = true;
|
|
await sleep(1000);
|
|
if (this.appRoot.currentPage !== 'digitalOceanOauth') {
|
|
// The user navigated away.
|
|
cancelled = true;
|
|
}
|
|
if (cancelled) {
|
|
throw CANCELLED_ERROR;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Intended to add a "retry or re-authenticate?" prompt to DigitalOcean
|
|
// operations. Specifically, any operation rejecting with an digitalocean_api.XhrError will
|
|
// result in a dialog asking the user whether to retry the operation or
|
|
// re-authenticate against DigitalOcean.
|
|
// This is necessary because an access token may expire or be revoked at
|
|
// any time and there's no way to programmatically distinguish network errors
|
|
// from CORS-type errors (see the comments in DigitalOceanSession for more
|
|
// information).
|
|
// TODO: It would be great if, once the user has re-authenticated, we could
|
|
// return the UI to its exact prior state. Fortunately, the most likely
|
|
// time to discover an invalid access token is when the application
|
|
// starts.
|
|
private digitalOceanRetry = <T>(f: () => Promise<T>): Promise<T> => {
|
|
return f().catch((e) => {
|
|
if (!(e instanceof digitalocean_api.XhrError)) {
|
|
return Promise.reject(e);
|
|
}
|
|
|
|
return new Promise<T>((resolve, reject) => {
|
|
this.appRoot.showConnectivityDialog((retry: boolean) => {
|
|
if (retry) {
|
|
this.digitalOceanRetry(f).then(resolve, reject);
|
|
} else {
|
|
this.disconnectDigitalOceanAccount();
|
|
reject(e);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
};
|
|
|
|
// Runs the DigitalOcean OAuth flow and returns the API access token.
|
|
// Throws CANCELLED_ERROR on cancellation, or the error in case of failure.
|
|
private async runDigitalOceanOauthFlow(): Promise<string> {
|
|
const oauth = runDigitalOceanOauth();
|
|
const handleOauthFlowCancelled = () => {
|
|
oauth.cancel();
|
|
this.disconnectDigitalOceanAccount();
|
|
this.showIntro();
|
|
};
|
|
this.appRoot.getAndShowDigitalOceanOauthFlow(handleOauthFlowCancelled);
|
|
try {
|
|
// DigitalOcean tokens expire after 30 days, unless they are manually
|
|
// revoked by the user. After 30 days the user will have to sign into
|
|
// DigitalOcean again. Note we cannot yet use DigitalOcean refresh
|
|
// tokens, as they require a client_secret to be stored on a server and
|
|
// not visible to end users in client-side JS. More details at:
|
|
// https://developers.digitalocean.com/documentation/oauth/#refresh-token-flow
|
|
return await oauth.result;
|
|
} catch (error) {
|
|
if (oauth.isCancelled()) {
|
|
throw CANCELLED_ERROR;
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Runs the GCP OAuth flow and returns the API refresh token (which can be
|
|
// exchanged for an access token).
|
|
// Throws CANCELLED_ERROR on cancellation, or the error in case of failure.
|
|
private async runGcpOauthFlow(): Promise<string> {
|
|
const oauth = runGcpOauth();
|
|
const handleOauthFlowCancelled = () => {
|
|
oauth.cancel();
|
|
this.disconnectGcpAccount();
|
|
this.showIntro();
|
|
};
|
|
this.appRoot.getAndShowGcpOauthFlow(handleOauthFlowCancelled);
|
|
try {
|
|
return await oauth.result;
|
|
} catch (error) {
|
|
if (oauth.isCancelled()) {
|
|
throw CANCELLED_ERROR;
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
private async handleConnectDigitalOceanAccountRequest(): Promise<void> {
|
|
let digitalOceanAccount: digitalocean.Account = null;
|
|
try {
|
|
const accessToken = await this.runDigitalOceanOauthFlow();
|
|
bringToFront();
|
|
digitalOceanAccount = this.cloudAccounts.connectDigitalOceanAccount(accessToken);
|
|
} catch (error) {
|
|
this.disconnectDigitalOceanAccount();
|
|
this.showIntro();
|
|
bringToFront();
|
|
if (error !== CANCELLED_ERROR) {
|
|
console.error(`DigitalOcean authentication failed: ${error}`);
|
|
this.appRoot.showError(this.appRoot.localize('error-do-auth'));
|
|
}
|
|
return;
|
|
}
|
|
|
|
const doServers = await this.loadDigitalOceanAccount(digitalOceanAccount);
|
|
if (doServers.length > 0) {
|
|
this.showServer(doServers[0]);
|
|
} else {
|
|
await this.showDigitalOceanCreateServer(this.digitalOceanAccount);
|
|
}
|
|
}
|
|
|
|
private async handleConnectGcpAccountRequest(): Promise<void> {
|
|
let gcpAccount: gcp.Account = null;
|
|
try {
|
|
const refreshToken = await this.runGcpOauthFlow();
|
|
bringToFront();
|
|
gcpAccount = this.cloudAccounts.connectGcpAccount(refreshToken);
|
|
} catch (error) {
|
|
this.disconnectGcpAccount();
|
|
this.showIntro();
|
|
bringToFront();
|
|
if (error !== CANCELLED_ERROR) {
|
|
console.error(`GCP authentication failed: ${error}`);
|
|
this.appRoot.showError(this.appRoot.localize('error-gcp-auth'));
|
|
}
|
|
return;
|
|
}
|
|
|
|
const gcpServers = await this.loadGcpAccount(gcpAccount);
|
|
if (gcpServers.length > 0) {
|
|
this.showServer(gcpServers[0]);
|
|
} else {
|
|
this.appRoot.getAndShowGcpCreateServerApp().start(this.gcpAccount);
|
|
}
|
|
}
|
|
|
|
// Clears the DigitalOcean credentials and returns to the intro screen.
|
|
private disconnectDigitalOceanAccount(): void {
|
|
if (!this.digitalOceanAccount) {
|
|
// Not connected.
|
|
return;
|
|
}
|
|
const accountId = this.digitalOceanAccount.getId();
|
|
this.cloudAccounts.disconnectDigitalOceanAccount();
|
|
this.digitalOceanAccount = null;
|
|
for (const serverEntry of this.appRoot.serverList) {
|
|
if (serverEntry.accountId === accountId) {
|
|
this.removeServer(serverEntry.id);
|
|
}
|
|
}
|
|
this.appRoot.digitalOceanAccount = null;
|
|
}
|
|
|
|
// Clears the GCP credentials and returns to the intro screen.
|
|
private disconnectGcpAccount(): void {
|
|
if (!this.gcpAccount) {
|
|
// Not connected.
|
|
return;
|
|
}
|
|
const accountId = this.gcpAccount.getId();
|
|
this.cloudAccounts.disconnectGcpAccount();
|
|
this.gcpAccount = null;
|
|
for (const serverEntry of this.appRoot.serverList) {
|
|
if (serverEntry.accountId === accountId) {
|
|
this.removeServer(serverEntry.id);
|
|
}
|
|
}
|
|
this.appRoot.gcpAccount = null;
|
|
}
|
|
|
|
// Opens the screen to create a server.
|
|
private async showDigitalOceanCreateServer(
|
|
digitalOceanAccount: digitalocean.Account
|
|
): Promise<void> {
|
|
try {
|
|
await this.ensureActiveDigitalOceanAccount(digitalOceanAccount);
|
|
} catch (error) {
|
|
if (this.appRoot.currentPage === 'digitalOceanOauth') {
|
|
this.showIntro();
|
|
}
|
|
if (error !== CANCELLED_ERROR) {
|
|
console.error('Failed to validate DigitalOcean account', error);
|
|
this.appRoot.showError(this.appRoot.localize('error-do-account-info'));
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const status = await digitalOceanAccount.getStatus();
|
|
if (status.hasReachedLimit) {
|
|
this.appRoot.showError(this.appRoot.localize('error-do-limit', 'num', status.dropletLimit));
|
|
return; // Don't proceed to the region picker.
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to check droplet limit status', e);
|
|
}
|
|
|
|
try {
|
|
const regionPicker = this.appRoot.getAndShowRegionPicker();
|
|
const map = await this.digitalOceanRetry(() => {
|
|
return this.digitalOceanAccount.listLocations();
|
|
});
|
|
regionPicker.options = filterOptions(map);
|
|
} catch (e) {
|
|
console.error(`Failed to get list of available regions: ${e}`);
|
|
this.appRoot.showError(this.appRoot.localize('error-do-regions'));
|
|
}
|
|
}
|
|
|
|
// Returns a promise which fulfills once the DigitalOcean droplet is created.
|
|
// Shadowbox may not be fully installed once this promise is fulfilled.
|
|
public async createDigitalOceanServer(region: digitalocean.Region): Promise<void> {
|
|
try {
|
|
const serverName = this.makeLocalizedServerName(region);
|
|
const server = await this.digitalOceanRetry(() => {
|
|
return this.digitalOceanAccount.createServer(region, serverName);
|
|
});
|
|
this.addServer(this.digitalOceanAccount.getId(), server);
|
|
this.showServer(server);
|
|
} catch (error) {
|
|
console.error('Error from createDigitalOceanServer', error);
|
|
this.appRoot.showError(this.appRoot.localize('error-server-creation'));
|
|
}
|
|
}
|
|
|
|
private makeLocalizedServerName(cloudLocation: CloudLocation): string {
|
|
const placeName = getShortName(cloudLocation, this.appRoot.localize as (id: string) => string);
|
|
return this.appRoot.localize('server-name', 'serverLocation', placeName);
|
|
}
|
|
|
|
public showServer(server: server_model.Server): void {
|
|
this.selectedServer = server;
|
|
this.appRoot.selectedServerId = server.getId();
|
|
localStorage.setItem(LAST_DISPLAYED_SERVER_STORAGE_KEY, server.getId());
|
|
this.appRoot.showServerView();
|
|
}
|
|
|
|
private async updateServerView(server: server_model.Server): Promise<void> {
|
|
if (await server.isHealthy()) {
|
|
this.setServerManagementView(server);
|
|
} else {
|
|
this.setServerUnreachableView(server);
|
|
}
|
|
}
|
|
|
|
// Show the server management screen. Assumes the server is healthy.
|
|
private async setServerManagementView(server: server_model.Server): Promise<void> {
|
|
// Show view and initialize fields from selectedServer.
|
|
const view = await this.appRoot.getServerView(server.getId());
|
|
const version = server.getVersion();
|
|
view.selectedPage = 'managementView';
|
|
view.metricsId = server.getMetricsId();
|
|
view.serverHostname = server.getHostnameForAccessKeys();
|
|
view.serverManagementApiUrl = server.getManagementApiUrl();
|
|
view.serverPortForNewAccessKeys = server.getPortForNewAccessKeys();
|
|
view.serverCreationDate = server.getCreatedDate();
|
|
view.serverVersion = version;
|
|
view.defaultDataLimitBytes = server.getDefaultDataLimit()?.bytes;
|
|
view.isDefaultDataLimitEnabled = view.defaultDataLimitBytes !== undefined;
|
|
view.showFeatureMetricsDisclaimer =
|
|
server.getMetricsEnabled() &&
|
|
!server.getDefaultDataLimit() &&
|
|
!hasSeenFeatureMetricsNotification();
|
|
|
|
if (version) {
|
|
view.isAccessKeyPortEditable = semver.gte(version, CHANGE_KEYS_PORT_VERSION);
|
|
view.supportsDefaultDataLimit = semver.gte(version, DATA_LIMITS_VERSION);
|
|
view.isHostnameEditable = semver.gte(version, CHANGE_HOSTNAME_VERSION);
|
|
view.hasPerKeyDataLimitDialog = semver.gte(version, KEY_SETTINGS_VERSION);
|
|
}
|
|
|
|
if (isManagedServer(server)) {
|
|
const host = server.getHost();
|
|
view.monthlyCost = host.getMonthlyCost()?.usd;
|
|
view.monthlyOutboundTransferBytes =
|
|
host.getMonthlyOutboundTransferLimit()?.terabytes * 10 ** 12;
|
|
view.cloudLocation = host.getCloudLocation();
|
|
}
|
|
|
|
view.metricsEnabled = server.getMetricsEnabled();
|
|
|
|
// Asynchronously load "My Connection" and other access keys in order to not block showing the
|
|
// server.
|
|
setTimeout(async () => {
|
|
this.showMetricsOptInWhenNeeded(server);
|
|
try {
|
|
const serverAccessKeys = await server.listAccessKeys();
|
|
view.accessKeyRows = serverAccessKeys.map(this.convertToUiAccessKey.bind(this));
|
|
if (view.defaultDataLimitBytes === undefined) {
|
|
view.defaultDataLimitBytes = (
|
|
await computeDefaultDataLimit(server, serverAccessKeys)
|
|
)?.bytes;
|
|
}
|
|
// Show help bubbles once the page has rendered.
|
|
setTimeout(() => {
|
|
showHelpBubblesOnce(view);
|
|
}, 250);
|
|
} catch (error) {
|
|
console.error(`Failed to load access keys: ${error}`);
|
|
this.appRoot.showError(this.appRoot.localize('error-keys-get'));
|
|
}
|
|
this.showTransferStats(server, view);
|
|
}, 0);
|
|
}
|
|
|
|
private async setServerUnreachableView(server: server_model.Server): Promise<void> {
|
|
// Display the unreachable server state within the server view.
|
|
const serverId = server.getId();
|
|
const serverView = await this.appRoot.getServerView(serverId);
|
|
serverView.selectedPage = 'unreachableView';
|
|
serverView.retryDisplayingServer = async () => {
|
|
await this.updateServerView(server);
|
|
};
|
|
}
|
|
|
|
private async setServerProgressView(server: server_model.ManagedServer): Promise<void> {
|
|
const view = await this.appRoot.getServerView(server.getId());
|
|
view.serverName = this.makeDisplayName(server);
|
|
view.selectedPage = 'progressView';
|
|
try {
|
|
for await (view.installProgress of server.monitorInstallProgress()) {
|
|
/* empty */
|
|
}
|
|
} catch {
|
|
// Ignore any errors; they will be handled by `this.addServer`.
|
|
}
|
|
}
|
|
|
|
private showMetricsOptInWhenNeeded(selectedServer: server_model.Server) {
|
|
const showMetricsOptInOnce = () => {
|
|
// Sanity check to make sure the running server is still displayed, i.e.
|
|
// it hasn't been deleted.
|
|
if (this.selectedServer !== selectedServer) {
|
|
return;
|
|
}
|
|
// Show the metrics opt in prompt if the server has not already opted in,
|
|
// and if they haven't seen the prompt yet according to localStorage.
|
|
const storageKey = selectedServer.getMetricsId() + '-prompted-for-metrics';
|
|
if (!selectedServer.getMetricsEnabled() && !localStorage.getItem(storageKey)) {
|
|
this.appRoot.showMetricsDialogForNewServer();
|
|
localStorage.setItem(storageKey, 'true');
|
|
}
|
|
};
|
|
|
|
// Calculate milliseconds passed since server creation.
|
|
const createdDate = selectedServer.getCreatedDate();
|
|
const now = new Date();
|
|
const msSinceCreation = now.getTime() - createdDate.getTime();
|
|
|
|
// Show metrics opt-in once ONE_DAY_IN_MS has passed since server creation.
|
|
const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;
|
|
if (msSinceCreation >= ONE_DAY_IN_MS) {
|
|
showMetricsOptInOnce();
|
|
} else {
|
|
setTimeout(showMetricsOptInOnce, ONE_DAY_IN_MS - msSinceCreation);
|
|
}
|
|
}
|
|
|
|
private async refreshTransferStats(selectedServer: server_model.Server, serverView: ServerView) {
|
|
try {
|
|
const usageMap = await selectedServer.getDataUsage();
|
|
const keyTransfers = [...usageMap.values()];
|
|
let totalInboundBytes = 0;
|
|
for (const accessKeyBytes of keyTransfers) {
|
|
totalInboundBytes += accessKeyBytes;
|
|
}
|
|
serverView.totalInboundBytes = totalInboundBytes;
|
|
|
|
// Update all the displayed access keys, even if usage didn't change, in case data limits did.
|
|
let keyTransferMax = 0;
|
|
let dataLimitMax = selectedServer.getDefaultDataLimit()?.bytes ?? 0;
|
|
for (const key of await selectedServer.listAccessKeys()) {
|
|
serverView.updateAccessKeyRow(key.id, {
|
|
transferredBytes: usageMap.get(key.id) ?? 0,
|
|
dataLimitBytes: key.dataLimit?.bytes,
|
|
});
|
|
keyTransferMax = Math.max(keyTransferMax, usageMap.get(key.id) ?? 0);
|
|
dataLimitMax = Math.max(dataLimitMax, key.dataLimit?.bytes ?? 0);
|
|
}
|
|
serverView.baselineDataTransfer = Math.max(keyTransferMax, dataLimitMax);
|
|
} catch (e) {
|
|
// Since failures are invisible to users we generally want exceptions here to bubble
|
|
// up and trigger a Sentry report. The exception is network errors, about which we can't
|
|
// do much (note: ShadowboxServer generates a breadcrumb for failures regardless which
|
|
// will show up when someone explicitly submits feedback).
|
|
// TODO(fortuna): the model is leaking implementation details here. We should clean this up
|
|
// Perhaps take a more event-based approach.
|
|
if (e instanceof path_api.ServerApiError && e.isNetworkError()) {
|
|
return;
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
private showTransferStats(selectedServer: server_model.Server, serverView: ServerView) {
|
|
this.refreshTransferStats(selectedServer, serverView);
|
|
// Get transfer stats once per minute for as long as server is selected.
|
|
const statsRefreshRateMs = 60 * 1000;
|
|
const intervalId = setInterval(() => {
|
|
if (this.selectedServer !== selectedServer) {
|
|
// Server is no longer running, stop interval
|
|
clearInterval(intervalId);
|
|
return;
|
|
}
|
|
this.refreshTransferStats(selectedServer, serverView);
|
|
}, statsRefreshRateMs);
|
|
}
|
|
|
|
private getS3InviteUrl(accessUrl: string, isAdmin = false) {
|
|
// TODO(alalama): display the invite in the user's preferred language.
|
|
const adminParam = isAdmin ? '?admin_embed' : '';
|
|
return `https://s3.amazonaws.com/outline-vpn/invite.html${adminParam}#${encodeURIComponent(
|
|
accessUrl
|
|
)}`;
|
|
}
|
|
|
|
// Converts the access key model to the format used by outline-server-view.
|
|
private convertToUiAccessKey(remoteAccessKey: server_model.AccessKey): DisplayAccessKey {
|
|
return {
|
|
id: remoteAccessKey.id,
|
|
placeholderName: this.appRoot.localize('key', 'keyId', remoteAccessKey.id),
|
|
name: remoteAccessKey.name,
|
|
accessUrl: remoteAccessKey.accessUrl,
|
|
transferredBytes: 0,
|
|
dataLimitBytes: remoteAccessKey.dataLimit?.bytes,
|
|
};
|
|
}
|
|
|
|
private async addAccessKey() {
|
|
const server = this.selectedServer;
|
|
try {
|
|
const serverAccessKey = await server.addAccessKey();
|
|
const uiAccessKey = this.convertToUiAccessKey(serverAccessKey);
|
|
const serverView = await this.appRoot.getServerView(server.getId());
|
|
serverView.addAccessKey(uiAccessKey);
|
|
this.appRoot.showNotification(this.appRoot.localize('notification-key-added'));
|
|
} catch (error) {
|
|
console.error(`Failed to add access key: ${error}`);
|
|
this.appRoot.showError(this.appRoot.localize('error-key-add'));
|
|
}
|
|
}
|
|
|
|
private renameAccessKey(accessKeyId: string, newName: string, entry: polymer.Base) {
|
|
this.selectedServer
|
|
.renameAccessKey(accessKeyId, newName)
|
|
.then(() => {
|
|
entry.commitName();
|
|
})
|
|
.catch((error) => {
|
|
console.error(`Failed to rename access key: ${error}`);
|
|
this.appRoot.showError(this.appRoot.localize('error-key-rename'));
|
|
entry.revertName();
|
|
});
|
|
}
|
|
|
|
private async setDefaultDataLimit(limit: server_model.DataLimit) {
|
|
if (!limit) {
|
|
return;
|
|
}
|
|
const previousLimit = this.selectedServer.getDefaultDataLimit();
|
|
if (previousLimit && limit.bytes === previousLimit.bytes) {
|
|
return;
|
|
}
|
|
const serverView = await this.appRoot.getServerView(this.appRoot.selectedServerId);
|
|
try {
|
|
await this.selectedServer.setDefaultDataLimit(limit);
|
|
this.appRoot.showNotification(this.appRoot.localize('saved'));
|
|
serverView.defaultDataLimitBytes = limit?.bytes;
|
|
serverView.isDefaultDataLimitEnabled = true;
|
|
this.refreshTransferStats(this.selectedServer, serverView);
|
|
// Don't display the feature collection disclaimer anymore.
|
|
serverView.showFeatureMetricsDisclaimer = false;
|
|
window.localStorage.setItem('dataLimits-feature-collection-notification', 'true');
|
|
} catch (error) {
|
|
console.error(`Failed to set server default data limit: ${error}`);
|
|
this.appRoot.showError(this.appRoot.localize('error-set-data-limit'));
|
|
const defaultLimit = previousLimit || (await computeDefaultDataLimit(this.selectedServer));
|
|
serverView.defaultDataLimitBytes = defaultLimit?.bytes;
|
|
serverView.isDefaultDataLimitEnabled = !!previousLimit;
|
|
}
|
|
}
|
|
|
|
private async removeDefaultDataLimit() {
|
|
const serverView = await this.appRoot.getServerView(this.appRoot.selectedServerId);
|
|
const previousLimit = this.selectedServer.getDefaultDataLimit();
|
|
try {
|
|
await this.selectedServer.removeDefaultDataLimit();
|
|
serverView.isDefaultDataLimitEnabled = false;
|
|
this.appRoot.showNotification(this.appRoot.localize('saved'));
|
|
this.refreshTransferStats(this.selectedServer, serverView);
|
|
} catch (error) {
|
|
console.error(`Failed to remove server default data limit: ${error}`);
|
|
this.appRoot.showError(this.appRoot.localize('error-remove-data-limit'));
|
|
serverView.isDefaultDataLimitEnabled = !!previousLimit;
|
|
}
|
|
}
|
|
|
|
private openPerKeyDataLimitDialog(
|
|
event: CustomEvent<{
|
|
keyId: string;
|
|
keyDataLimitBytes: number | undefined;
|
|
keyName: string;
|
|
serverId: string;
|
|
defaultDataLimitBytes: number | undefined;
|
|
}>
|
|
) {
|
|
const detail = event.detail;
|
|
const onDataLimitSet = this.savePerKeyDataLimit.bind(this, detail.serverId, detail.keyId);
|
|
const onDataLimitRemoved = this.removePerKeyDataLimit.bind(this, detail.serverId, detail.keyId);
|
|
const activeDataLimitBytes = detail.keyDataLimitBytes ?? detail.defaultDataLimitBytes;
|
|
this.appRoot.openPerKeyDataLimitDialog(
|
|
detail.keyName,
|
|
activeDataLimitBytes,
|
|
onDataLimitSet,
|
|
onDataLimitRemoved
|
|
);
|
|
}
|
|
|
|
private async savePerKeyDataLimit(
|
|
serverId: string,
|
|
keyId: string,
|
|
dataLimitBytes: number
|
|
): Promise<boolean> {
|
|
this.appRoot.showNotification(this.appRoot.localize('saving'));
|
|
const server = this.idServerMap.get(serverId);
|
|
const serverView = await this.appRoot.getServerView(server.getId());
|
|
try {
|
|
await server.setAccessKeyDataLimit(keyId, {bytes: dataLimitBytes});
|
|
this.refreshTransferStats(server, serverView);
|
|
this.appRoot.showNotification(this.appRoot.localize('saved'));
|
|
return true;
|
|
} catch (error) {
|
|
console.error(`Failed to set data limit for access key ${keyId}: ${error}`);
|
|
this.appRoot.showError(this.appRoot.localize('error-set-per-key-limit'));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async removePerKeyDataLimit(serverId: string, keyId: string): Promise<boolean> {
|
|
this.appRoot.showNotification(this.appRoot.localize('saving'));
|
|
const server = this.idServerMap.get(serverId);
|
|
const serverView = await this.appRoot.getServerView(server.getId());
|
|
try {
|
|
await server.removeAccessKeyDataLimit(keyId);
|
|
this.refreshTransferStats(server, serverView);
|
|
this.appRoot.showNotification(this.appRoot.localize('saved'));
|
|
return true;
|
|
} catch (error) {
|
|
console.error(`Failed to remove data limit from access key ${keyId}: ${error}`);
|
|
this.appRoot.showError(this.appRoot.localize('error-remove-per-key-limit'));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async setHostnameForAccessKeys(hostname: string, serverSettings: polymer.Base) {
|
|
this.appRoot.showNotification(this.appRoot.localize('saving'));
|
|
try {
|
|
await this.selectedServer.setHostnameForAccessKeys(hostname);
|
|
this.appRoot.showNotification(this.appRoot.localize('saved'));
|
|
serverSettings.enterSavedState();
|
|
} catch (error) {
|
|
this.appRoot.showError(this.appRoot.localize('error-not-saved'));
|
|
if (error.isNetworkError()) {
|
|
serverSettings.enterErrorState(this.appRoot.localize('error-network'));
|
|
return;
|
|
}
|
|
const message = error.response.status === 400 ? 'error-hostname-invalid' : 'error-unexpected';
|
|
serverSettings.enterErrorState(this.appRoot.localize(message));
|
|
}
|
|
}
|
|
|
|
private async setPortForNewAccessKeys(port: number, serverSettings: polymer.Base) {
|
|
this.appRoot.showNotification(this.appRoot.localize('saving'));
|
|
try {
|
|
await this.selectedServer.setPortForNewAccessKeys(port);
|
|
this.appRoot.showNotification(this.appRoot.localize('saved'));
|
|
serverSettings.enterSavedState();
|
|
} catch (error) {
|
|
this.appRoot.showError(this.appRoot.localize('error-not-saved'));
|
|
if (error.isNetworkError()) {
|
|
serverSettings.enterErrorState(this.appRoot.localize('error-network'));
|
|
return;
|
|
}
|
|
const code = error.response.status;
|
|
if (code === 409) {
|
|
serverSettings.enterErrorState(this.appRoot.localize('error-keys-port-in-use'));
|
|
return;
|
|
}
|
|
serverSettings.enterErrorState(this.appRoot.localize('error-unexpected'));
|
|
}
|
|
}
|
|
|
|
// Returns promise which fulfills when the server is created successfully,
|
|
// or rejects with an error message that can be displayed to the user.
|
|
public async createManualServer(userInput: string): Promise<void> {
|
|
let serverConfig: server_model.ManualServerConfig;
|
|
try {
|
|
serverConfig = parseManualServerConfig(userInput);
|
|
} catch (e) {
|
|
// This shouldn't happen because the UI validates the URL before enabling the DONE button.
|
|
const msg = `could not parse server config: ${e.message}`;
|
|
console.error(msg);
|
|
throw new Error(msg);
|
|
}
|
|
|
|
// Don't let `ManualServerRepository.addServer` throw to avoid redundant error handling if we
|
|
// are adding an existing server. Query the repository instead to treat the UI accordingly.
|
|
const storedServer = this.manualServerRepository.findServer(serverConfig);
|
|
if (storedServer) {
|
|
this.appRoot.showNotification(this.appRoot.localize('notification-server-exists'), 5000);
|
|
this.showServer(storedServer);
|
|
return;
|
|
}
|
|
const manualServer = await this.manualServerRepository.addServer(serverConfig);
|
|
if (await manualServer.isHealthy()) {
|
|
this.addServer(null, manualServer);
|
|
this.showServer(manualServer);
|
|
} else {
|
|
// Remove inaccessible manual server from local storage if it was just created.
|
|
manualServer.forget();
|
|
console.error('Manual server installed but unreachable.');
|
|
throw new UnreachableServerError();
|
|
}
|
|
}
|
|
|
|
private async removeAccessKey(accessKeyId: string) {
|
|
const server = this.selectedServer;
|
|
try {
|
|
await server.removeAccessKey(accessKeyId);
|
|
(await this.appRoot.getServerView(server.getId())).removeAccessKey(accessKeyId);
|
|
this.appRoot.showNotification(this.appRoot.localize('notification-key-removed'));
|
|
} catch (error) {
|
|
console.error(`Failed to remove access key: ${error}`);
|
|
this.appRoot.showError(this.appRoot.localize('error-key-remove'));
|
|
}
|
|
}
|
|
|
|
private deleteServer(serverId: string) {
|
|
const serverToDelete = this.getServerById(serverId);
|
|
if (!isManagedServer(serverToDelete)) {
|
|
const msg = 'cannot delete non-ManagedServer';
|
|
console.error(msg);
|
|
throw new Error(msg);
|
|
}
|
|
|
|
const confirmationTitle = this.appRoot.localize('confirmation-server-destroy-title');
|
|
const confirmationText = this.appRoot.localize('confirmation-server-destroy');
|
|
const confirmationButton = this.appRoot.localize('destroy');
|
|
this.appRoot.getConfirmation(confirmationTitle, confirmationText, confirmationButton, () => {
|
|
this.digitalOceanRetry(() => {
|
|
// TODO: Add an activity indicator in OutlineServerView during deletion.
|
|
return serverToDelete.getHost().delete();
|
|
}).then(
|
|
() => {
|
|
this.removeServer(serverId);
|
|
this.showIntro();
|
|
this.appRoot.showNotification(this.appRoot.localize('notification-server-destroyed'));
|
|
},
|
|
(e) => {
|
|
// Don't show a toast on the login screen.
|
|
if (!(e instanceof digitalocean_api.XhrError)) {
|
|
console.error(`Failed destroy server: ${e}`);
|
|
this.appRoot.showError(this.appRoot.localize('error-server-destroy'));
|
|
}
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
private forgetServer(serverId: string) {
|
|
const serverToForget = this.getServerById(serverId);
|
|
if (!isManualServer(serverToForget)) {
|
|
const msg = 'cannot forget non-ManualServer';
|
|
console.error(msg);
|
|
throw new Error(msg);
|
|
}
|
|
const confirmationTitle = this.appRoot.localize('confirmation-server-remove-title');
|
|
const confirmationText = this.appRoot.localize('confirmation-server-remove');
|
|
const confirmationButton = this.appRoot.localize('remove');
|
|
this.appRoot.getConfirmation(confirmationTitle, confirmationText, confirmationButton, () => {
|
|
serverToForget.forget();
|
|
this.removeServer(serverId);
|
|
this.showIntro();
|
|
this.appRoot.showNotification(this.appRoot.localize('notification-server-removed'));
|
|
});
|
|
}
|
|
|
|
private async setMetricsEnabled(metricsEnabled: boolean) {
|
|
const serverView = await this.appRoot.getServerView(this.appRoot.selectedServerId);
|
|
try {
|
|
await this.selectedServer.setMetricsEnabled(metricsEnabled);
|
|
this.appRoot.showNotification(this.appRoot.localize('saved'));
|
|
// Change metricsEnabled property on polymer element to update display.
|
|
serverView.metricsEnabled = metricsEnabled;
|
|
} catch (error) {
|
|
console.error(`Failed to set metrics enabled: ${error}`);
|
|
this.appRoot.showError(this.appRoot.localize('error-metrics'));
|
|
serverView.metricsEnabled = !metricsEnabled;
|
|
}
|
|
}
|
|
|
|
private async renameServer(newName: string) {
|
|
const serverToRename = this.selectedServer;
|
|
const serverId = this.appRoot.selectedServerId;
|
|
const view = await this.appRoot.getServerView(serverId);
|
|
try {
|
|
await serverToRename.setName(newName);
|
|
view.serverName = newName;
|
|
this.updateServerEntry(serverToRename);
|
|
} catch (error) {
|
|
console.error(`Failed to rename server: ${error}`);
|
|
this.appRoot.showError(this.appRoot.localize('error-server-rename'));
|
|
const oldName = this.selectedServer.getName();
|
|
view.serverName = oldName;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(view.$.serverSettings as any).serverName = oldName;
|
|
}
|
|
}
|
|
|
|
private cancelServerCreation(serverToCancel: server_model.Server): void {
|
|
if (!isManagedServer(serverToCancel)) {
|
|
const msg = 'cannot cancel non-ManagedServer';
|
|
console.error(msg);
|
|
throw new Error(msg);
|
|
}
|
|
// TODO: Make the cancel button show an immediate state transition,
|
|
// indicate that deletion is in-progress, and allow the user to return
|
|
// to server creation in the meantime.
|
|
serverToCancel
|
|
.getHost()
|
|
.delete()
|
|
.then(() => {
|
|
this.removeServer(serverToCancel.getId());
|
|
this.showIntro();
|
|
});
|
|
}
|
|
|
|
private async setAppLanguage(languageCode: string, languageDir: 'rtl' | 'ltr') {
|
|
try {
|
|
await this.appRoot.setLanguage(languageCode, languageDir);
|
|
document.documentElement.setAttribute('dir', languageDir);
|
|
window.localStorage.setItem('overrideLanguage', languageCode);
|
|
} catch (error) {
|
|
this.appRoot.showError(this.appRoot.localize('error-unexpected'));
|
|
}
|
|
}
|
|
}
|