Feature/cloud backups (#889)

* cifs for cloud backups on lan

* password spelling fix

* fix spelling and fix rpc method

* fix other methods

* remove old code and rename method

* add support for cifs backup targets

wip

cifs api

simplify idiom

add doc comment

wip

wip

should work™

* add password hash to server info

* fix type

* fix types for cifs

* minor fixes for cifs feature

* fix rpc structure

* fix copy, address some TODOs

* add subcommand

* backup path and navigation

* wizard edits

* rebased success page

* wiz conflicts resolved

* current change actually

* only unsub if done

* no fileter if necessary

* fix copy for cifs old password

* setup complete (#913)

* setup complete

* minor fixes

* setup.complete

* complete bool

* setup-wizard: complete boolean

Co-authored-by: Matt Hill <matthewonthemoon@gmail.com>
Co-authored-by: Drew Ansbacher <drew.ansbacher@spiredigital.com>
Co-authored-by: Matt Hill <MattDHill@users.noreply.github.com>
This commit is contained in:
Aiden McClelland
2021-12-07 11:51:04 -07:00
parent 6ee0bf8636
commit e6fb74a800
140 changed files with 3968 additions and 2399 deletions

View File

@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { EmbassyPage } from './embassy.page'
import { PasswordPageModule } from '../password/password.module'
import { PasswordPageModule } from '../../modals/password/password.module'
import { EmbassyPageRoutingModule } from './embassy-routing.module'
import { PipesModule } from 'src/app/pipes/pipe.module'

View File

@@ -1,6 +1,6 @@
<ion-content>
<ion-grid style="padding-top: 32px; height: 100%; max-width: 540px;">
<ion-row style="height: 100%;">
<ion-grid>
<ion-row>
<ion-col class="ion-text-center">
<div style="padding-bottom: 32px;">
@@ -14,29 +14,19 @@
</ion-card-header>
<ion-card-content class="ion-margin">
<ng-container *ngIf="!loading && !storageDrives.length">
<h2>No drives found</h2>
<p>Please connect a storage drive to your Embassy and refresh the page.</p>
<ion-button style="margin-top: 25px;" (click)="refresh()" color="light">
Refresh
</ion-button>
</ng-container>
<!-- loading -->
<ion-spinner *ngIf="loading; else loaded" class="center-spinner" name="lines"></ion-spinner>
<ion-item-group>
<ng-container *ngIf="loading">
<ion-item button lines="none">
<ion-avatar slot="start">
<ion-skeleton-text animated></ion-skeleton-text>
</ion-avatar>
<ion-label class="ion-text-wrap">
<ion-skeleton-text style="width: 80%; margin: 13px 0;" animated></ion-skeleton-text>
<ion-skeleton-text style="width: 60%; margin: 10px 0;" animated></ion-skeleton-text>
</ion-label>
</ion-item>
<!-- not loading -->
<ng-template #loaded>
<ng-container *ngIf="!storageDrives.length">
<h2>No drives found</h2>
<p>Please connect an storage drive to your Embassy and click "Refresh".</p>
</ng-container>
<ng-container *ngIf="storageDrives.length">
<ion-item (click)="chooseDrive(drive)" class="ion-margin-bottom" button lines="none" *ngFor="let drive of storageDrives" [disabled]="drive.capacity < 34359738368">
<ion-icon slot="start" name="save-outline"></ion-icon>
<ion-item-group *ngIf="storageDrives.length">
<ion-item (click)="chooseDrive(drive)" class="ion-margin-bottom" 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>
@@ -47,8 +37,8 @@
</p>
</ion-label>
</ion-item>
</ng-container>
</ion-item-group>
</ion-item-group>
</ng-template>
</ion-card-content>
</ion-card>
</ion-col>

View File

@@ -1,9 +1,9 @@
import { Component } from '@angular/core'
import { AlertController, LoadingController, ModalController, NavController } from '@ionic/angular'
import { ApiService, DiskInfo } from 'src/app/services/api/api.service'
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 '../password/password.page'
import { PasswordPage } from '../../modals/password/password.page'
@Component({
selector: 'app-embassy',
@@ -11,8 +11,7 @@ import { PasswordPage } from '../password/password.page'
styleUrls: ['embassy.page.scss'],
})
export class EmbassyPage {
storageDrives = []
selectedDrive: DiskInfo = null
storageDrives: DiskInfo[] = []
loading = true
constructor (
@@ -30,15 +29,14 @@ export class EmbassyPage {
}
async refresh () {
this.storageDrives = []
this.selectedDrive = null
this.loading = true
await this.getDrives()
}
async getDrives () {
try {
this.storageDrives = (await this.apiService.getDrives()).filter(d => !d.partitions.map(p => p.logicalname).includes(this.stateService.recoveryPartition?.logicalname))
const drives = await this.apiService.getDrives()
this.storageDrives = drives.filter(d => !d.partitions.map(p => p.logicalname).includes((this.stateService.recoverySource as DiskRecoverySource)?.logicalname))
} catch (e) {
this.errorToastService.present(e.message)
} finally {
@@ -60,14 +58,22 @@ export class EmbassyPage {
{
text: 'Continue',
handler: () => {
this.presentModalPassword(drive)
if (this.stateService.recoveryPassword) {
this.setupEmbassy(drive, this.stateService.recoveryPassword)
} else {
this.presentModalPassword(drive)
}
},
},
],
})
await alert.present()
} else {
this.presentModalPassword(drive)
if (this.stateService.recoveryPassword) {
this.setupEmbassy(drive, this.stateService.recoveryPassword)
} else {
this.presentModalPassword(drive)
}
}
}
@@ -80,31 +86,30 @@ export class EmbassyPage {
})
modal.onDidDismiss().then(async ret => {
if (!ret.data || !ret.data.password) return
const loader = await this.loadingCtrl.create({
message: 'Transferring encrypted data',
})
await loader.present()
this.stateService.storageDrive = drive
this.stateService.embassyPassword = ret.data.password
try {
await this.stateService.setupEmbassy()
if (!!this.stateService.recoveryPartition) {
await this.navCtrl.navigateForward(`/loading`)
} else {
await this.navCtrl.navigateForward(`/init`)
}
} catch (e) {
this.errorToastService.present(`${e.message}: ${e.details}`)
console.error(e.message)
console.error(e.details)
} finally {
loader.dismiss()
}
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

@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { HomePage } from './home.page'
import { PasswordPageModule } from '../password/password.module'
import { PasswordPageModule } from '../../modals/password/password.module'
import { HomePageRoutingModule } from './home-routing.module'

View File

@@ -1,6 +1,6 @@
<ion-content>
<ion-grid style="padding-top: 32px; height: 100%; max-width: 540px;">
<ion-row style="height: 100%;">
<ion-grid>
<ion-row>
<ion-col class="ion-text-center">
<div style="padding-bottom: 32px;">

View File

@@ -1,6 +1,6 @@
<ion-content>
<ion-grid style="padding-top: 32px; height: 100%; max-width: 540px;">
<ion-row style="height: 100%;">
<ion-grid>
<ion-row>
<ion-col class="ion-text-center">
<div style="padding-bottom: 32px;">

View File

@@ -1,6 +1,7 @@
import { Component } from '@angular/core'
import { interval, Observable, Subscription } from 'rxjs'
import { delay, finalize, take, tap } from 'rxjs/operators'
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({
@@ -9,14 +10,18 @@ import { StateService } from 'src/app/services/state.service'
styleUrls: ['init.page.scss'],
})
export class InitPage {
progress: number
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),

View File

@@ -1,6 +1,6 @@
<ion-content color="light">
<ion-grid style="padding-top: 32px; height: 100%; max-width: 540px;">
<ion-row style="height: 100%;">
<ion-grid>
<ion-row>
<ion-col class="ion-text-center">
<div style="padding-bottom: 32px;">

View File

@@ -15,10 +15,10 @@ export class LoadingPage {
ngOnInit () {
this.stateService.pollDataTransferProgress()
const progSub = this.stateService.dataProgSubject.subscribe(async progress => {
if (progress === 1) {
const progSub = this.stateService.dataCompletionSubject.subscribe(async complete => {
if (complete) {
progSub.unsubscribe()
await this.navCtrl.navigateForward(`/success`)
await this.navCtrl.navigateForward(`/init`)
}
})
}

View File

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

View File

@@ -1,19 +0,0 @@
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'
import { PasswordPageRoutingModule } from './password-routing.module'
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
PasswordPageRoutingModule,
],
declarations: [PasswordPage],
})
export class PasswordPageModule { }

View File

@@ -1,79 +0,0 @@
<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 class="input-label">
Password:
</p>
<ion-item
color="dark"
[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 class="input-label">
Confirm Password:
</p>
<ion-item color="dark" [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

@@ -1,74 +0,0 @@
import { Component, Input, ViewChild } from '@angular/core'
import { IonInput, ModalController } from '@ionic/angular'
import { DiskInfo, PartitionInfo } 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() recoveryPartition: PartitionInfo
@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.recoveryPartition || !this.recoveryPartition['embassy-os']) this.pwError = 'No recovery drive' // unreachable
try {
argon2.verify(this.recoveryPartition['embassy-os']['password-hash'], this.password)
this.modalController.dismiss({ password: this.password })
} 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 })
}
validate () {
if (!!this.recoveryPartition) 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

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

View File

@@ -1,18 +0,0 @@
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'
import { ProdKeyModalRoutingModule } from './prod-key-modal-routing.module'
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
ProdKeyModalRoutingModule,
],
declarations: [ProdKeyModal],
})
export class ProdKeyModalModule { }

View File

@@ -1,41 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-title>
Verify Recovery Product Key
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<form (ngSubmit)="verifyProductKey()">
<div style="padding: 8px 24px;">
<div style="padding-bottom: 16px;">
<p>Verify the product key for the chosen recovery drive.</p>
</div>
<ion-item color="dark">
<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()">
Verify
</ion-button>
</ion-toolbar>
</ion-footer>

View File

@@ -1,54 +0,0 @@
import { Component, Input, ViewChild } from '@angular/core'
import { IonInput, LoadingController, ModalController } from '@ionic/angular'
import { ApiService, PartitionInfo } 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() recoveryPartition: PartitionInfo
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.recoveryPartition.logicalname)
this.httpService.productKey = this.productKey
await this.apiService.verifyProductKey()
this.modalController.dismiss({ productKey: this.productKey })
} catch (e) {
this.httpService.productKey = undefined
this.error = 'Invalid Product Key'
} finally {
loader.dismiss()
}
}
cancel () {
this.modalController.dismiss()
}
}

View File

@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { ProductKeyPage } from './product-key.page'
import { PasswordPageModule } from '../password/password.module'
import { PasswordPageModule } from '../../modals/password/password.module'
import { ProductKeyPageRoutingModule } from './product-key-routing.module'
@NgModule({

View File

@@ -1,6 +1,6 @@
<ion-content>
<ion-grid style="padding-top: 32px; height: 100%; max-width: 540px;">
<ion-row style="height: 100%;">
<ion-grid>
<ion-row>
<ion-col class="ion-text-center">
<div style="padding-bottom: 32px;">
@@ -8,14 +8,14 @@
</div>
<ion-card color="dark">
<ion-card-header class="ion-text-center" style="padding-bottom: 8px;">
<ion-card-header style="padding-bottom: 8px;">
<ion-card-title>Enter Product Key</ion-card-title>
</ion-card-header>
<ion-card-content class="ion-margin">
<form (submit)="submit()" style="margin-bottom: 12px;">
<ion-item-group class="ion-padding-bottom">
<p class="input-label">Product Key</p>
<p class="ion-text-left">Product Key</p>
<ion-item color="dark">
<ion-icon slot="start" name="key-outline" style="margin-right: 16px;"></ion-icon>
<ion-input

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,10 @@
<div class="inline">
<h2 *ngIf="hasValidBackup">
<ion-icon name="cloud-done" color="success"></ion-icon>
Embassy backup detected
</h2>
<h2 *ngIf="!hasValidBackup">
<ion-icon name="cloud-offline" color="danger"></ion-icon>
No Embassy backup
</h2>
</div>

View File

@@ -2,14 +2,15 @@ import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { RecoverPage } from './recover.page'
import { PasswordPageModule } from '../password/password.module'
import { ProdKeyModalModule } from '../prod-key-modal/prod-key-modal.module'
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,
@@ -18,7 +19,7 @@ import { PipesModule } from 'src/app/pipes/pipe.module'
PasswordPageModule,
ProdKeyModalModule,
PipesModule,
CifsModalModule,
],
declarations: [RecoverPage],
})
export class RecoverPageModule { }

View File

@@ -1,6 +1,6 @@
<ion-content>
<ion-grid style="padding-top: 32px; height: 100%; max-width: 540px;">
<ion-row style="height: 100%;">
<ion-grid>
<ion-row>
<ion-col>
<div style="padding-bottom: 32px;" class="ion-text-center">
@@ -9,66 +9,53 @@
<ion-card color="dark">
<ion-card-header class="ion-text-center">
<ion-card-title>Select Recovery Drive</ion-card-title>
<ion-card-subtitle>Select the drive containing the Embassy you want to recover.</ion-card-subtitle>
<ion-card-title>Restore from Backup</ion-card-title>
<ion-card-subtitle>Select the shared folder or physical drive containing your Embassy backup</ion-card-subtitle>
</ion-card-header>
<ion-card-content class="ion-margin">
<ng-container *ngIf="!loading && !drives.length">
<h2>No recovery drives found</h2>
<p>Please connect a recovery drive to your Embassy and refresh the page.</p>
<ion-button
(click)="refresh()"
style="text-align:center"
class="claim-button"
>
Refresh
</ion-button>
</ng-container>
<ion-spinner *ngIf="loading" class="center-spinner" name="lines"></ion-spinner>
<ion-item-group>
<ng-container *ngIf="loading">
<ion-skeleton-text animated class="skeleton-header"></ion-skeleton-text>
<ion-item color="light" style="padding-bottom: 10px;">
<ion-avatar slot="start" style="margin-right: 24px;">
<ion-skeleton-text animated style="width: 30px; height: 30px; border-radius: 0;"></ion-skeleton-text>
</ion-avatar>
<!-- 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 restore from a shared folder, please follow the <a href="https://docs.start9.com/user-manual/general/backups.html" 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 restore from a physical drive, please follow the <a href="https://docs.start9.com/user-manual/general/backups.html" target="blank" noreferrer>instructions</a>.
</p>
<ng-container *ngFor="let target of driveTargets">
<ion-item button *ngIf="target.drive as drive" [disabled]="!driveClickable(drive)" (click)="select(drive)">
<ion-icon slot="start" name="save-outline" size="large" color="light"></ion-icon>
<ion-label>
<ion-skeleton-text animated style="width: 100px; height: 20px; margin-bottom: 12px;"></ion-skeleton-text>
<ion-skeleton-text animated style="width: 50px; height: 16px; margin-bottom: 16px;"></ion-skeleton-text>
<ion-skeleton-text animated style="width: 100px;"></ion-skeleton-text>
<h1>{{ drive.label || drive.logicalname }}</h1>
<drive-status [hasValidBackup]="target.hasValidBackup"></drive-status>
<p>{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model || 'Unknown Model' }}</p>
<p>Capacity: {{ drive.capacity | convertBytes }}</p>
</ion-label>
</ion-item>
</ng-container>
<!-- loaded -->
<div *ngFor="let drive of drives" class="ion-padding-bottom">
<h2 class="drive-label">
{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model || 'Unknown Model' }} - {{ drive.capacity | convertBytes }}
</h2>
<ion-item lines="none" button *ngFor="let partition of drive.partitions" [disabled]="!partitionClickable(partition)" (click)="choosePartition(partition)">
<ion-icon slot="start" name="save-outline"></ion-icon>
<ion-label>
<h1>{{ partition.label || partition.logicalname }}</h1>
<h2>{{ partition.capacity | convertBytes }}</h2>
<p *ngIf="partitionClickable(partition)">
<ion-text color="success">
Embassy backup detected
</ion-text>
</p>
<p *ngIf="!partitionClickable(partition)">
<ion-text>
No Embassy backup detected
</ion-text>
</p>
</ion-label>
<div *ngIf="partition['embassy-os'] && partition['embassy-os'].full">
<ion-icon *ngIf="partition['embassy-os'].version.startsWith('0.2')" color="success" slot="end" name="lock-open-outline" size="large"></ion-icon>
<ion-icon *ngIf="!partition['embassy-os'].version.startsWith('0.2')" color="danger" slot="end" name="lock-closed-outline" size="large"></ion-icon>
</div>
</ion-item>
</div>
</ion-item-group>
</ion-card-content>
</ion-card>

View File

@@ -1,20 +1,4 @@
.selected {
border: 4px solid var(--ion-color-secondary);
box-shadow: 4px 4px 16px var(--ion-color-light);
}
.drive-label {
.target-label {
font-weight: bold;
padding-bottom: 6px;
}
.skeleton-header {
width: 180px;
height: 18px;
--ion-text-color-rgb: var(--ion-color-light-rgb);
margin-bottom: 6px;
}
ion-item {
padding-bottom: 6px;
}

View File

@@ -1,10 +1,12 @@
import { Component } from '@angular/core'
import { Component, Input } from '@angular/core'
import { AlertController, LoadingController, ModalController, NavController } from '@ionic/angular'
import { ApiService, DiskInfo, PartitionInfo } from 'src/app/services/api/api.service'
import { CifsModal } from 'src/app/modals/cifs-modal/cifs-modal.page'
import { ApiService, CifsBackupTarget, DiskBackupTarget, DiskRecoverySource, RecoverySource } 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 '../password/password.page'
import { ProdKeyModal } from '../prod-key-modal/prod-key-modal.page'
import { MappedDisk } from 'src/app/util/misc.util'
import { PasswordPage } from '../../modals/password/password.page'
import { ProdKeyModal } from '../../modals/prod-key-modal/prod-key-modal.page'
@Component({
selector: 'app-recover',
@@ -12,14 +14,14 @@ import { ProdKeyModal } from '../prod-key-modal/prod-key-modal.page'
styleUrls: ['recover.page.scss'],
})
export class RecoverPage {
selectedPartition: PartitionInfo = null
loading = true
drives: DiskInfo[] = []
driveTargets: 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,
@@ -32,25 +34,43 @@ export class RecoverPage {
}
async refresh () {
this.selectedPartition = null
this.loading = true
await this.getDrives()
}
partitionClickable (partition: PartitionInfo) {
return partition['embassy-os']?.full && (this.stateService.hasProductKey || this.is02x(partition))
driveClickable (drive: DiskBackupTarget) {
return drive['embassy-os']?.full && (this.stateService.hasProductKey || this.is02x(drive))
}
async getDrives () {
this.driveTargets = []
try {
const drives = await this.apiService.getDrives()
this.drives = drives.filter(d => d.partitions.length)
drives.filter(d => d.partitions.length).forEach(d => {
d.partitions.forEach(p => {
this.driveTargets.push(
{
hasValidBackup: p['embassy-os']?.full,
drive: {
type: 'disk',
vendor: d.vendor,
model: d.model,
logicalname: p.logicalname,
label: p.label,
capacity: p.capacity,
used: p.used,
'embassy-os': p['embassy-os'],
},
},
)
})
})
const importableDrive = drives.find(d => !!d.guid)
if (!!importableDrive && !this.hasShownGuidAlert) {
const alert = await this.alertCtrl.create({
header: 'Embassy Drive Detected',
message: 'A valid EmbassyOS data drive has been detected. To use this drive in its current state, simply click "Use Drive" below.',
message: 'A valid EmbassyOS data drive has been detected. To use this drive as-is, simply click "Use Drive" below.',
buttons: [
{
role: 'cancel',
@@ -74,14 +94,69 @@ export class RecoverPage {
}
}
async importDrive (guid: string) {
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) {
if (target['embassy-os'].version.startsWith('0.2')) {
return this.selectRecoverySource(target.logicalname)
}
if (this.stateService.hasProductKey) {
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 {
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(`/success`)
await this.navCtrl.navigateForward(`/init`)
} catch (e) {
this.errorToastService.present(`${e.message}: ${e.data}`)
} finally {
@@ -89,60 +164,26 @@ export class RecoverPage {
}
}
async choosePartition (partition: PartitionInfo) {
this.selectedPartition = partition
if (partition['embassy-os'].version.startsWith('0.2')) {
return this.selectRecoveryPartition()
}
if (this.stateService.hasProductKey) {
const modal = await this.modalController.create({
component: PasswordPage,
componentProps: {
recoveryPartition: this.selectedPartition,
},
cssClass: 'alertlike-modal',
})
modal.onDidDismiss().then(async ret => {
if (!ret.data) {
this.selectedPartition = null
} else if (ret.data.password) {
this.selectRecoveryPartition(ret.data.password)
}
})
await modal.present()
// if no product key, it means they are an upgrade kit user
} else {
const modal = await this.modalController.create({
component: ProdKeyModal,
componentProps: {
recoveryPartition: this.selectedPartition,
},
cssClass: 'alertlike-modal',
})
modal.onDidDismiss().then(async ret => {
if (!ret.data) {
this.selectedPartition = null
} else if (ret.data.productKey) {
this.selectRecoveryPartition()
}
})
await modal.present()
private async selectRecoverySource (logicalname: string, password?: string) {
this.stateService.recoverySource = {
type: 'disk',
logicalname,
}
this.stateService.recoveryPassword = password
this.navCtrl.navigateForward(`/embassy`)
}
async selectRecoveryPartition (password?: string) {
this.stateService.recoveryPartition = this.selectedPartition
if (password) {
this.stateService.recoveryPassword = password
}
await this.navCtrl.navigateForward(`/embassy`)
}
private is02x (partition: PartitionInfo): boolean {
return !this.stateService.hasProductKey && partition['embassy-os']?.version.startsWith('0.2')
private is02x (drive: DiskBackupTarget): boolean {
return !this.stateService.hasProductKey && drive['embassy-os']?.version.startsWith('0.2')
}
}
@Component({
selector: 'drive-status',
templateUrl: './drive-status.component.html',
styleUrls: ['./recover.page.scss'],
})
export class DriveStatusComponent {
@Input() hasValidBackup: boolean
}

View File

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

View File

@@ -3,17 +3,13 @@ import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { SuccessPage } from './success.page'
import { PasswordPageModule } from '../password/password.module'
import { SuccessPageRoutingModule } from './success-routing.module'
import { PasswordPageModule } from '../../modals/password/password.module'
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
SuccessPageRoutingModule,
PasswordPageModule,
],
declarations: [SuccessPage],

View File

@@ -31,7 +31,7 @@
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 class="input-label">Tor Address</p>
<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>
@@ -81,7 +81,7 @@
<ion-icon slot="end" name="download-outline"></ion-icon>
</ion-button>
<p class="input-label">LAN Address</p>
<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>