feat: move all frontend projects under the same Angular workspace (#1141)

* feat: move all frontend projects under the same Angular workspace

* Refactor/angular workspace (#1154)

* update frontend build steps

Co-authored-by: waterplea <alexander@inkin.ru>
Co-authored-by: Matt Hill <matthewonthemoon@gmail.com>
Co-authored-by: Lucy Cifferello <12953208+elvece@users.noreply.github.com>
This commit is contained in:
Aiden McClelland
2022-01-31 14:01:33 -07:00
committed by GitHub
parent 7e6c852ebd
commit 574539faec
504 changed files with 11569 additions and 78972 deletions

View File

@@ -0,0 +1,49 @@
import { NgModule } from '@angular/core'
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
import { NavGuard, RecoveryNavGuard } from './guards/nav-guard'
const routes: Routes = [
{ path: '', redirectTo: '/product-key', pathMatch: 'full' },
{
path: 'init',
loadChildren: () => import('./pages/init/init.module').then( m => m.InitPageModule),
canActivate: [NavGuard],
},
{
path: 'product-key',
loadChildren: () => import('./pages/product-key/product-key.module').then( m => m.ProductKeyPageModule),
},
{
path: 'home',
loadChildren: () => import('./pages/home/home.module').then( m => m.HomePageModule),
canActivate: [NavGuard],
},
{
path: 'recover',
loadChildren: () => import('./pages/recover/recover.module').then( m => m.RecoverPageModule),
canActivate: [RecoveryNavGuard],
},
{
path: 'embassy',
loadChildren: () => import('./pages/embassy/embassy.module').then( m => m.EmbassyPageModule),
canActivate: [NavGuard],
},
{
path: 'loading',
loadChildren: () => import('./pages/loading/loading.module').then( m => m.LoadingPageModule),
canActivate: [NavGuard],
},
]
@NgModule({
imports: [
RouterModule.forRoot(routes, {
scrollPositionRestoration: 'enabled',
preloadingStrategy: PreloadAllModules,
useHash: true,
initialNavigation: 'disabled',
}),
],
exports: [RouterModule],
})
export class AppRoutingModule { }

View File

@@ -0,0 +1,3 @@
<ion-app>
<ion-router-outlet></ion-router-outlet>
</ion-app>

View File

@@ -0,0 +1,36 @@
import { Component } from '@angular/core'
import { NavController } from '@ionic/angular'
import { ApiService } from './services/api/api.service'
import { ErrorToastService } from './services/error-toast.service'
import { StateService } from './services/state.service'
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styleUrls: ['app.component.scss'],
})
export class AppComponent {
constructor (
private readonly apiService: ApiService,
private readonly errorToastService: ErrorToastService,
private readonly navCtrl: NavController,
private readonly stateService: StateService,
) { }
async ngOnInit () {
try {
const status = await this.apiService.getStatus()
if (status.migrating || status['product-key']) {
this.stateService.hasProductKey = true
this.stateService.isMigrating = status.migrating
await this.navCtrl.navigateForward(`/product-key`)
} else {
this.stateService.hasProductKey = false
this.stateService.isMigrating = false
await this.navCtrl.navigateForward(`/recover`)
}
} catch (e) {
this.errorToastService.present(`${e.message}: ${e.details}`)
}
}
}

View File

@@ -0,0 +1,64 @@
import { ErrorHandler, NgModule } from '@angular/core'
import { BrowserModule } from '@angular/platform-browser'
import { RouteReuseStrategy } from '@angular/router'
import { HttpClientModule } from '@angular/common/http'
import { ApiService } from './services/api/api.service'
import { MockApiService } from './services/api/mock-api.service'
import { LiveApiService } from './services/api/live-api.service'
import { HttpService } from './services/api/http.service'
import {
IonicModule,
IonicRouteStrategy,
iosTransitionAnimation,
} from '@ionic/angular'
import { AppComponent } from './app.component'
import { AppRoutingModule } from './app-routing.module'
import { GlobalErrorHandler } from './services/global-error-handler.service'
import { SuccessPageModule } from './pages/success/success.module'
import { InitPageModule } from './pages/init/init.module'
import { HomePageModule } from './pages/home/home.module'
import { LoadingPageModule } from './pages/loading/loading.module'
import { ProdKeyModalModule } from './modals/prod-key-modal/prod-key-modal.module'
import { ProductKeyPageModule } from './pages/product-key/product-key.module'
import { RecoverPageModule } from './pages/recover/recover.module'
import { WorkspaceConfig } from '@shared'
const { useMocks } = require('../../../../config.json') as WorkspaceConfig
@NgModule({
declarations: [AppComponent],
entryComponents: [],
imports: [
BrowserModule,
IonicModule.forRoot({
mode: 'md',
navAnimation: iosTransitionAnimation,
}),
AppRoutingModule,
HttpClientModule,
SuccessPageModule,
HomePageModule,
LoadingPageModule,
ProdKeyModalModule,
ProductKeyPageModule,
RecoverPageModule,
InitPageModule,
],
providers: [
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
{
provide: ApiService,
useFactory: (http: HttpService) => {
if (useMocks) {
return new MockApiService()
} else {
return new LiveApiService(http)
}
},
deps: [HttpService],
},
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
],
bootstrap: [AppComponent],
})
export class AppModule {}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@angular/core'
import { CanActivate, Router } from '@angular/router'
import { HttpService } from '../services/api/http.service'
import { StateService } from '../services/state.service'
@Injectable({
providedIn: 'root',
})
export class NavGuard implements CanActivate {
constructor (
private readonly router: Router,
private readonly httpService: HttpService,
) { }
canActivate (): boolean {
if (this.httpService.productKey) {
return true
} else {
this.router.navigateByUrl('product-key')
return false
}
}
}
@Injectable({
providedIn: 'root',
})
export class RecoveryNavGuard implements CanActivate {
constructor (
private readonly router: Router,
private readonly httpService: HttpService,
private readonly stateService: StateService,
) { }
canActivate (): boolean {
if (this.httpService.productKey || !this.stateService.hasProductKey) {
return true
} else {
this.router.navigateByUrl('product-key')
return false
}
}
}

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { CifsModal } from './cifs-modal.page'
@NgModule({
declarations: [
CifsModal,
],
imports: [
CommonModule,
FormsModule,
IonicModule,
],
exports: [
CifsModal,
],
})
export class CifsModalModule { }

View File

@@ -0,0 +1,82 @@
<ion-header>
<ion-toolbar>
<ion-title>
Connect Shared Folder
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<form (ngSubmit)="submit()" #cifsForm="ngForm">
<p>Hostname *</p>
<ion-item>
<ion-input
id="hostname"
required
[(ngModel)]="cifs.hostname"
name="hostname"
#hostname="ngModel"
placeholder="e.g. 'My Computer' OR 'my-computer.local'"
pattern="^[a-zA-Z0-9._-]+( [a-zA-Z0-9]+)*$"
></ion-input>
</ion-item>
<p [hidden]="hostname.valid || hostname.pristine">
<ion-text color="danger">Hostname is required. e.g. 'My Computer' OR 'my-computer.local'</ion-text>
</p>
<p>Path *</p>
<ion-item>
<ion-input
id="path"
required
[(ngModel)]="cifs.path"
name="path"
#path="ngModel"
placeholder="ex. /Desktop/my-folder'"
></ion-input>
</ion-item>
<p [hidden]="path.valid || path.pristine">
<ion-text color="danger">Path is required</ion-text>
</p>
<p>Username *</p>
<ion-item>
<ion-input
id="username"
required
[(ngModel)]="cifs.username"
name="username"
#username="ngModel"
placeholder="Enter username"
></ion-input>
</ion-item>
<p [hidden]="username.valid || username.pristine">
<ion-text color="danger">Username is required</ion-text>
</p>
<p>Password</p>
<ion-item>
<ion-input
id="password"
type="password"
[(ngModel)]="cifs.password"
name="password"
#password="ngModel"
></ion-input>
</ion-item>
<button hidden type="submit"></button>
</form>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-button class="ion-padding-end" slot="end" color="dark" fill="clear" (click)="cancel()">
Cancel
</ion-button>
<ion-button class="ion-padding-end" slot="end" color="dark" fill="clear" strong="true" [disabled]="!cifsForm.form.valid" (click)="submit()">
Verify
</ion-button>
</ion-toolbar>
</ion-footer>

View File

@@ -0,0 +1,3 @@
ion-content {
--ion-text-color: var(--ion-color-dark);
}

View File

@@ -0,0 +1,88 @@
import { Component } from '@angular/core'
import { AlertController, LoadingController, ModalController } from '@ionic/angular'
import { ApiService, CifsBackupTarget, EmbassyOSRecoveryInfo } from 'src/app/services/api/api.service'
import { PasswordPage } from '../password/password.page'
@Component({
selector: 'cifs-modal',
templateUrl: 'cifs-modal.page.html',
styleUrls: ['cifs-modal.page.scss'],
})
export class CifsModal {
cifs = {
type: 'cifs' as 'cifs',
hostname: '',
path: '',
username: '',
password: '',
}
constructor (
private readonly modalController: ModalController,
private readonly apiService: ApiService,
private readonly loadingCtrl: LoadingController,
private readonly alertCtrl: AlertController,
) { }
cancel () {
this.modalController.dismiss()
}
async submit (): Promise<void> {
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Connecting to shared folder...',
cssClass: 'loader',
})
await loader.present()
try {
const embassyOS = await this.apiService.verifyCifs(this.cifs)
const is02x = embassyOS.version.startsWith('0.2')
if (is02x) {
this.modalController.dismiss({
cifs: this.cifs,
}, 'success')
} else {
this.presentModalPassword(embassyOS)
}
} catch (e) {
this.presentAlertFailed()
} finally {
loader.dismiss()
}
}
private async presentModalPassword (embassyOS: EmbassyOSRecoveryInfo): Promise<void> {
const target: CifsBackupTarget = {
...this.cifs,
mountable: true,
'embassy-os': embassyOS,
}
const modal = await this.modalController.create({
component: PasswordPage,
componentProps: { target },
cssClass: 'alertlike-modal',
})
modal.onDidDismiss().then(res => {
if (res.role === 'success') {
this.modalController.dismiss({
cifs: this.cifs,
recoveryPassword: res.data.password,
}, 'success')
}
})
await modal.present()
}
private async presentAlertFailed (): Promise<void> {
const alert = await this.alertCtrl.create({
header: 'Connection Failed',
message: 'Unable to connect to shared folder. Ensure (1) target computer is connected to LAN, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.',
buttons: ['OK'],
})
alert.present()
}
}

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { PasswordPage } from './password.page'
@NgModule({
declarations: [
PasswordPage,
],
imports: [
CommonModule,
FormsModule,
IonicModule,
],
exports: [
PasswordPage,
],
})
export class PasswordPageModule { }

View File

@@ -0,0 +1,72 @@
<ion-header>
<ion-toolbar>
<ion-title>
{{ !!storageDrive ? 'Set Password' : 'Unlock Drive' }}
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<div style="padding: 8px 24px;">
<div style="padding-bottom: 16px;">
<ng-container *ngIf="!!storageDrive">
<p>Choose a password for your Embassy. <i>Make it good. Write it down.</i></p>
<p style="color: var(--ion-color-warning);">Losing your password can result in total loss of data.</p>
</ng-container>
<p *ngIf="!storageDrive">Enter the password that was used to encrypt this drive.</p>
</div>
<form (ngSubmit)="!!storageDrive ? submitPw() : verifyPw()">
<p>Password</p>
<ion-item [class]="pwError ? 'error-border' : password && !!storageDrive ? 'success-border' : ''">
<ion-input
#focusInput
[(ngModel)]="password"
[ngModelOptions]="{'standalone': true}"
[type]="!unmasked1 ? 'password' : 'text'"
placeholder="Enter Password"
(ionChange)="validate()"
maxlength="64"
></ion-input>
<ion-button fill="clear" color="light" (click)="unmasked1 = !unmasked1">
<ion-icon slot="icon-only" [name]="unmasked1 ? 'eye-off-outline' : 'eye-outline'" size="small"></ion-icon>
</ion-button>
</ion-item>
<div style="height: 16px;">
<p style="color: var(--ion-color-danger); font-size: x-small;">{{ pwError }}</p>
</div>
<ng-container *ngIf="!!storageDrive">
<p>Confirm Password</p>
<ion-item [class]="verError ? 'error-border' : passwordVer ? 'success-border' : ''">
<ion-input
[(ngModel)]="passwordVer"
[ngModelOptions]="{'standalone': true}"
[type]="!unmasked2 ? 'password' : 'text'"
(ionChange)="checkVer()"
maxlength="64"
placeholder="Retype Password"
></ion-input>
<ion-button fill="clear" color="light" (click)="unmasked2 = !unmasked2">
<ion-icon slot="icon-only" [name]="unmasked2 ? 'eye-off-outline' : 'eye-outline'" size="small"></ion-icon>
</ion-button>
</ion-item>
<div style="height: 16px;">
<p style="color: var(--ion-color-danger); font-size: x-small;">{{ verError }}</p>
</div>
</ng-container>
<input type="submit" style="display: none" />
</form>
</div>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-button class="ion-padding-end" slot="end" color="dark" fill="clear" (click)="cancel()">
Cancel
</ion-button>
<ion-button class="ion-padding-end" slot="end" color="dark" fill="clear" strong="true" (click)="!!storageDrive ? submitPw() : verifyPw()">
{{ !!storageDrive ? 'Finish' : 'Unlock' }}
</ion-button>
</ion-toolbar>
</ion-footer>

View File

@@ -0,0 +1,3 @@
ion-content {
--ion-text-color: var(--ion-color-dark);
}

View File

@@ -0,0 +1,74 @@
import { Component, Input, ViewChild } from '@angular/core'
import { IonInput, ModalController } from '@ionic/angular'
import { DiskInfo, CifsBackupTarget, DiskBackupTarget } from 'src/app/services/api/api.service'
import * as argon2 from '@start9labs/argon2'
@Component({
selector: 'app-password',
templateUrl: 'password.page.html',
styleUrls: ['password.page.scss'],
})
export class PasswordPage {
@ViewChild('focusInput') elem: IonInput
@Input() target: CifsBackupTarget | DiskBackupTarget
@Input() storageDrive: DiskInfo
pwError = ''
password = ''
unmasked1 = false
verError = ''
passwordVer = ''
unmasked2 = false
constructor (
private modalController: ModalController,
) { }
ngAfterViewInit () {
setTimeout(() => this.elem.setFocus(), 400)
}
async verifyPw () {
if (!this.target || !this.target['embassy-os']) this.pwError = 'No recovery target' // unreachable
try {
argon2.verify(this.target['embassy-os']['password-hash'], this.password)
this.modalController.dismiss({ password: this.password }, 'success')
} catch (e) {
this.pwError = 'Incorrect password provided'
}
}
async submitPw () {
this.validate()
if (this.password !== this.passwordVer) {
this.verError = '*passwords do not match'
}
if (this.pwError || this.verError) return
this.modalController.dismiss({ password: this.password }, 'success')
}
validate () {
if (!!this.target) return this.pwError = ''
if (this.passwordVer) {
this.checkVer()
}
if (this.password.length < 12) {
this.pwError = 'Must be 12 characters or greater'
} else {
this.pwError = ''
}
}
checkVer () {
this.verError = this.password !== this.passwordVer ? 'Passwords do not match' : ''
}
cancel () {
this.modalController.dismiss()
}
}

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { ProdKeyModal } from './prod-key-modal.page'
@NgModule({
declarations: [
ProdKeyModal,
],
imports: [
CommonModule,
FormsModule,
IonicModule,
],
exports: [
ProdKeyModal,
],
})
export class ProdKeyModalModule { }

View File

@@ -0,0 +1,41 @@
<ion-header>
<ion-toolbar>
<ion-title>
Enter Product Key
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<form (ngSubmit)="verifyProductKey()">
<div style="padding: 8px 24px;">
<div style="padding-bottom: 16px;">
<p>Enter your 0.2.x Product Key to establish an encrypted connection with your new Embassy.</p>
</div>
<ion-item>
<ion-input
#focusInput
[(ngModel)]="productKey"
placeholder="Enter Product Key"
maxlength="12"
></ion-input>
</ion-item>
<div style="height: 16px;">
<p style="color: var(--ion-color-danger); font-size: x-small;">{{ error }}</p>
</div>
</div>
<input type="submit" style="display: none" />
</form>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-button class="ion-padding-end" slot="end" color="dark" fill="clear" (click)="cancel()">
Cancel
</ion-button>
<ion-button class="ion-padding-end" slot="end" color="dark" fill="clear" strong="true" (click)="verifyProductKey()">
Submit
</ion-button>
</ion-toolbar>
</ion-footer>

View File

@@ -0,0 +1,3 @@
ion-content {
--ion-text-color: var(--ion-color-dark);
}

View File

@@ -0,0 +1,54 @@
import { Component, Input, ViewChild } from '@angular/core'
import { IonInput, LoadingController, ModalController } from '@ionic/angular'
import { ApiService, DiskBackupTarget } from 'src/app/services/api/api.service'
import { HttpService } from 'src/app/services/api/http.service'
@Component({
selector: 'prod-key-modal',
templateUrl: 'prod-key-modal.page.html',
styleUrls: ['prod-key-modal.page.scss'],
})
export class ProdKeyModal {
@ViewChild('focusInput') elem: IonInput
@Input() target: DiskBackupTarget
error = ''
productKey = ''
unmasked = false
constructor (
private readonly modalController: ModalController,
private readonly apiService: ApiService,
private readonly loadingCtrl: LoadingController,
private readonly httpService: HttpService,
) { }
ngAfterViewInit () {
setTimeout(() => this.elem.setFocus(), 400)
}
async verifyProductKey () {
if (!this.productKey) return
const loader = await this.loadingCtrl.create({
message: 'Verifying Product Key',
})
await loader.present()
try {
await this.apiService.set02XDrive(this.target.logicalname)
this.httpService.productKey = this.productKey
await this.apiService.verifyProductKey()
this.modalController.dismiss({ productKey: this.productKey }, 'success')
} catch (e) {
this.httpService.productKey = undefined
this.error = 'Invalid Product Key'
} finally {
loader.dismiss()
}
}
cancel () {
this.modalController.dismiss()
}
}

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { EmbassyPage } from './embassy.page'
const routes: Routes = [
{
path: '',
component: EmbassyPage,
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class EmbassyPageRoutingModule { }

View File

@@ -0,0 +1,21 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { EmbassyPage } from './embassy.page'
import { PasswordPageModule } from '../../modals/password/password.module'
import { EmbassyPageRoutingModule } from './embassy-routing.module'
import { PipesModule } from 'src/app/pipes/pipe.module'
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
EmbassyPageRoutingModule,
PasswordPageModule,
PipesModule,
],
declarations: [EmbassyPage],
})
export class EmbassyPageModule { }

View File

@@ -0,0 +1,55 @@
<ion-content>
<ion-grid>
<ion-row>
<ion-col class="ion-text-center">
<div style="padding-bottom: 32px;">
<img src="assets/img/logo.png" style="max-width: 240px;" />
</div>
<ion-card color="dark">
<ion-card-header class="ion-text-center" style="padding-bottom: 8px;" *ngIf="loading || storageDrives.length; else empty">
<ion-card-title>Select Storage Drive</ion-card-title>
<ion-card-subtitle>Select the drive where your Embassy data will be stored.</ion-card-subtitle>
</ion-card-header>
<ng-template #empty>
<ion-card-header class="ion-text-center" style="padding-bottom: 8px;">
<ion-card-title>No drives found</ion-card-title>
<ion-card-subtitle>Please connect a storage drive to your Embassy and click "Refresh".</ion-card-subtitle>
</ion-card-header>
</ng-template>
<ion-card-content class="ion-margin">
<!-- loading -->
<ion-spinner *ngIf="loading; else loaded" class="center-spinner" name="lines"></ion-spinner>
<!-- not loading -->
<ng-template #loaded>
<ng-container *ngIf="!storageDrives.length">
<ion-button fill="clear" color="primary" (click)="getDrives()">
<ion-icon slot="start" name='refresh'></ion-icon>
Refresh
</ion-button>
</ng-container>
<ion-item-group *ngIf="storageDrives.length">
<ion-item (click)="chooseDrive(drive)" class="ion-margin-bottom" [disabled]="tooSmall(drive)" button lines="none" *ngFor="let drive of storageDrives">
<ion-icon slot="start" name="save-outline" size="large" color="light"></ion-icon>
<ion-label class="ion-text-wrap">
<h1>{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model || 'Unknown Model' }}</h1>
<h2>{{ drive.logicalname }} - {{ drive.capacity | convertBytes }}</h2>
<p *ngIf=tooSmall(drive)>
<ion-text color="danger">
Drive capacity too small.
</ion-text>
</p>
</ion-label>
</ion-item>
</ion-item-group>
</ng-template>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

@@ -0,0 +1,134 @@
import { Component } from '@angular/core'
import { AlertController, LoadingController, ModalController, NavController } from '@ionic/angular'
import { ApiService, DiskInfo, DiskRecoverySource } from 'src/app/services/api/api.service'
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { StateService } from 'src/app/services/state.service'
import { PasswordPage } from '../../modals/password/password.page'
@Component({
selector: 'app-embassy',
templateUrl: 'embassy.page.html',
styleUrls: ['embassy.page.scss'],
})
export class EmbassyPage {
storageDrives: DiskInfo[] = []
loading = true
constructor (
private readonly apiService: ApiService,
private readonly navCtrl: NavController,
private readonly modalController: ModalController,
private readonly alertCtrl: AlertController,
private readonly stateService: StateService,
private readonly loadingCtrl: LoadingController,
private readonly errorToastService: ErrorToastService,
) { }
async ngOnInit () {
await this.getDrives()
}
tooSmall (drive: DiskInfo) {
return drive.capacity < 34359738368
}
async refresh () {
this.loading = true
await this.getDrives()
}
async getDrives () {
this.loading = true
try {
const { disks, reconnect } = await this.apiService.getDrives()
this.storageDrives = disks.filter(d => !d.partitions.map(p => p.logicalname).includes((this.stateService.recoverySource as DiskRecoverySource)?.logicalname))
if (!this.storageDrives.length && reconnect.length) {
const list = `<ul>${reconnect.map(recon => `<li>${recon}</li>`)}</ul>`
const alert = await this.alertCtrl.create({
header: 'Warning',
message: `One or more devices you connected had to be reconfigured to support the current hardware platform. Please unplug and replug the following device(s), then refresh the page:<br> ${list}`,
buttons: [
{
role: 'cancel',
text: 'OK',
},
],
})
await alert.present()
}
} catch (e) {
this.errorToastService.present(e.message)
} finally {
this.loading = false
}
}
async chooseDrive (drive: DiskInfo) {
if (!!drive.partitions.find(p => p.used) || !!drive.guid) {
const alert = await this.alertCtrl.create({
header: 'Warning',
subHeader: 'Drive contains data!',
message: 'All data stored on this drive will be permanently deleted.',
buttons: [
{
role: 'cancel',
text: 'Cancel',
},
{
text: 'Continue',
handler: () => {
if (this.stateService.recoveryPassword) {
this.setupEmbassy(drive, this.stateService.recoveryPassword)
} else {
this.presentModalPassword(drive)
}
},
},
],
})
await alert.present()
} else {
if (this.stateService.recoveryPassword) {
this.setupEmbassy(drive, this.stateService.recoveryPassword)
} else {
this.presentModalPassword(drive)
}
}
}
private async presentModalPassword (drive: DiskInfo): Promise<void> {
const modal = await this.modalController.create({
component: PasswordPage,
componentProps: {
storageDrive: drive,
},
})
modal.onDidDismiss().then(async ret => {
if (!ret.data || !ret.data.password) return
this.setupEmbassy(drive, ret.data.password)
})
await modal.present()
}
private async setupEmbassy (drive: DiskInfo, password: string): Promise<void> {
const loader = await this.loadingCtrl.create({
message: 'Transferring encrypted data. This could take a while...',
})
await loader.present()
try {
await this.stateService.setupEmbassy(drive.logicalname, password)
if (!!this.stateService.recoverySource) {
await this.navCtrl.navigateForward(`/loading`)
} else {
await this.navCtrl.navigateForward(`/init`)
}
} catch (e) {
this.errorToastService.present(`${e.message}: ${e.details}. Restart Embassy to try again.`)
console.error(e)
} finally {
loader.dismiss()
}
}
}

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { HomePage } from './home.page'
const routes: Routes = [
{
path: '',
component: HomePage,
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class HomePageRoutingModule { }

View File

@@ -0,0 +1,21 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { HomePage } from './home.page'
import { PasswordPageModule } from '../../modals/password/password.module'
import { HomePageRoutingModule } from './home-routing.module'
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
HomePageRoutingModule,
PasswordPageModule,
],
declarations: [HomePage],
})
export class HomePageModule { }

View File

@@ -0,0 +1,40 @@
<ion-content>
<ion-grid>
<ion-row>
<ion-col class="ion-text-center">
<div style="padding-bottom: 32px;">
<img src="assets/img/logo.png" style="max-width: 240px;" />
</div>
<ion-card color="dark">
<ion-card-content class="ion-margin">
<!-- fresh -->
<ion-card
routerLink="/embassy"
color="light"
style="text-align: center; background-color: #00919b !important; height: 160px; margin-bottom: 20px; box-shadow: 4px 4px 16px var(--ion-color-light);"
>
<ion-card-header>
<ion-card-title style="font-size: 40px;">Start Fresh</ion-card-title>
<ion-card-subtitle>Get started with a brand new Embassy</ion-card-subtitle>
</ion-card-header>
<!-- recover -->
</ion-card>
<ion-card
routerLink="/recover"
color="light"
style="text-align: center; background-color: #bf5900 !important; height: 160px; box-shadow: 4px 4px 16px var(--ion-color-light);"
>
<ion-card-header>
<ion-card-title style="font-size: 40px;">Recover</ion-card-title>
<ion-card-subtitle>Restore from backup or recover an old Embassy</ion-card-subtitle>
</ion-card-header>
</ion-card>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

@@ -0,0 +1,9 @@
import { Component } from '@angular/core'
@Component({
selector: 'app-home',
templateUrl: 'home.page.html',
styleUrls: ['home.page.scss'],
})
export class HomePage { }

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { InitPage } from './init.page'
const routes: Routes = [
{
path: '',
component: InitPage,
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class InitPageRoutingModule { }

View File

@@ -0,0 +1,18 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { InitPage } from './init.page'
import { InitPageRoutingModule } from './init-routing.module'
import { SuccessPageModule } from '../success/success.module'
@NgModule({
imports: [
CommonModule,
IonicModule,
InitPageRoutingModule,
SuccessPageModule,
],
declarations: [InitPage],
exports: [InitPage],
})
export class InitPageModule { }

View File

@@ -0,0 +1,26 @@
<ion-content>
<ion-grid>
<ion-row>
<ion-col class="ion-text-center">
<div style="padding-bottom: 32px;">
<img src="assets/img/logo.png" style="max-width: 240px;" />
</div>
<success [hidden]="!stateService.embassyLoaded" (onDownload)="download()"></success>
<ion-card [hidden]="stateService.embassyLoaded" color="dark">
<ion-card-header>
<ion-card-title style="font-size: 40px;">Initializing Embassy</ion-card-title>
<ion-card-subtitle>Progress: {{ progress }}%</ion-card-subtitle>
</ion-card-header>
<ion-card-content class="ion-margin">
<ion-progress-bar color="primary" style="max-width: 700px; margin: auto; padding-bottom: 20px; margin-bottom: 40px;" [value]="progress / 100"></ion-progress-bar>
<p class="ion-text-start">After completion, you will be prompted to download a file from your Embassy. Save the file somewhere safe, it is the easiest way to recover your Embassy's addresses and SSL certificate in case you lose them.</p>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

@@ -0,0 +1,61 @@
import { Component } from '@angular/core'
import { interval, Subscription } from 'rxjs'
import { finalize, take, tap } from 'rxjs/operators'
import { ApiService } from 'src/app/services/api/api.service'
import { StateService } from 'src/app/services/state.service'
@Component({
selector: 'app-init',
templateUrl: 'init.page.html',
styleUrls: ['init.page.scss'],
})
export class InitPage {
progress = 0
sub: Subscription
constructor (
private readonly apiService: ApiService,
public readonly stateService: StateService,
) { }
ngOnInit () {
// call setup.complete to tear down embassy.local and spin up embassy-[id].local
this.apiService.setupComplete()
this.sub = interval(130)
.pipe(
take(101),
tap(num => {
this.progress = num
}),
finalize(() => {
setTimeout(() => {
this.stateService.embassyLoaded = true
this.download()
}, 500)
}),
).subscribe()
}
ngOnDestroy () {
if (this.sub) this.sub.unsubscribe()
}
download () {
document.getElementById('tor-addr').innerHTML = this.stateService.torAddress
document.getElementById('lan-addr').innerHTML = this.stateService.lanAddress
document.getElementById('cert').setAttribute('href', 'data:application/x-x509-ca-cert;base64,' + encodeURIComponent(this.stateService.cert))
let html = document.getElementById('downloadable').innerHTML
const filename = 'embassy-info.html'
const elem = document.createElement('a')
elem.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(html))
elem.setAttribute('download', filename)
elem.style.display = 'none'
document.body.appendChild(elem)
elem.click()
document.body.removeChild(elem)
}
}

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { LoadingPage } from './loading.page'
const routes: Routes = [
{
path: '',
component: LoadingPage,
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class LoadingPageRoutingModule { }

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { LoadingPage } from './loading.page'
import { LoadingPageRoutingModule } from './loading-routing.module'
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
LoadingPageRoutingModule,
],
declarations: [LoadingPage],
})
export class LoadingPageModule { }

View File

@@ -0,0 +1,24 @@
<ion-content color="light">
<ion-grid>
<ion-row>
<ion-col class="ion-text-center">
<div style="padding-bottom: 32px;">
<img src="assets/img/logo.png" style="max-width: 240px;" />
</div>
<ion-card color="dark">
<ion-card-header>
<ion-card-title style="font-size: 40px;">Recovering</ion-card-title>
<ion-card-subtitle>Progress: {{ (stateService.dataProgress * 100).toFixed(0) }}%</ion-card-subtitle>
</ion-card-header>
<ion-card-content class="ion-margin">
<ion-progress-bar color="primary" style="max-width: 700px; margin: auto; padding-bottom: 20px; margin-bottom: 40px;" [value]="stateService.dataProgress"></ion-progress-bar>
<p class="ion-text-start">After completion, you will be prompted to download a file from your Embassy. Save the file somewhere safe, it is the easiest way to recover your Embassy's addresses and SSL certificate in case you lose them.</p>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

@@ -0,0 +1,26 @@
import { Component } from '@angular/core'
import { NavController } from '@ionic/angular'
import { StateService } from 'src/app/services/state.service'
@Component({
selector: 'app-loading',
templateUrl: 'loading.page.html',
styleUrls: ['loading.page.scss'],
})
export class LoadingPage {
constructor (
public stateService: StateService,
private navCtrl: NavController,
) { }
ngOnInit () {
this.stateService.pollDataTransferProgress()
const progSub = this.stateService.dataCompletionSubject.subscribe(async complete => {
if (complete) {
progSub.unsubscribe()
await this.navCtrl.navigateForward(`/init`)
}
})
}
}

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { ProductKeyPage } from './product-key.page'
const routes: Routes = [
{
path: '',
component: ProductKeyPage,
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class ProductKeyPageRoutingModule { }

View File

@@ -0,0 +1,19 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { ProductKeyPage } from './product-key.page'
import { PasswordPageModule } from '../../modals/password/password.module'
import { ProductKeyPageRoutingModule } from './product-key-routing.module'
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
ProductKeyPageRoutingModule,
PasswordPageModule,
],
declarations: [ProductKeyPage],
})
export class ProductKeyPageModule { }

View File

@@ -0,0 +1,43 @@
<ion-content>
<ion-grid>
<ion-row>
<ion-col class="ion-text-center">
<div style="padding-bottom: 32px;">
<img src="assets/img/logo.png" style="max-width: 240px;" />
</div>
<ion-card color="dark">
<ion-card-header style="padding-bottom: 8px;">
<ion-card-title>Product Key</ion-card-title>
<ion-card-subtitle>Enter your product key to establish an encrypted connection with your Embassy</ion-card-subtitle>
</ion-card-header>
<ion-card-content class="ion-margin">
<form (submit)="submit()" style="margin-bottom: 12px;">
<ion-item-group class="ion-padding-bottom">
<ion-item color="dark">
<ion-icon slot="start" name="key-outline" style="margin-right: 16px;"></ion-icon>
<ion-input
#focusInput
name="productKey"
[(ngModel)]="productKey"
(ionChange)="error = ''"
maxlength="12"
>
</ion-input>
</ion-item>
<div class="ion-text-left">
<p *ngIf="error" style="padding-top: 4px"><ion-text color="danger">{{ error }}</ion-text></p>
</div>
</ion-item-group>
<ion-button type="submit" color="light" class="claim-button">
Submit
</ion-button>
</form>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

@@ -0,0 +1,5 @@
ion-item {
--border-style: solid;
--border-width: 1px;
--border-color: var(--ion-color-medium);
}

View File

@@ -0,0 +1,53 @@
import { Component, ViewChild } from '@angular/core'
import { IonInput, LoadingController, NavController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/api.service'
import { HttpService } from 'src/app/services/api/http.service'
import { StateService } from 'src/app/services/state.service'
@Component({
selector: 'app-product-key',
templateUrl: 'product-key.page.html',
styleUrls: ['product-key.page.scss'],
})
export class ProductKeyPage {
@ViewChild('focusInput') elem: IonInput
productKey: string
error: string
constructor (
private readonly navCtrl: NavController,
private readonly stateService: StateService,
private readonly apiService: ApiService,
private readonly loadingCtrl: LoadingController,
private readonly httpService: HttpService,
) { }
ionViewDidEnter () {
setTimeout(() => this.elem.setFocus(), 400)
}
async submit () {
if (!this.productKey) return this.error = 'Must enter product key'
const loader = await this.loadingCtrl.create({
message: 'Verifying Product Key',
})
await loader.present()
try {
this.httpService.productKey = this.productKey
await this.apiService.verifyProductKey()
if (this.stateService.isMigrating) {
await this.navCtrl.navigateForward(`/loading`)
} else {
await this.navCtrl.navigateForward(`/home`)
}
} catch (e) {
this.error = 'Invalid Product Key'
this.httpService.productKey = undefined
} finally {
loader.dismiss()
}
}
}

View File

@@ -0,0 +1,14 @@
<div class="inline">
<!-- has backup -->
<h2 *ngIf="hasValidBackup; else noBackup">
<ion-icon name="cloud-done" color="success"></ion-icon>
{{ is02x ? 'Embassy 0.2.x detected' : 'Embassy backup detected' }}
</h2>
<!-- no backup -->
<ng-template #noBackup>
<h2>
<ion-icon name="cloud-offline" color="danger"></ion-icon>
No Embassy backup
</h2>
</ng-template>
</div>

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { RecoverPage } from './recover.page'
const routes: Routes = [
{
path: '',
component: RecoverPage,
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class RecoverPageRoutingModule { }

View File

@@ -0,0 +1,25 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { DriveStatusComponent, RecoverPage } from './recover.page'
import { PasswordPageModule } from '../../modals/password/password.module'
import { ProdKeyModalModule } from '../../modals/prod-key-modal/prod-key-modal.module'
import { RecoverPageRoutingModule } from './recover-routing.module'
import { PipesModule } from 'src/app/pipes/pipe.module'
import { CifsModalModule } from 'src/app/modals/cifs-modal/cifs-modal.module'
@NgModule({
declarations: [RecoverPage, DriveStatusComponent],
imports: [
CommonModule,
FormsModule,
IonicModule,
RecoverPageRoutingModule,
PasswordPageModule,
ProdKeyModalModule,
PipesModule,
CifsModalModule,
],
})
export class RecoverPageModule { }

View File

@@ -0,0 +1,65 @@
<ion-content>
<ion-grid>
<ion-row>
<ion-col>
<div style="padding-bottom: 32px;" class="ion-text-center">
<img src="assets/img/logo.png" style="max-width: 240px;" />
</div>
<ion-card color="dark">
<ion-card-header class="ion-text-center">
<ion-card-title>Recover</ion-card-title>
<ion-card-subtitle>Select the shared folder or drive containing your Embassy backup</ion-card-subtitle>
</ion-card-header>
<ion-card-content class="ion-margin">
<ion-spinner *ngIf="loading" class="center-spinner" name="lines"></ion-spinner>
<!-- loaded -->
<ion-item-group *ngIf="!loading">
<!-- cifs -->
<h2 class="target-label">
Shared Network Folder
</h2>
<p class="ion-padding-bottom">
Using a shared folder is the recommended way to recover from backup, since it works with all Embassy hardware configurations.
To recover from a shared folder, please follow the <a href="https://docs.start9.com/user-manual/general/backups.html#shared-network-folder" target="_blank" noreferrer>instructions</a>.
</p>
<!-- connect -->
<ion-item button lines="none" (click)="presentModalCifs()">
<ion-icon slot="start" name="folder-open-outline" size="large" color="light"></ion-icon>
<ion-label>Open Shared Folder</ion-label>
</ion-item>
<br />
<br />
<!-- drives -->
<h2 class="target-label">
Physical Drives
</h2>
<p class="ion-padding-bottom">
Warning! Plugging in more than one physical drive to Embassy can lead to power failure and data corruption.
To recover from a physical drive, please follow the <a href="https://docs.start9.com/user-manual/general/backups.html#physical-drive" target="_blank" noreferrer>instructions</a>.
</p>
<ng-container *ngFor="let mapped of mappedDrives">
<ion-item button *ngIf="mapped.drive as drive" [disabled]="!driveClickable(mapped)" (click)="select(drive)">
<ion-icon slot="start" name="save-outline" size="large" color="light"></ion-icon>
<ion-label>
<h1>{{ drive.label || drive.logicalname }}</h1>
<drive-status [hasValidBackup]="mapped.hasValidBackup" [is02x]="mapped.is02x"></drive-status>
<p>{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model || 'Unknown Model' }}</p>
<p>Capacity: {{ drive.capacity | convertBytes }}</p>
</ion-label>
</ion-item>
</ng-container>
</ion-item-group>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

@@ -0,0 +1,4 @@
.target-label {
font-weight: bold;
padding-bottom: 6px;
}

View File

@@ -0,0 +1,223 @@
import { Component, Input } from '@angular/core'
import { AlertController, IonicSafeString, LoadingController, ModalController, NavController } from '@ionic/angular'
import { CifsModal } from 'src/app/modals/cifs-modal/cifs-modal.page'
import { ApiService, DiskBackupTarget } from 'src/app/services/api/api.service'
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { StateService } from 'src/app/services/state.service'
import { PasswordPage } from '../../modals/password/password.page'
import { ProdKeyModal } from '../../modals/prod-key-modal/prod-key-modal.page'
@Component({
selector: 'app-recover',
templateUrl: 'recover.page.html',
styleUrls: ['recover.page.scss'],
})
export class RecoverPage {
loading = true
mappedDrives: MappedDisk[] = []
hasShownGuidAlert = false
constructor (
private readonly apiService: ApiService,
private readonly navCtrl: NavController,
private readonly modalCtrl: ModalController,
private readonly modalController: ModalController,
private readonly alertCtrl: AlertController,
private readonly loadingCtrl: LoadingController,
private readonly errorToastService: ErrorToastService,
public readonly stateService: StateService,
) { }
async ngOnInit () {
await this.getDrives()
}
async refresh () {
this.loading = true
await this.getDrives()
}
driveClickable (mapped: MappedDisk) {
return mapped.drive['embassy-os']?.full && (this.stateService.hasProductKey || mapped.is02x)
}
async getDrives () {
this.mappedDrives = []
try {
const { disks, reconnect } = await this.apiService.getDrives()
disks.filter(d => d.partitions.length).forEach(d => {
d.partitions.forEach(p => {
const drive: DiskBackupTarget = {
vendor: d.vendor,
model: d.model,
logicalname: p.logicalname,
label: p.label,
capacity: p.capacity,
used: p.used,
'embassy-os': p['embassy-os'],
}
this.mappedDrives.push(
{
hasValidBackup: p['embassy-os']?.full,
is02x: drive['embassy-os']?.version.startsWith('0.2'),
drive,
},
)
})
})
if (!this.mappedDrives.length && reconnect.length) {
const list = `<ul>${reconnect.map(recon => `<li>${recon}</li>`)}</ul>`
const alert = await this.alertCtrl.create({
header: 'Warning',
message: `One or more devices you connected had to be reconfigured to support the current hardware platform. Please unplug and replug the following device(s), then refresh the page:<br> ${list}`,
buttons: [
{
role: 'cancel',
text: 'OK',
},
],
})
await alert.present()
}
const importableDrive = disks.find(d => !!d.guid)
if (!!importableDrive && !this.hasShownGuidAlert) {
const alert = await this.alertCtrl.create({
header: 'Embassy Data Drive Detected',
message: new IonicSafeString(`${importableDrive.vendor || 'Unknown Vendor'} - ${importableDrive.model || 'Unknown Model' } contains Embassy data. To use this drive and its data <i>as-is</i>, click "Use Drive". This will complete the setup process.<br /><br /><b>Important</b>. If you are trying to restore from backup or update from 0.2.x, DO NOT click "Use Drive". Instead, click "Cancel" and follow instructions.`),
buttons: [
{
role: 'cancel',
text: 'Cancel',
},
{
text: 'Use Drive',
handler: async () => {
await this.importDrive(importableDrive.guid)
},
},
],
})
await alert.present()
this.hasShownGuidAlert = true
}
} catch (e) {
this.errorToastService.present(`${e.message}: ${e.details}`)
} finally {
this.loading = false
}
}
async presentModalCifs (): Promise<void> {
const modal = await this.modalCtrl.create({
component: CifsModal,
})
modal.onDidDismiss().then(res => {
if (res.role === 'success') {
const { hostname, path, username, password } = res.data.cifs
this.stateService.recoverySource = {
type: 'cifs',
hostname,
path,
username,
password,
}
this.stateService.recoveryPassword = res.data.recoveryPassword
this.navCtrl.navigateForward('/embassy')
}
})
await modal.present()
}
async select (target: DiskBackupTarget) {
const is02x = target['embassy-os'].version.startsWith('0.2')
if (this.stateService.hasProductKey) {
if (is02x) {
this.selectRecoverySource(target.logicalname)
} else {
const modal = await this.modalController.create({
component: PasswordPage,
componentProps: { target },
cssClass: 'alertlike-modal',
})
modal.onDidDismiss().then(res => {
if (res.data && res.data.password) {
this.selectRecoverySource(target.logicalname, res.data.password)
}
})
await modal.present()
}
// if no product key, it means they are an upgrade kit user
} else {
if (!is02x) {
const alert = await this.alertCtrl.create({
header: 'Error',
message: 'In order to use this image, you must select a drive containing a valid 0.2.x Embassy.',
buttons: [
{
role: 'cancel',
text: 'OK',
},
],
})
await alert.present()
} else {
const modal = await this.modalController.create({
component: ProdKeyModal,
componentProps: { target },
cssClass: 'alertlike-modal',
})
modal.onDidDismiss().then(res => {
if (res.data && res.data.productKey) {
this.selectRecoverySource(target.logicalname)
}
})
await modal.present()
}
}
}
private async importDrive (guid: string) {
const loader = await this.loadingCtrl.create({
message: 'Importing Drive',
})
await loader.present()
try {
await this.stateService.importDrive(guid)
await this.navCtrl.navigateForward(`/init`)
} catch (e) {
this.errorToastService.present(`${e.message}: ${e.details}`)
} finally {
loader.dismiss()
}
}
private async selectRecoverySource (logicalname: string, password?: string) {
this.stateService.recoverySource = {
type: 'disk',
logicalname,
}
this.stateService.recoveryPassword = password
this.navCtrl.navigateForward(`/embassy`)
}
}
@Component({
selector: 'drive-status',
templateUrl: './drive-status.component.html',
styleUrls: ['./recover.page.scss'],
})
export class DriveStatusComponent {
@Input() hasValidBackup: boolean
@Input() is02x: boolean
}
interface MappedDisk {
is02x: boolean
hasValidBackup: boolean
drive: DiskBackupTarget
}

View File

@@ -0,0 +1,18 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { SuccessPage } from './success.page'
import { PasswordPageModule } from '../../modals/password/password.module'
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
PasswordPageModule,
],
declarations: [SuccessPage],
exports: [SuccessPage],
})
export class SuccessPageModule { }

View File

@@ -0,0 +1,167 @@
<ion-card color="dark">
<ion-card-header class="ion-text-center" color="success">
<ion-icon style="font-size: 80px;" name="checkmark-circle-outline"></ion-icon>
<ion-card-title>Setup Complete!</ion-card-title>
</ion-card-header>
<ion-card-content>
<br />
<ng-template [ngIf]="stateService.recoverySource && stateService.recoverySource.type === 'disk'">
<h2>You can now safely unplug your backup drive.</h2>
</ng-template>
<!-- Tor Instructions -->
<div (click)="toggleTor()" class="toggle-label">
<h2>Tor Instructions:</h2>
<ion-icon
name="chevron-down-outline"
[ngStyle]="{
'transform': torOpen ? 'rotate(-90deg)' : 'rotate(0deg)',
'transition': 'transform 0.4s ease-out'
}"
></ion-icon>
</div>
<div
[ngStyle]="{
'overflow' : 'hidden',
'max-height': torOpen ? '500px' : '0px',
'transition': 'max-height 0.4s ease-out'
}"
>
<div class="ion-padding ion-text-start">
<p>
To use your Embassy over Tor, visit its unique Tor address from any Tor-enabled browser.
For a list of recommended browsers, click <a href="https://docs.start9.com/user-manual/connecting.html" target="_blank" rel="noreferrer"><b>here</b></a>.
</p>
<br />
<p>Tor Address</p>
<ion-item lines="none" color="dark">
<ion-label class="ion-text-wrap">
<code><ion-text color="light">{{ stateService.torAddress }}</ion-text></code>
</ion-label>
<ion-button color="light" fill="clear" (click)="copy(stateService.torAddress)">
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
</ion-button>
</ion-item>
</div>
<div style="padding-bottom: 24px; border-bottom: solid 1px;"></div>
<br />
</div>
<!-- LAN Instructions -->
<div (click)="toggleLan()" class="toggle-label">
<h2>LAN Instructions (Slightly Advanced):</h2>
<ion-icon
name="chevron-down-outline"
[ngStyle]="{
'transform': lanOpen ? 'rotate(-90deg)' : 'rotate(0deg)',
'transition': 'transform 0.4s ease-out'
}"
></ion-icon>
</div>
<div
[ngStyle]="{
'overflow' : 'hidden',
'max-height': lanOpen ? '500px' : '0px',
'transition': 'max-height 0.4s ease-out'
}"
>
<div class="ion-padding ion-text-start">
<p>To use your Embassy locally, you must:</p>
<ol>
<li>Currently be connected to the same Local Area Network (LAN) as your Embassy.</li>
<li>Download your Embassy's Root Certificate Authority.</li>
<li>Trust your Embassy's Root CA on <i>both</i> your computer/phone and in your browser settings.</li>
</ol>
<p>
For step-by-step instructions, click
<a href="https://docs.start9.com/user-manual/general/lan-setup.html" target="_blank" rel="noreferrer"><b>here</b></a>.
</p>
<p>
<b>Please note, once setup is complete, the embassy.local address will no longer connect to your Embassy.</b>
</p>
<ion-button style="margin-top: 24px; margin-bottom: 24px;" color="light" (click)="installCert()">
Download Root CA
<ion-icon slot="end" name="download-outline"></ion-icon>
</ion-button>
<p>LAN Address</p>
<ion-item lines="none" color="dark">
<ion-label class="ion-text-wrap">
<code><ion-text color="light">{{ stateService.lanAddress }}</ion-text></code>
</ion-label>
<ion-button color="light" fill="clear" (click)="copy(stateService.lanAddress)">
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
</ion-button>
</ion-item>
</div>
<div style="padding-bottom: 24px; border-bottom: solid 1px;"></div>
<br />
</div>
<div class="ion-text-center ion-padding-top">
<ion-button color="light" fill="clear" color="primary" strong (click)="download()">
Download this page
<ion-icon slot="end" name="download-outline"></ion-icon>
</ion-button>
</div>
<br />
</ion-card-content>
</ion-card>
<!-- cert elem -->
<a hidden id="install-cert" download="embassy.crt"></a>
<!-- download elem -->
<div hidden id="downloadable">
<div style="padding: 0 24px; font-family: Courier;">
<h1>Embassy Info</h1>
<section style="padding: 16px; border: solid 1px;">
<h2>Tor Info</h2>
<p>
To use your Embassy over Tor, visit its unique Tor address from any Tor-enabled browser.
</p>
<p>
For a list of recommended browsers, click <a href="https://docs.start9.com/user-manual/connecting.html" target="_blank" rel="noreferrer"><b>here</b></a>.
</p>
<p><b>Tor Address: </b><code id="tor-addr"></code></p>
</section>
<section style="padding: 16px; border: solid 1px; border-top: none;">
<h2>LAN Info</h2>
<p>To use your Embassy locally, you must:</p>
<ol>
<li>Currently be connected to the same Local Area Network (LAN) as your Embassy.</li>
<li>Download your Embassy's Root Certificate Authority.</li>
<li>Trust your Embassy's Root CA on <i>both</i> your computer/phone and in your browser settings.</li>
</ol>
<p>
For step-by-step instructions, click
<a href="https://docs.start9.com/user-manual/general/lan-setup.html" target="_blank" rel="noreferrer"><b>here</b></a>.
</p>
<div style="margin: 42px 0;">
<a
id="cert"
download="embassy.crt"
style="
background: #25272b;
padding: 10px;
text-decoration: none;
text-align: center;
border-radius: 4px;
color: white;
"
>
Download Root CA
</a>
</div>
<p><b>LAN Address: </b><code id="lan-addr"></code></p>
</section>
</div>
</div>

View File

@@ -0,0 +1,29 @@
p {
color: var(--ion-color-light);
}
a {
text-decoration: none;
}
.toggle-label {
padding: 24px 0 8px 0;
cursor: pointer;
display: flex;
flex-direction: row;
justify-content: space-between;
* {
display: inline-block;
vertical-align: middle;
}
h2 {
font-weight: bold;
}
ion-icon {
text-align: right;
font-size: 24px;
}
}

View File

@@ -0,0 +1,65 @@
import { Component, EventEmitter, Output } from '@angular/core'
import { ToastController } from '@ionic/angular'
import { StateService } from 'src/app/services/state.service'
@Component({
selector: 'success',
templateUrl: 'success.page.html',
styleUrls: ['success.page.scss'],
})
export class SuccessPage {
@Output() onDownload = new EventEmitter()
torOpen = true
lanOpen = false
constructor (
private readonly toastCtrl: ToastController,
public readonly stateService: StateService,
) { }
ngAfterViewInit () {
document.getElementById('install-cert').setAttribute('href', 'data:application/x-x509-ca-cert;base64,' + encodeURIComponent(this.stateService.cert))
}
async copy (address: string): Promise<void> {
const success = await this.copyToClipboard(address)
const message = success ? 'copied to clipboard!' : 'failed to copy'
const toast = await this.toastCtrl.create({
header: message,
position: 'bottom',
duration: 1000,
})
await toast.present()
}
toggleTor () {
this.torOpen = !this.torOpen
}
toggleLan () {
this.lanOpen = !this.lanOpen
}
installCert () {
document.getElementById('install-cert').click()
}
download () {
this.onDownload.emit()
}
private async copyToClipboard (str: string): Promise<boolean> {
const el = document.createElement('textarea')
el.value = str
el.setAttribute('readonly', '')
el.style.position = 'absolute'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
const copy = document.execCommand('copy')
document.body.removeChild(el)
return copy
}
}

View File

@@ -0,0 +1,18 @@
import { Pipe, PipeTransform } from '@angular/core'
// converts bytes to gigabytes
@Pipe({
name: 'convertBytes',
})
export class ConvertBytesPipe implements PipeTransform {
transform (bytes: number): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
}

View File

@@ -0,0 +1,10 @@
import { NgModule } from '@angular/core'
import { ConvertBytesPipe } from './convert-bytes.pipe'
@NgModule({
declarations: [ConvertBytesPipe],
imports: [],
exports: [ConvertBytesPipe],
})
export class PipesModule { }

View File

@@ -0,0 +1,98 @@
export abstract class ApiService {
// unencrypted
abstract getStatus (): Promise<GetStatusRes> // setup.status
abstract getDrives (): Promise<DiskListResponse> // setup.disk.list
abstract set02XDrive (logicalname: string): Promise<void> // setup.recovery.v2.set
abstract getRecoveryStatus (): Promise<RecoveryStatusRes> // setup.recovery.status
// encrypted
abstract verifyCifs (cifs: CifsRecoverySource): Promise<EmbassyOSRecoveryInfo> // setup.cifs.verify
abstract verifyProductKey (): Promise<void> // echo - throws error if invalid
abstract importDrive (guid: string): Promise<SetupEmbassyRes> // setup.execute
abstract setupEmbassy (setupInfo: SetupEmbassyReq): Promise<SetupEmbassyRes> // setup.execute
abstract setupComplete (): Promise<void> // setup.complete
}
export interface GetStatusRes {
'product-key': boolean
migrating: boolean
}
export interface SetupEmbassyReq {
'embassy-logicalname': string
'embassy-password': string
'recovery-source': CifsRecoverySource | DiskRecoverySource | null
'recovery-password': string | null
}
export interface SetupEmbassyRes {
'tor-address': string
'lan-address': string
'root-ca': string
}
export interface EmbassyOSRecoveryInfo {
version: string
full: boolean
'password-hash': string | null
'wrapped-key': string | null
}
export interface DiskListResponse {
disks: DiskInfo[]
reconnect: string[]
}
export interface DiskBackupTarget {
vendor: string | null
model: string | null
logicalname: string | null
label: string | null
capacity: number
used: number | null
'embassy-os': EmbassyOSRecoveryInfo | null
}
export interface CifsBackupTarget {
hostname: string
path: string
username: string
mountable: boolean
'embassy-os': EmbassyOSRecoveryInfo | null
}
export interface DiskRecoverySource {
type: 'disk'
logicalname: string // partition logicalname
}
export interface CifsRecoverySource {
type: 'cifs'
hostname: string
path: string
username: string
password: string | null
}
export interface DiskInfo {
logicalname: string,
vendor: string | null,
model: string | null,
partitions: PartitionInfo[],
capacity: number,
guid: string | null, // cant back up if guid exists
}
export interface RecoveryStatusRes {
'bytes-transferred': number
'total-bytes': number
complete: boolean
}
export interface PartitionInfo {
logicalname: string,
label: string | null,
capacity: number,
used: number | null,
'embassy-os': EmbassyOSRecoveryInfo | null,
}

View File

@@ -0,0 +1,243 @@
import { Injectable } from '@angular/core'
import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http'
import { Observable } from 'rxjs'
import * as aesjs from 'aes-js'
import * as pbkdf2 from 'pbkdf2'
@Injectable({
providedIn: 'root',
})
export class HttpService {
fullUrl: string
productKey: string
constructor (
private readonly http: HttpClient,
) {
const port = window.location.port
this.fullUrl = `${window.location.protocol}//${window.location.hostname}:${port}/rpc/v1`
}
async rpcRequest<T> (body: RPCOptions, encrypted = true): Promise<T> {
const httpOpts = {
method: Method.POST,
body,
url: this.fullUrl,
}
let res: RPCResponse<T>
if (encrypted) {
res = await this.encryptedHttpRequest<RPCResponse<T>>(httpOpts)
} else {
res = await this.httpRequest<RPCResponse<T>>(httpOpts)
}
if (isRpcError(res)) {
console.error('RPC ERROR: ', res)
throw new RpcError(res.error)
}
if (isRpcSuccess(res)) return res.result
}
async encryptedHttpRequest<T> (httpOpts: {
body: RPCOptions;
url: string;
}): Promise<T> {
const urlIsRelative = httpOpts.url.startsWith('/')
const url = urlIsRelative ?
this.fullUrl + httpOpts.url :
httpOpts.url
const encryptedBody = await AES_CTR.encryptPbkdf2(this.productKey, encodeUtf8(JSON.stringify(httpOpts.body)))
const options = {
responseType: 'arraybuffer',
body: encryptedBody.buffer,
observe: 'events',
reportProgress: false,
headers: {
'Content-Encoding': 'aesctr256',
'Content-Type': 'application/json',
},
} as any
const req = this.http.post(url, options.body, options)
return (req)
.toPromise()
.then(res => AES_CTR.decryptPbkdf2(this.productKey, (res as any).body as ArrayBuffer))
.then(res => JSON.parse(res))
.catch(e => {
if (!e.status && !e.statusText) {
throw new EncryptionError(e)
} else {
throw new HttpError(e)
}
})
}
async httpRequest<T> (httpOpts: {
body: RPCOptions;
url: string;
}): Promise<T> {
const urlIsRelative = httpOpts.url.startsWith('/')
const url = urlIsRelative ?
this.fullUrl + httpOpts.url :
httpOpts.url
const options = {
responseType: 'json',
body: httpOpts.body,
observe: 'events',
reportProgress: false,
headers: { 'content-type': 'application/json', accept: 'application/json' },
} as any
const req: Observable<{ body: T }> = this.http.post(url, httpOpts.body, options) as any
return (req)
.toPromise()
.then(res => res.body)
.catch(e => { throw new HttpError(e) })
}
}
function RpcError (e: RPCError['error']): void {
const { code, message, data } = e
this.code = code
this.message = message
if (typeof data !== 'string') {
this.details = data.details
} else {
this.details = data
}
}
function HttpError (e: HttpErrorResponse): void {
const { status, statusText } = e
this.code = status
this.message = statusText
this.details = null
}
function EncryptionError (e: HttpErrorResponse): void {
this.code = null
this.message = 'Invalid Key'
this.details = null
}
function isRpcError<Error, Result> (arg: { error: Error } | { result: Result }): arg is { error: Error } {
return !!(arg as any).error
}
function isRpcSuccess<Error, Result> (arg: { error: Error } | { result: Result }): arg is { result: Result } {
return !!(arg as any).result
}
export enum Method {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
PATCH = 'PATCH',
DELETE = 'DELETE',
}
export interface RPCOptions {
method: string
params?: {
[param: string]: string | number | boolean | object | string[] | number[];
}
}
interface RPCBase {
jsonrpc: '2.0'
id: string
}
export interface RPCRequest<T> extends RPCBase {
method: string
params?: T
}
export interface RPCSuccess<T> extends RPCBase {
result: T
}
export interface RPCError extends RPCBase {
error: {
code: number,
message: string
data?: {
details: string
} | string
}
}
export type RPCResponse<T> = RPCSuccess<T> | RPCError
type HttpError = HttpErrorResponse & { error: { code: string, message: string } }
export interface HttpOptions {
method: Method
url: string
headers?: HttpHeaders | {
[header: string]: string | string[]
}
params?: HttpParams | {
[param: string]: string | string[]
}
responseType?: 'json' | 'text' | 'arrayBuffer'
withCredentials?: boolean
body?: any
timeout?: number
}
type AES_CTR = {
encryptPbkdf2: (secretKey: string, messageBuffer: Uint8Array) => Promise<Uint8Array>
decryptPbkdf2: (secretKey, arr: ArrayBuffer) => Promise<string>
}
export const AES_CTR: AES_CTR = {
encryptPbkdf2: async (secretKey: string, messageBuffer: Uint8Array) => {
const salt = window.crypto.getRandomValues(new Uint8Array(16))
const counter = window.crypto.getRandomValues(new Uint8Array(16))
const key = pbkdf2.pbkdf2Sync(secretKey, salt, 1000, 256 / 8, 'sha256')
const aesCtr = new aesjs.ModeOfOperation.ctr(key, new aesjs.Counter(counter))
const encryptedBytes = aesCtr.encrypt(messageBuffer)
return new Uint8Array([...counter, ...salt, ...encryptedBytes])
},
decryptPbkdf2: async (secretKey: string, arr: ArrayBuffer) => {
const buff = new Uint8Array(arr)
const counter = buff.slice(0, 16)
const salt = buff.slice(16, 32)
const cipher = buff.slice(32)
const key = pbkdf2.pbkdf2Sync(secretKey, salt, 1000, 256 / 8, 'sha256')
const aesCtr = new aesjs.ModeOfOperation.ctr(key, new aesjs.Counter(counter))
const decryptedBytes = aesCtr.decrypt(cipher)
return aesjs.utils.utf8.fromBytes(decryptedBytes)
},
}
export const encode16 = (buffer: Uint8Array) => buffer.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '')
export const decode16 = hexString => new Uint8Array(hexString.match(/.{1,2}/g).map(byte => parseInt(byte, 16)))
export function encodeUtf8 (str: string): Uint8Array {
const encoder = new TextEncoder()
return encoder.encode(str)
}
export function decodeUtf8 (arr: Uint8Array): string {
return new TextDecoder().decode(arr)
}

View File

@@ -0,0 +1,99 @@
import { Injectable } from '@angular/core'
import { ApiService, CifsRecoverySource, DiskInfo, DiskListResponse, DiskRecoverySource, EmbassyOSRecoveryInfo, GetStatusRes, RecoveryStatusRes, SetupEmbassyReq, SetupEmbassyRes } from './api.service'
import { HttpService } from './http.service'
@Injectable({
providedIn: 'root',
})
export class LiveApiService extends ApiService {
constructor (
private readonly http: HttpService,
) { super() }
// ** UNENCRYPTED **
async getStatus () {
return this.http.rpcRequest<GetStatusRes>({
method: 'setup.status',
params: { },
}, false)
}
async getDrives () {
return this.http.rpcRequest<DiskListResponse>({
method: 'setup.disk.list',
params: { },
}, false)
}
async set02XDrive (logicalname) {
return this.http.rpcRequest<void>({
method: 'setup.recovery.v2.set',
params: { logicalname },
}, false)
}
async getRecoveryStatus () {
return this.http.rpcRequest<RecoveryStatusRes>({
method: 'setup.recovery.status',
params: { },
}, false)
}
// ** ENCRYPTED **
async verifyCifs (source: CifsRecoverySource) {
source.path = source.path.replace('/\\/g', '/')
return this.http.rpcRequest<EmbassyOSRecoveryInfo>({
method: 'setup.cifs.verify',
params: source as any,
})
}
async verifyProductKey () {
return this.http.rpcRequest<void>({
method: 'echo',
params: { 'message': 'hello' },
})
}
async importDrive (guid: string) {
const res = await this.http.rpcRequest<SetupEmbassyRes>({
method: 'setup.attach',
params: { guid },
})
return {
...res,
'root-ca': btoa(res['root-ca']),
}
}
async setupEmbassy (setupInfo: SetupEmbassyReq) {
if (isCifsSource(setupInfo['recovery-source'])) {
setupInfo['recovery-source'].path = setupInfo['recovery-source'].path.replace('/\\/g', '/')
}
const res = await this.http.rpcRequest<SetupEmbassyRes>({
method: 'setup.execute',
params: setupInfo as any,
})
return {
...res,
'root-ca': btoa(res['root-ca']),
}
}
async setupComplete () {
await this.http.rpcRequest<SetupEmbassyRes>({
method: 'setup.complete',
params: { },
})
}
}
function isCifsSource (source: CifsRecoverySource | DiskRecoverySource | undefined): source is CifsRecoverySource {
return !!(source as CifsRecoverySource)?.hostname
}

View File

@@ -0,0 +1,231 @@
import { Injectable } from '@angular/core'
import { pauseFor } from 'src/app/util/misc.util'
import { ApiService, CifsRecoverySource, SetupEmbassyReq } from './api.service'
let tries = 0
@Injectable({
providedIn: 'root',
})
export class MockApiService extends ApiService {
constructor () {
super()
}
// ** UNENCRYPTED **
async getStatus () {
await pauseFor(1000)
return {
'product-key': true,
migrating: false,
}
}
async getDrives () {
await pauseFor(1000)
return {
disks: [
{
logicalname: 'abcd',
vendor: 'Samsung',
model: 'T5',
partitions: [
{
logicalname: 'pabcd',
label: null,
capacity: 73264762332,
used: null,
'embassy-os': {
version: '0.2.17',
full: true,
'password-hash': null,
'wrapped-key': null,
},
}
],
capacity: 123456789123,
guid: 'uuid-uuid-uuid-uuid',
}
],
reconnect: [],
}
}
async set02XDrive () {
await pauseFor(1000)
return
}
async getRecoveryStatus () {
tries = Math.min(tries + 1, 4)
return {
'bytes-transferred': tries,
'total-bytes': 4,
complete: tries === 4,
}
}
// ** ENCRYPTED **
async verifyCifs (params: CifsRecoverySource) {
await pauseFor(1000)
return {
version: '0.3.0',
full: true,
'password-hash': '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
'wrapped-key': '',
}
}
async verifyProductKey () {
await pauseFor(1000)
return
}
async importDrive (guid: string) {
await pauseFor(3000)
return setupRes
}
async setupEmbassy (setupInfo: SetupEmbassyReq) {
await pauseFor(3000)
return setupRes
}
async setupComplete () {
await pauseFor(1000)
}
}
const rootCA =
`-----BEGIN CERTIFICATE-----
MIIDpzCCAo+gAwIBAgIRAIIuOarlQETlUQEOZJGZYdIwDQYJKoZIhvcNAQELBQAw
bTELMAkGA1UEBhMCVVMxFTATBgNVBAoMDEV4YW1wbGUgQ29ycDEOMAwGA1UECwwF
U2FsZXMxCzAJBgNVBAgMAldBMRgwFgYDVQQDDA93d3cuZXhhbXBsZS5jb20xEDAO
BgNVBAcMB1NlYXR0bGUwHhcNMjEwMzA4MTU0NjI3WhcNMjIwMzA4MTY0NjI3WjBt
MQswCQYDVQQGEwJVUzEVMBMGA1UECgwMRXhhbXBsZSBDb3JwMQ4wDAYDVQQLDAVT
YWxlczELMAkGA1UECAwCV0ExGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTEQMA4G
A1UEBwwHU2VhdHRsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMP7
t5AKFZQ7abqkeyUjsBVIWRa9tCh8oge9u/LvCbxU738G4jssT+Oud3WMajIjuNow
cpc+0Q/e42ULO/6gTNrTs6OCOo9lV6G0Dprf/e91DWoKgPatem/pUjNyraifHZfu
b5mLHCfahjWXUQtc/sjmDQaZRK3Kar6ljlUBE/Le9NEyOAIkSLPzDtW8LXm4iwcU
BZrb828rKd1Aw9oI1+3bfzB6xXmzZxc5RLXveOCEhKGD32jKZ/RNFSC8AZAwJe+x
bTsys/lUOYFTuT8Bn0TGxR8x7Y4H75+F9BavY3v+WkLj4M+olN9dMR7Et9FMt4u4
YRokv5zp8zIb5iTne1kCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E
FgQUaW3+r328uTLokog2TklmoBK+yt4wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3
DQEBCwUAA4IBAQAXjd/7UZ8RDE+PLWSDNGQdLemOBTcawF+tK+PzA4Evlmn9VuNc
g+x3oZvVZSDQBANUz0b9oPeo54aE38dW1zQm2qfTab8822aqeWMLyJ1dMsAgqYX2
t9+u6w3NzRCw8Pvz18V69+dFE5AeXmNP0Z5/gdz8H/NSpctjlzopbScRZKCSlPid
Rf3ZOPm9QP92YpWyYDkfAU04xdDo1vR0MYjKPkl4LjRqSU/tcCJnPMbJiwq+bWpX
2WJoEBXB/p15Kn6JxjI0ze2SnSI48JZ8it4fvxrhOo0VoLNIuCuNXJOwU17Rdl1W
YJidaq7je6k18AdgPA0Kh8y1XtfUH3fTaVw4
-----END CERTIFICATE-----`
const setupRes = {
'tor-address': 'http://asdafsadasdasasdasdfasdfasdf.onion',
'lan-address': 'https://embassy-abcdefgh.local',
'root-ca': btoa(rootCA),
}
const disks = [
{
vendor: 'Samsung',
model: 'SATA',
logicalname: '/dev/sda',
guid: 'theguid',
partitions: [
{
logicalname: 'sda1',
label: 'label 1',
capacity: 100000,
used: 200.1255312,
'embassy-os': null,
},
{
logicalname: 'sda2',
label: 'label 2',
capacity: 50000,
used: 200.1255312,
'embassy-os': null,
},
],
capacity: 150000,
},
{
vendor: 'Samsung',
model: null,
logicalname: 'dev/sdb',
partitions: [],
capacity: 34359738369,
guid: null,
},
{
vendor: 'Crucial',
model: 'MX500',
logicalname: 'dev/sdc',
guid: null,
partitions: [
{
logicalname: 'sdc1',
label: 'label 1',
capacity: 0,
used: null,
'embassy-os': {
version: '0.3.3',
full: true,
'password-hash': 'asdfasdfasdf',
'wrapped-key': '',
},
},
{
logicalname: 'sdc1MOCKTESTER',
label: 'label 1',
capacity: 0,
used: null,
'embassy-os': {
version: '0.3.6',
full: true,
// password is 'asdfasdf'
'password-hash': '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
'wrapped-key': '',
},
},
{
logicalname: 'sdc1',
label: 'label 1',
capacity: 0,
used: null,
'embassy-os': {
version: '0.3.3',
full: false,
'password-hash': 'asdfasdfasdf',
'wrapped-key': '',
},
},
],
capacity: 100000,
},
{
vendor: 'Sandisk',
model: null,
logicalname: '/dev/sdd',
guid: null,
partitions: [
{
logicalname: 'sdd1',
label: null,
capacity: 10000,
used: null,
'embassy-os': {
version: '0.2.7',
full: true,
'password-hash': 'asdfasdfasdf',
'wrapped-key': '',
},
},
],
capacity: 10000,
},
]

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@angular/core'
import { ToastController } from '@ionic/angular'
@Injectable({
providedIn: 'root',
})
export class ErrorToastService {
private toast: HTMLIonToastElement
constructor (
private readonly toastCtrl: ToastController,
) { }
async present (message: string): Promise<void> {
if (this.toast) return
this.toast = await this.toastCtrl.create({
header: 'Error',
message,
duration: 0,
position: 'top',
cssClass: 'error-toast',
animated: true,
buttons: [
{
side: 'end',
icon: 'close',
handler: () => {
this.dismiss()
},
},
],
})
await this.toast.present()
}
async dismiss (): Promise<void> {
if (this.toast) {
await this.toast.dismiss()
this.toast = undefined
}
}
}

View File

@@ -0,0 +1,14 @@
import { ErrorHandler, Injectable } from '@angular/core'
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
handleError (e: any): void {
console.error(e)
const chunkFailedMessage = /Loading chunk [\d]+ failed/
if (chunkFailedMessage.test(e.message)) {
window.location.reload()
}
}
}

View File

@@ -0,0 +1,82 @@
import { Injectable } from '@angular/core'
import { BehaviorSubject } from 'rxjs'
import { ApiService, CifsRecoverySource, DiskRecoverySource } from './api/api.service'
import { ErrorToastService } from './error-toast.service'
import { pauseFor } from '../util/misc.util'
@Injectable({
providedIn: 'root',
})
export class StateService {
hasProductKey: boolean
isMigrating: boolean
polling = false
embassyLoaded = false
recoverySource: CifsRecoverySource | DiskRecoverySource
recoveryPassword: string
dataTransferProgress: { bytesTransferred: number, totalBytes: number, complete: boolean } | null
dataProgress = 0
dataCompletionSubject = new BehaviorSubject(false)
torAddress: string
lanAddress: string
cert: string
constructor (
private readonly apiService: ApiService,
private readonly errorToastService: ErrorToastService,
) { }
async pollDataTransferProgress () {
this.polling = true
await pauseFor(500)
if (
this.dataTransferProgress?.complete
) {
this.dataCompletionSubject.next(true)
return
}
let progress
try {
progress = await this.apiService.getRecoveryStatus()
} catch (e) {
this.errorToastService.present(`${e.message}: ${e.details}.\nRestart Embassy to try again.`)
}
if (progress) {
this.dataTransferProgress = {
bytesTransferred: progress['bytes-transferred'],
totalBytes: progress['total-bytes'],
complete: progress.complete,
}
if (this.dataTransferProgress.totalBytes) {
this.dataProgress = this.dataTransferProgress.bytesTransferred / this.dataTransferProgress.totalBytes
}
}
setTimeout(() => this.pollDataTransferProgress(), 0) // prevent call stack from growing
}
async importDrive (guid: string): Promise<void> {
const ret = await this.apiService.importDrive(guid)
this.torAddress = ret['tor-address']
this.lanAddress = ret['lan-address']
this.cert = ret['root-ca']
}
async setupEmbassy (storageLogicalname: string, password: string): Promise<void> {
const ret = await this.apiService.setupEmbassy({
'embassy-logicalname': storageLogicalname,
'embassy-password': password,
'recovery-source': this.recoverySource || null,
'recovery-password': this.recoveryPassword || null,
})
this.torAddress = ret['tor-address']
this.lanAddress = ret['lan-address']
this.cert = ret['root-ca']
}
}

View File

@@ -0,0 +1,3 @@
export const pauseFor = (ms: number) => {
return new Promise(resolve => setTimeout(resolve, ms))
}

View File

@@ -0,0 +1,3 @@
export const environment = {
production: true,
}

View File

@@ -0,0 +1,16 @@
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
// The list of file replacements can be found in `angular.json`.
export const environment = {
production: false,
}
/*
* For easier debugging in development mode, you can import the following file
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
*
* This import should be commented out in production mode because it will have a negative impact
* on performance if an error is thrown.
*/
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Embassy Setup</title>
<base href="/" />
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<script>
var global = window;
</script>
<link rel="icon" type="image/x-icon" href="assets/icon/favicon.ico"/>
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,12 @@
import { enableProdMode } from '@angular/core'
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'
import { AppModule } from './app/app.module'
import { environment } from './environments/environment'
if (environment.production) {
enableProdMode()
}
platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.log(err))

View File

@@ -0,0 +1,65 @@
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
*
* This file is divided into 2 sections:
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
* file.
*
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
*
* Learn more in https://angular.io/guide/browser-support
*/
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/** IE11 requires the following for NgClass support on SVG elements */
// import 'classlist.js'; // Run `npm install --save classlist.js`.
/**
* Web Animations `@angular/platform-browser/animations`
* Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
* Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
*/
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
/**
* By default, zone.js will patch all possible macroTask and DomEvents
* user can disable parts of macroTask/DomEvents patch by setting following flags
* because those flags need to be set before `zone.js` being loaded, and webpack
* will put import in the top of bundle, so user need to create a separate file
* in this directory (for example: zone-flags.ts), and put the following flags
* into that file, and then add the following code before importing zone.js.
* import './zone-flags';
*
* The flags allowed in zone-flags.ts are listed here.
*
* The following flags will work for all browsers.
*
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
*
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
* with the following flag, it will bypass `zone.js` patch for IE/Edge
*
* (window as any).__Zone_enable_cross_context_check = true;
*
*/
import './zone-flags'
/***************************************************************************************************
* Zone JS is required by default for Angular itself.
*/
import 'zone.js/dist/zone' // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS
*/

View File

@@ -0,0 +1,123 @@
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: normal;
src: url('/assets/fonts/Montserrat/Montserrat-Regular.ttf');
}
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: bold;
src: url('/assets/fonts/Montserrat/Montserrat-Bold.ttf');
}
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: thin;
src: url('/assets/fonts/Montserrat/Montserrat-Light.ttf');
}
@font-face {
font-family: 'Benton Sans';
font-style: normal;
font-weight: normal;
src: url('/assets/fonts/Benton_Sans/BentonSans-Regular.otf');
}
/** Ionic CSS Variables overrides **/
:root {
--ion-font-family: 'Benton Sans';
}
ion-content {
--background: var(--ion-color-medium);
}
ion-grid {
padding-top: 32px;
height: 100%;
max-width: 600px;
}
ion-row {
height: 100%;
}
ion-item {
--color: var(--ion-color-light);
}
ion-toolbar {
--ion-background-color: var(--ion-color-light);
ion-title {
color: var(--ion-color-dark);
}
}
ion-avatar {
width: 27px;
height: 27px;
}
ion-item {
--highlight-color-valid: transparent;
--highlight-color-invalid: transparent;
--border-radius: 4px;
}
ion-card-title {
margin: 16px 0;
font-family: 'Montserrat';
font-size: x-large;
--color: var(--ion-color-light);
}
ion-toast {
--background: var(--ion-color-light);
--button-color: var(--ion-color-dark);
--border-style: solid;
--border-width: 1px;
--color: white;
}
.center-spinner {
height: 20vh;
width: 100%;
}
.inline {
* {
display: inline-block;
vertical-align: middle;
}
}
.claim-button {
margin-inline-start: 0;
margin-inline-end: 0;
margin-top: 24px;
min-width: 140px;
}
.error-toast {
--border-color: var(--ion-color-danger);
width: 40%;
min-width: 400px;
--end: 8px;
right: 8px;
left: unset;
top: 64px;
}
.error-border {
border: 2px solid var(--ion-color-danger);
border-radius: 4px;
}
.success-border {
border: 2px solid var(--ion-color-success);
border-radius: 4px;
}

View File

@@ -0,0 +1,6 @@
/**
* Prevents Angular change detection from
* running with certain Web Component callbacks
*/
// eslint-disable-next-line no-underscore-dangle
(window as any).__Zone_disable_customElements = true

View File

@@ -0,0 +1,9 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "./"
},
"files": ["src/main.ts", "src/polyfills.ts"],
"include": ["src/**/*.d.ts"]
}