import * as sentry from '@sentry/electron' ; import * as semver from 'semver' ; import * as digitalocean_api from '../cloud/digitalocean_api' ; import * as errors from '../infrastructure/errors' ; 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 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' ; 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 ) ; const CANCELLED_ERROR = new Error ( 'Cancelled' ) ; export const LAST_DISPLAYED_SERVER_STORAGE_KEY = 'lastDisplayedServer' ; function displayDataAmountToDataLimit ( dataAmount ) { if ( ! dataAmount ) { return null ; } return { bytes : displayDataAmountToBytes ( dataAmount ) } ; } async function computeDefaultDataLimit ( server , accessKeys ? ) { try { let serverTransferCapacity = { 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 } ; } } function hasSeenFeatureMetricsNotification ( ) { return ! ! window . localStorage . getItem ( 'dataLimitsHelpBubble-dismissed' ) && ! ! window . localStorage . getItem ( 'dataLimits-feature-collection-notification' ) ; } async function showHelpBubblesOnce ( serverView ) { if ( ! window . localStorage . getItem ( 'addAccessKeyHelpBubble-dismissed' ) ) { await serverView . showAddAccessKeyHelpBubble ( ) ; window . localStorage . setItem ( 'addAccessKeyHelpBubble-dismissed' , 'true' ) ; } if ( ! window . localStorage . getItem ( 'getConnectedHelpBubble-dismissed' ) ) { await serverView . showGetConnectedHelpBubble ( ) ; window . localStorage . setItem ( 'getConnectedHelpBubble-dismissed' , 'true' ) ; } if ( ! window . localStorage . getItem ( 'dataLimitsHelpBubble-dismissed' ) && serverView . supportsDefaultDataLimit ) { await serverView . showDataLimitsHelpBubble ( ) ; window . localStorage . setItem ( 'dataLimitsHelpBubble-dismissed' , 'true' ) ; } } function isManagedServer ( testServer ) { return ! ! ( testServer as server . ManagedServer ) . getHost ; } function isManualServer ( testServer ) { return ! ! ( testServer as server . ManualServer ) . forget ; } export class App { private digitalOceanAccount : digitalocean . Account ; private gcpAccount : gcp . Account ; private selectedServer : server . Server ; private idServerMap = new Map < string , server . Server > ( ) ; constructor ( private appRoot , private readonly version , private manualServerRepository , private cloudAccounts ) { appRoot . setAttribute ( 'outline-version' , this . version ) ; appRoot . addEventListener ( 'ConnectDigitalOceanAccountRequested' , ( event ) => { this . handleConnectDigitalOceanAccountRequest ( ) ; } ) ; appRoot . addEventListener ( 'CreateDigitalOceanServerRequested' , ( event ) => { 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 ( event ) => this . handleConnectGcpAccountRequest ( ) ) ; appRoot . addEventListener ( 'CreateGcpServerRequested' , async ( event ) => { this . appRoot . getAndShowGcpCreateServerApp ( ) . start ( this . gcpAccount ) ; } ) ; appRoot . addEventListener ( 'GcpServerCreated' , ( event ) => { const server = event . detail . server ; this . addServer ( this . gcpAccount . getId ( ) , server ) ; this . showServer ( server ) ; } ) ; appRoot . addEventListener ( 'DigitalOceanSignOutRequested' , ( event ) => { this . disconnectDigitalOceanAccount ( ) ; this . showIntro ( ) ; } ) ; appRoot . addEventListener ( 'GcpSignOutRequested' , ( event ) => { this . disconnectGcpAccount ( ) ; this . showIntro ( ) ; } ) ; appRoot . addEventListener ( 'SetUpDigitalOceanServerRequested' , ( event ) => { this . createDigitalOceanServer ( event . detail . region ) ; } ) ; appRoot . addEventListener ( 'DeleteServerRequested' , ( event ) => { this . deleteServer ( event . detail . serverId ) ; } ) ; appRoot . addEventListener ( 'ForgetServerRequested' , ( event ) => { this . forgetServer ( event . detail . serverId ) ; } ) ; appRoot . addEventListener ( 'AddAccessKeyRequested' , ( event ) => { this . addAccessKey ( ) ; } ) ; appRoot . addEventListener ( 'RemoveAccessKeyRequested' , ( event ) => { this . removeAccessKey ( event . detail . accessKeyId ) ; } ) ; appRoot . addEventListener ( 'OpenPerKeyDataLimitDialogRequested' , this . openPerKeyDataLimitDialog . bind ( this ) ) ; appRoot . addEventListener ( 'RenameAccessKeyRequested' , ( event ) => { this . renameAccessKey ( event . detail . accessKeyId , event . detail . newName , event . detail . entry ) ; } ) ; appRoot . addEventListener ( 'SetDefaultDataLimitRequested' , ( event ) => { this . setDefaultDataLimit ( displayDataAmountToDataLimit ( event . detail . limit ) ) ; } ) ; appRoot . addEventListener ( 'RemoveDefaultDataLimitRequested' , ( event ) => { this . removeDefaultDataLimit ( ) ; } ) ; appRoot . addEventListener ( 'ChangePortForNewAccessKeysRequested' , ( event ) => { this . setPortForNewAccessKeys ( event . detail . validatedInput , event . detail . ui ) ; } ) ; appRoot . addEventListener ( 'ChangeHostnameForAccessKeysRequested' , ( event ) => { this . setHostnameForAccessKeys ( event . detail . validatedInput , event . detail . ui ) ; } ) ; appRoot . addEventListener ( 'ManualServerEdited' , ( event ) => { let isValid = true ; try { parseManualServerConfig ( event . detail . userInput ) ; } catch ( e ) { isValid = false ; } const manualServerEntryEl = appRoot . getManualServerEntry ( ) ; manualServerEntryEl . enableDoneButton = isValid ; } ) ; appRoot . addEventListener ( 'ManualServerEntered' , ( event ) => { const userInput = event . detail . userInput ; const manualServerEntryEl = appRoot . getManualServerEntry ( ) ; this . createManualServer ( userInput ) . then ( ( ) => { manualServerEntryEl . clear ( ) ; } ) . catch ( ( e ) => { manualServerEntryEl . showConnection = false ; if ( e instanceof errors . UnreachableServerError ) { const errorTitle = appRoot . localize ( 'error-server-unreachable-title' ) ; const errorMessage = appRoot . localize ( 'error-server-unreachable' ) ; this . appRoot . showManualServerError ( errorTitle , errorMessage ) ; } else { let errorMessage = '' ; if ( e . message ) { errorMessage += `${ e . message }\n` ; } if ( userInput ) { errorMessage += userInput ; } appRoot . openManualInstallFeedback ( errorMessage ) ; } } ) ; } ) ; appRoot . addEventListener ( 'EnableMetricsRequested' , ( event ) => { this . setMetricsEnabled ( true ) ; } ) ; appRoot . addEventListener ( 'DisableMetricsRequested' , ( event ) => { this . setMetricsEnabled ( false ) ; } ) ; appRoot . addEventListener ( 'SubmitFeedback' , ( event ) => { const detail = 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 ) => { this . setAppLanguage ( event . detail . languageCode , event . detail . languageDir ) ; } ) ; appRoot . addEventListener ( 'ServerRenameRequested' , ( event ) => { this . renameServer ( event . detail . newName ) ; } ) ; appRoot . addEventListener ( 'CancelServerCreationRequested' , ( event ) => { this . cancelServerCreation ( this . selectedServer ) ; } ) ; appRoot . addEventListener ( 'OpenImageRequested' , ( event ) => { openImage ( event . detail . imagePath ) ; } ) ; appRoot . addEventListener ( 'OpenShareDialogRequested' , ( event ) => { const accessKey = event . detail . accessKey ; this . appRoot . openShareDialog ( accessKey , this . getS3InviteUrl ( accessKey ) ) ; } ) ; appRoot . addEventListener ( 'OpenGetConnectedDialogRequested' , ( event ) => { this . appRoot . openGetConnectedDialog ( this . getS3InviteUrl ( event . detail . accessKey , true ) ) ; } ) ; appRoot . addEventListener ( 'ShowServerRequested' , ( event ) => { const server = this . getServerById ( event . detail . displayServerId ) ; if ( server ) { this . showServer ( server ) ; } else { console . error ( `Could not find server for display server ID ${ event . detail . displayServerId }` ) ; } } ) ; onUpdateDownloaded ( this . displayAppUpdateNotification . bind ( this ) ) ; } private showIntro ( ) { this . appRoot . showIntro ( ) ; } private displayAppUpdateNotification ( ) { this . appRoot . showNotification ( this . appRoot . localize ( 'notification-app-update' ) , 60000 ) ; } async start ( ) { this . showIntro ( ) ; await Promise . all ( [ this . loadDigitalOceanAccount ( this . cloudAccounts . getDigitalOceanAccount ( ) ) , this . loadGcpAccount ( this . cloudAccounts . getGcpAccount ( ) ) , this . loadManualServers ( ) ] ) ; 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 ) { if ( ! digitalOceanAccount ) { return [ ] ; } try { this . digitalOceanAccount = digitalOceanAccount ; this . appRoot . digitalOceanAccount = { id : this . digitalOceanAccount . getId ( ) , name : await this . digitalOceanAccount . getName ( ) , } ; const status = await this . digitalOceanAccount . getStatus ( ) ; if ( status !== digitalocean . Status . ACTIVE ) { return [ ] ; } const servers = await this . digitalOceanAccount . listServers ( ) ; for ( const server of servers ) { this . addServer ( this . digitalOceanAccount . getId ( ) , server ) ; } return servers ; } catch ( error ) { this . appRoot . showError ( this . appRoot . localize ( 'error-do-account-info' ) ) ; console . error ( 'Failed to load DigitalOcean Account:' , error ) ; } return [ ] ; } private async loadGcpAccount ( gcpAccount ) { 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 ) { 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 , server ) { return { id : server . getId ( ) , accountId , name : this . makeDisplayName ( server ) , isSynced : ! ! server . getName ( ) , } ; } private makeDisplayName ( server ) { let name = server . getName ( ) ?? server . getHostnameForAccessKeys ( ) ; if ( ! name ) { let cloudLocation = null ; if ( isManagedServer ( server ) ) { cloudLocation = server . getHost ( ) . getCloudLocation ( ) ; } name = this . makeLocalizedServerName ( cloudLocation ) ; } return name ; } private addServer ( accountId , server ) { 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 ) ; } setTimeout ( async ( ) => { if ( isManagedServer ( server ) ) { try { for await ( const _ of server . monitorInstallProgress ( ) ) { } } catch ( error ) { if ( error instanceof errors . ServerInstallCanceledError ) { return ; } console . log ( 'Server creation failed' , error ) ; this . appRoot . showError ( this . appRoot . localize ( 'error-server-creation' ) ) ; } } await this . updateServerView ( server ) ; this . updateServerEntry ( server ) ; } , 0 ) ; } private removeServer ( serverId ) { 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 ) { this . appRoot . serverList = this . appRoot . serverList . map ( ( ds ) => ds . id === server . getId ( ) ? this . makeServerListEntry ( ds . accountId , server ) : ds ) ; } private getServerById ( serverId ) { return this . idServerMap . get ( serverId ) ; } private async ensureActiveDigitalOceanAccount ( digitalOceanAccount ) { let cancelled = false ; let activatingAccount = false ; const signOutAction = ( ) => { cancelled = true ; this . disconnectDigitalOceanAccount ( ) ; } ; const oauthUi = this . appRoot . getDigitalOceanOauthFlow ( signOutAction ) ; while ( true ) { const status = await this . digitalOceanRetry ( async ( ) => { if ( cancelled ) { throw CANCELLED_ERROR ; } return await digitalOceanAccount . getStatus ( ) ; } ) ; if ( status === digitalocean . Status . ACTIVE ) { bringToFront ( ) ; if ( activatingAccount ) { oauthUi . showAccountActive ( ) ; await sleep ( 1500 ) ; } return ; } this . appRoot . showDigitalOceanOauthFlow ( ) ; activatingAccount = true ; if ( status === digitalocean . Status . MISSING_BILLING_INFORMATION ) { oauthUi . showBilling ( ) ; } else { oauthUi . showEmailVerification ( ) ; } await sleep ( 1000 ) ; if ( this . appRoot . currentPage !== 'digitalOceanOauth' ) { cancelled = true ; } if ( cancelled ) { throw CANCELLED_ERROR ; } } } private digitalOceanRetry = < T > ( f ) : 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 ) => { if ( retry ) { this . digitalOceanRetry ( f ) . then ( resolve , reject ) ; } else { this . disconnectDigitalOceanAccount ( ) ; reject ( e ) ; } } ) ; } ) ; } ) ; } ; private async runDigitalOceanOauthFlow ( ) { const oauth = runDigitalOceanOauth ( ) ; const handleOauthFlowCancelled = ( ) => { oauth . cancel ( ) ; this . disconnectDigitalOceanAccount ( ) ; this . showIntro ( ) ; } ; this . appRoot . getAndShowDigitalOceanOauthFlow ( handleOauthFlowCancelled ) ; try { return await oauth . result ; } catch ( error ) { if ( oauth . isCancelled ( ) ) { throw CANCELLED_ERROR ; } else { throw error ; } } } private async runGcpOauthFlow ( ) { 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 ( ) { let digitalOceanAccount = 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 ( ) { let gcpAccount = 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 ) ; } } private disconnectDigitalOceanAccount ( ) { if ( ! this . digitalOceanAccount ) { 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 ; } private disconnectGcpAccount ( ) { if ( ! this . gcpAccount ) { 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 ; } private async showDigitalOceanCreateServer ( digitalOceanAccount ) { 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 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' ) ) ; } } public async createDigitalOceanServer ( region ) { 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 ) { const placeName = getShortName ( cloudLocation , this . appRoot . localize as ( id : string ) => string ) ; return this . appRoot . localize ( 'server-name' , 'serverLocation' , placeName ) ; } public showServer ( server ) { this . selectedServer = server ; this . appRoot . selectedServerId = server . getId ( ) ; localStorage . setItem ( LAST_DISPLAYED_SERVER_STORAGE_KEY , server . getId ( ) ) ; this . appRoot . showServerView ( ) ; } private async updateServerView ( server ) { if ( await server . isHealthy ( ) ) { this . setServerManagementView ( server ) ; } else { this . setServerUnreachableView ( server ) ; } } private async setServerManagementView ( server ) { 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 ( ) ; setTimeout ( async ( ) => { this . showMetricsOptInWhenNeeded ( server , view ) ; 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 ; } 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 ) { 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 ) { const view = await this . appRoot . getServerView ( server . getId ( ) ) ; view . serverName = this . makeDisplayName ( server ) ; view . selectedPage = 'progressView' ; try { for await ( view . installProgress of server . monitorInstallProgress ( ) ) { } } catch { } } private showMetricsOptInWhenNeeded ( selectedServer , serverView ) { const showMetricsOptInOnce = ( ) => { if ( this . selectedServer !== selectedServer ) { return ; } const storageKey = selectedServer . getMetricsId ( ) + '-prompted-for-metrics' ; if ( ! selectedServer . getMetricsEnabled ( ) && ! localStorage . getItem ( storageKey ) ) { this . appRoot . showMetricsDialogForNewServer ( ) ; localStorage . setItem ( storageKey , 'true' ) ; } } ; const createdDate = selectedServer . getCreatedDate ( ) ; const now = new Date ( ) ; const msSinceCreation = now . getTime ( ) - createdDate . getTime ( ) ; 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 , serverView ) { try { const usageMap = await selectedServer . getDataUsage ( ) ; const keyTransfers = [ ... usageMap . values ( ) ] ; let totalInboundBytes = 0 ; for ( const accessKeyBytes of keyTransfers ) { totalInboundBytes += accessKeyBytes ; } serverView . totalInboundBytes = totalInboundBytes ; 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 ) { if ( e instanceof errors . ServerApiError && e . isNetworkError ( ) ) { return ; } throw e ; } } private showTransferStats ( selectedServer , serverView ) { this . refreshTransferStats ( selectedServer , serverView ) ; const statsRefreshRateMs = 60 * 1000 ; const intervalId = setInterval ( ( ) => { if ( this . selectedServer !== selectedServer ) { clearInterval ( intervalId ) ; return ; } this . refreshTransferStats ( selectedServer , serverView ) ; } , statsRefreshRateMs ) ; } private getS3InviteUrl ( accessUrl , isAdmin = false ) { const adminParam = isAdmin ? '?admin_embed' : '' ; return `https://s3.amazonaws.com/outline-vpn/invite.html${ adminParam }#${ encodeURIComponent ( accessUrl ) }` ; } private convertToUiAccessKey ( remoteAccessKey ) { 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 , newName , entry ) { 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 ) { 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 ) ; 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 ) { 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 , keyId , dataLimitBytes ) { 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 , keyId ) { 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 , serverSettings ) { 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 , serverSettings ) { 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' ) ) ; } } public async createManualServer ( userInput ) { let serverConfig ; try { serverConfig = parseManualServerConfig ( userInput ) ; } catch ( e ) { const msg = `could not parse server config: ${ e . message }` ; console . error ( msg ) ; throw new Error ( msg ) ; } 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 { manualServer . forget ( ) ; console . error ( 'Manual server installed but unreachable.' ) ; throw new errors . UnreachableServerError ( ) ; } } private async removeAccessKey ( accessKeyId ) { 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 ) { 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 ( ( ) => { return serverToDelete . getHost ( ) . delete ( ) ; } ) . then ( ( ) => { this . removeServer ( serverId ) ; this . showIntro ( ) ; this . appRoot . showNotification ( this . appRoot . localize ( 'notification-server-destroyed' ) ) ; } , ( e ) => { if ( ! ( e instanceof digitalocean_api . XhrError ) ) { console . error ( `Failed destroy server: ${ e }` ) ; this . appRoot . showError ( this . appRoot . localize ( 'error-server-destroy' ) ) ; } } ) ; } ) ; } private forgetServer ( serverId ) { 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 ) { const serverView = await this . appRoot . getServerView ( this . appRoot . selectedServerId ) ; try { await this . selectedServer . setMetricsEnabled ( metricsEnabled ) ; this . appRoot . showNotification ( this . appRoot . localize ( 'saved' ) ) ; 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 ) { 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 ; ( view . $ . serverSettings as any ) . serverName = oldName ; } } private cancelServerCreation ( serverToCancel ) { if ( ! isManagedServer ( serverToCancel ) ) { const msg = 'cannot cancel non-ManagedServer' ; console . error ( msg ) ; throw new Error ( msg ) ; } serverToCancel . getHost ( ) . delete ( ) . then ( ( ) => { this . removeServer ( serverToCancel . getId ( ) ) ; this . showIntro ( ) ; } ) ; } private async setAppLanguage ( languageCode , languageDir ) { 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' ) ) ; } } }