Feature/sideload (#1520)

* base styling and action placeholders for package sideload

* apparently didnt add new folder

* wip

* parse manifest and icon from s9pk to upload

* wip handle s9pk upload

* adjust types, finalize actions, cleanup

* clean up and fix data clearing and response

* include rest rpc in proxy conf sample

* address feedback to use shorthand falsy coercion

* update copy and invalid package file ux

* do not wait package upload, instead show install progress

* fix proxy for rest rpc

rename sideload package page titles
This commit is contained in:
Lucy C
2022-06-13 12:41:19 -06:00
parent 2b92d0f119
commit 7916a2352f
19 changed files with 582 additions and 28 deletions

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@
/product_key.txt
/*_product_key.txt
.vscode/settings.json
deploy_web.sh

View File

@@ -23,6 +23,8 @@
"@start9labs/emver": "^0.1.5",
"aes-js": "^3.1.2",
"ansi-to-html": "^0.7.2",
"cbor": "npm:@jprochazk/cbor@^0.4.9",
"cbor-web": "^8.1.0",
"core-js": "^3.21.1",
"dompurify": "^2.3.6",
"fast-json-patch": "^3.1.1",
@@ -4638,6 +4640,20 @@
}
]
},
"node_modules/cbor": {
"name": "@jprochazk/cbor",
"version": "0.4.9",
"resolved": "https://registry.npmjs.org/@jprochazk/cbor/-/cbor-0.4.9.tgz",
"integrity": "sha512-FWNnkOtWrFOLXKG2nzOHR/EnCCGZZPvatAvWXDmkTDxgjj9JHDK3DkMUHcFCY3a9weylMCSO/nLOUM170NAO0Q=="
},
"node_modules/cbor-web": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/cbor-web/-/cbor-web-8.1.0.tgz",
"integrity": "sha512-2hWHHMVrfffgoEmsAUh8vCxHoLa1vgodtC73+C5cSarkJlwTapnqAzcHINlP6Ej0DXuP4OmmJ9LF+JaNM5Lj/g==",
"engines": {
"node": ">=12.19"
}
},
"node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
@@ -12290,9 +12306,9 @@
"dev": true
},
"node_modules/semver-regex": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-3.1.3.tgz",
"integrity": "sha512-Aqi54Mk9uYTjVexLnR67rTyBusmwd04cLkHy9hNvk3+G3nT2Oyg7E0l4XVbOaNwIvQ3hHeYxGcyEy+mKreyBFQ==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-3.1.4.tgz",
"integrity": "sha512-6IiqeZNgq01qGf0TId0t3NvKzSvUsjcpdEO3AQNeIjR6A2+ckTnQlDpl4qu1bjRv0RzN3FP9hzFmws3lKqRWkA==",
"dev": true,
"engines": {
"node": ">=8"
@@ -17690,6 +17706,16 @@
"integrity": "sha512-MWPzG54AGdo3nWx7zHZTefseM5Y1ccM7hlQKHRqJkPozUaw3hNbBTMmLn16GG2FUzjR13Cr3NPfhIieX5PzXDA==",
"dev": true
},
"cbor": {
"version": "npm:@jprochazk/cbor@0.4.9",
"resolved": "https://registry.npmjs.org/@jprochazk/cbor/-/cbor-0.4.9.tgz",
"integrity": "sha512-FWNnkOtWrFOLXKG2nzOHR/EnCCGZZPvatAvWXDmkTDxgjj9JHDK3DkMUHcFCY3a9weylMCSO/nLOUM170NAO0Q=="
},
"cbor-web": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/cbor-web/-/cbor-web-8.1.0.tgz",
"integrity": "sha512-2hWHHMVrfffgoEmsAUh8vCxHoLa1vgodtC73+C5cSarkJlwTapnqAzcHINlP6Ej0DXuP4OmmJ9LF+JaNM5Lj/g=="
},
"chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
@@ -23321,9 +23347,9 @@
"dev": true
},
"semver-regex": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-3.1.3.tgz",
"integrity": "sha512-Aqi54Mk9uYTjVexLnR67rTyBusmwd04cLkHy9hNvk3+G3nT2Oyg7E0l4XVbOaNwIvQ3hHeYxGcyEy+mKreyBFQ==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-3.1.4.tgz",
"integrity": "sha512-6IiqeZNgq01qGf0TId0t3NvKzSvUsjcpdEO3AQNeIjR6A2+ckTnQlDpl4qu1bjRv0RzN3FP9hzFmws3lKqRWkA==",
"dev": true
},
"send": {

View File

@@ -37,6 +37,8 @@
"@start9labs/emver": "^0.1.5",
"aes-js": "^3.1.2",
"ansi-to-html": "^0.7.2",
"cbor": "npm:@jprochazk/cbor@^0.4.9",
"cbor-web": "^8.1.0",
"core-js": "^3.21.1",
"dompurify": "^2.3.6",
"fast-json-patch": "^3.1.1",
@@ -49,8 +51,8 @@
"patch-db-client": "file: ../../../patch-db/client",
"pbkdf2": "^3.1.2",
"rxjs": "^6.6.7",
"tslib": "^2.3.0",
"ts-matches": "^5.1.0",
"tslib": "^2.3.0",
"uuid": "^8.3.2",
"zone.js": "^0.11.5"
},

View File

@@ -33,6 +33,7 @@
<ion-item-divider></ion-item-divider>
<ion-item-group></ion-item-group>
<ion-label></ion-label>
<ion-label style="font-weight: bold"></ion-label>
<ion-list></ion-list>
<ion-loading></ion-loading>
<ion-modal></ion-modal>

View File

@@ -60,6 +60,7 @@ const ICONS = [
'play-outline',
'power',
'pulse',
'push-outline',
'qr-code-outline',
'receipt-outline',
'refresh',

View File

@@ -67,6 +67,11 @@ const routes: Routes = [
loadChildren: () =>
import('./sessions/sessions.module').then(m => m.SessionsPageModule),
},
{
path: 'sideload',
loadChildren: () =>
import('./sideload/sideload.module').then(m => m.SideloadPageModule),
},
{
path: 'specs',
loadChildren: () =>

View File

@@ -331,6 +331,17 @@ export class ServerShowPage {
detail: true,
disabled: of(false),
},
{
title: 'Manually install a service',
description: `Install a service by drag n' drop`,
icon: 'push-outline',
action: () =>
this.navCtrl.navigateForward(['sideload'], {
relativeTo: this.route,
}),
detail: true,
disabled: of(false),
},
{
title: 'Marketplace Settings',
description: 'Add or remove marketplaces',

View File

@@ -0,0 +1,39 @@
import {
Directive,
ElementRef,
EventEmitter,
HostBinding,
HostListener,
Output,
} from '@angular/core'
import { DomSanitizer } from '@angular/platform-browser'
@Directive({
selector: '[appDnd]',
})
export class DragNDropDirective {
@Output() onFileDropped: EventEmitter<any> = new EventEmitter()
@HostBinding('style.background') private background = 'rgba(24, 24, 24, 0.5)'
constructor(el: ElementRef, private sanitizer: DomSanitizer) {}
@HostListener('dragover', ['$event']) public onDragOver(evt: DragEvent) {
evt.preventDefault()
evt.stopPropagation()
this.background = '#6a937b3c'
}
@HostListener('dragleave', ['$event']) public onDragLeave(evt: DragEvent) {
evt.preventDefault()
evt.stopPropagation()
this.background = 'rgba(24, 24, 24, 0.5)'
}
@HostListener('drop', ['$event']) public onDrop(evt: DragEvent) {
evt.preventDefault()
evt.stopPropagation()
this.background = ' rgba(24, 24, 24, 0.5)'
this.onFileDropped.emit(evt)
}
}

View File

@@ -0,0 +1,26 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { SideloadPage } from './sideload.page'
import { Routes, RouterModule } from '@angular/router'
import { EmverPipesModule, SharedPipesModule } from '@start9labs/shared'
import { DragNDropDirective } from './dnd.directive'
const routes: Routes = [
{
path: '',
component: SideloadPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
SharedPipesModule,
EmverPipesModule,
],
declarations: [SideloadPage, DragNDropDirective],
})
export class SideloadPageModule {}

View File

@@ -0,0 +1,94 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="embassy"></ion-back-button>
</ion-buttons>
<ion-title>Manually install a service</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-text-center">
<!-- file upload -->
<div
*ngIf="!toUpload.file"
class="drop-area"
[class.drop-area_mobile]="isMobile"
appDnd
(onFileDropped)="handleFileDrop($event)"
>
<ion-icon
name="cloud-upload-outline"
color="dark"
style="font-size: 42px"
></ion-icon>
<h4>To install a service manually, upload the s9pk here</h4>
<p *ngIf="onTor">
<ion-text color="success"
>Tip: switch to LAN for faster uploads.</ion-text
>
</p>
<br />
<ion-button color="primary" type="file">
<label for="upload-photo">Browse</label>
<input
type="file"
style="position: absolute; opacity: 0; height: 100%"
id="upload-photo"
(change)="handleFileInput($event)"
/>
</ion-button>
</div>
<!-- file uploaded -->
<div class="drop-area_filled" *ngIf="toUpload.file">
<div class="inline" *ngIf="valid; else invalid">
<ion-icon name="checkmark-circle-outline" color="success"></ion-icon>
<h4>{{ message }}</h4>
</div>
<ng-template #invalid>
<div class="area">
<div class="inline">
<ion-icon
*ngIf="!valid"
name="close-circle-outline"
color="danger"
></ion-icon>
<h4><ion-text color="danger">{{ message }}</ion-text></h4>
</div>
<ion-button color="primary" (click)="clearToUpload()">
Try again
</ion-button>
</div>
</ng-template>
<br />
<div *ngIf="valid">
<div *ngIf="toUpload.manifest " class="service-card">
<div class="row row_end">
<ion-button
style="
--background-hover: transparent;
--padding-end: 0px;
--padding-start: 0px;
"
fill="clear"
size="small"
(click)="clearToUpload()"
>
<ion-icon slot="icon-only" name="close" color="danger"></ion-icon>
</ion-button>
</div>
<div class="row">
<img
*ngIf="toUpload.icon"
[alt]="toUpload.manifest.title + ' Icon'"
[src]="toUpload.icon | trustUrl"
/>
<h2>{{ toUpload.manifest.title }}</h2>
<p>{{ toUpload.manifest.version | displayEmver }}</p>
</div>
</div>
<ion-button color="primary" (click)="handleUpload()">
Upload & Install
</ion-button>
</div>
</div>
</ion-content>

View File

@@ -0,0 +1,90 @@
.inline {
* {
vertical-align: initial;
padding-right: 5px;
}
}
.area {
flex-direction: column;
justify-content: center;
}
.drop-area {
display: flex;
background-color: rgba(24, 24, 24, 0.5);
flex-direction: column;
justify-content: center;
align-items: center;
border-style: dashed;
border-width: 2px;
border-color: var(--ion-color-dark);
color: var(--ion-color-dark);
border-radius: 5px;
margin: 60px;
padding: 30px;
min-height: 600px;
&_filled {
display: flex;
background-color: rgba(24, 24, 24, 0.5);
flex-direction: column;
justify-content: center;
align-items: center;
border-style: solid;
border-width: 2px;
border-color: var(--ion-color-dark);
color: var(--ion-color-dark);
border-radius: 5px;
margin: 60px;
padding: 30px;
min-height: 600px;
}
&_mobile {
border-width: 0px !important;
}
ion-input {
color: var(--ion-color-dark);
}
}
.service-card {
background: radial-gradient(var(--ion-color-step-100), transparent);
min-width: 200px;
max-width: 300px;
height: auto;
padding: 0;
display: flex;
flex-direction: column;
justify-content: center;
margin: 20px 20px 40px 20px;
border-style: solid;
border-color: var(--ion-color-step-100);
border-radius: 7px;
padding: 4px 8px 8px 8px;
.row {
width: auto;
&_end {
align-self: end;
ion-button {
width: 80%;
}
}
}
img {
width: 60px;
text-align: center;
}
h2,
p {
text-align: center;
}
}

View File

@@ -0,0 +1,205 @@
import { Component } from '@angular/core'
import { isPlatform, LoadingController, NavController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { Manifest } from 'src/app/services/patch-db/data-model'
import { ConfigService } from 'src/app/services/config.service'
import cbor from 'cbor'
import { ErrorToastService } from '@start9labs/shared'
interface Positions {
[key: string]: [bigint, bigint] // [position, length]
}
const MAGIC = new Uint8Array([59, 59])
const VERSION = new Uint8Array([1])
@Component({
selector: 'sideload',
templateUrl: './sideload.page.html',
styleUrls: ['./sideload.page.scss'],
})
export class SideloadPage {
isMobile = isPlatform(window, 'ios') || isPlatform(window, 'android')
toUpload: {
manifest: Manifest | null
icon: string | null
file: File | null
} = {
manifest: null,
icon: null,
file: null,
}
onTor = this.config.isTor()
valid: boolean
message: string
constructor(
private readonly loadingCtrl: LoadingController,
private readonly api: ApiService,
private readonly navCtrl: NavController,
private readonly errToast: ErrorToastService,
private readonly config: ConfigService,
) {}
handleFileDrop(e: any) {
const files = e.dataTransfer.files
this.setFile(files)
}
handleFileInput(e: any) {
const files = e.target.files
this.setFile(files)
}
async setFile(files?: File[]) {
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Verifying Package',
cssClass: 'loader',
})
await loader.present()
if (!files || !files.length) return
this.toUpload.file = files[0]
// verify valid s9pk
const magic = new Uint8Array(
await readBlobToArrayBuffer(this.toUpload.file.slice(0, 2)),
)
const version = new Uint8Array(
await readBlobToArrayBuffer(this.toUpload.file.slice(2, 3)),
)
if (compare(magic, MAGIC) && compare(version, VERSION)) {
loader.dismiss()
this.valid = true
this.message = 'A valid package file has been detected!'
await this.parseS9pk(this.toUpload.file)
} else {
loader.dismiss()
this.valid = false
this.message = 'Invalid package file'
}
}
clearToUpload() {
this.toUpload.file = null
this.toUpload.manifest = null
this.toUpload.icon = null
}
async handleUpload() {
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Uploading Package',
cssClass: 'loader',
})
await loader.present()
try {
const guid = await this.api.sideloadPackage({
manifest: this.toUpload.manifest!,
icon: this.toUpload.icon!,
})
this.api
.uploadPackage(guid, await readBlobToArrayBuffer(this.toUpload.file!))
.catch(e => {
this.errToast.present(e)
})
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
await this.navCtrl.navigateForward(
`/services/${this.toUpload.manifest!.id}`,
)
this.clearToUpload()
}
}
async parseS9pk(file: Blob) {
const positions: Positions = {}
// magic=2bytes, version=1bytes, pubkey=32bytes, signature=64bytes, toc_length=4bytes = 103byte is starting point
let start = 103
let end = start + 1 // 104
const tocLength = new DataView(
await readBlobToArrayBuffer(
this.toUpload.file?.slice(99, 103) ?? new Blob(),
),
).getUint32(0, false)
await getPositions(start, end, file, positions, tocLength as any)
await this.getManifest(positions, file)
await this.getIcon(positions, file)
}
async getManifest(positions: Positions, file: Blob) {
const data = await readBlobToArrayBuffer(
file.slice(
Number(positions['manifest'][0]),
Number(positions['manifest'][0]) + Number(positions['manifest'][1]),
),
)
this.toUpload.manifest = await cbor.decode(data, true)
}
async getIcon(positions: Positions, file: Blob) {
const data = file.slice(
Number(positions['icon'][0]),
Number(positions['icon'][0]) + Number(positions['icon'][1]),
)
this.toUpload.icon = await readBlobAsDataURL(data)
}
}
async function getPositions(
initialStart: number,
initialEnd: number,
file: Blob,
positions: Positions,
tocLength: number,
) {
let start = initialStart
let end = initialEnd
const titleLength = new Uint8Array(
await readBlobToArrayBuffer(file.slice(start, end)),
)[0]
const tocTitle = await file.slice(end, end + titleLength).text()
start = end + titleLength
end = start + 8
const chapterPosition = new DataView(
await readBlobToArrayBuffer(file.slice(start, end)),
).getBigUint64(0, false)
start = end
end = start + 8
const chapterLength = new DataView(
await readBlobToArrayBuffer(file.slice(start, end)),
).getBigUint64(0, false)
positions[tocTitle] = [chapterPosition, chapterLength]
start = end
end = start + 1
if (end <= tocLength + (initialStart - 1)) {
await getPositions(start, end, file, positions, tocLength)
}
}
async function readBlobAsDataURL(f: Blob): Promise<string> {
const reader = new FileReader()
reader.readAsDataURL(f)
return new Promise(resolve => {
reader.onloadend = () => {
resolve(reader.result as string)
}
})
}
async function readBlobToArrayBuffer(f: Blob): Promise<ArrayBuffer> {
const reader = new FileReader()
reader.readAsArrayBuffer(f)
return new Promise(resolve => {
reader.onloadend = () => {
resolve(reader.result as ArrayBuffer)
}
})
}
function compare(a: Uint8Array, b: Uint8Array) {
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false
}
return true
}

View File

@@ -5,6 +5,7 @@ import { ConfigSpec } from 'src/app/pkg-config/config-types'
import {
DataModel,
DependencyError,
Manifest,
} from 'src/app/services/patch-db/data-model'
export module RR {
@@ -239,6 +240,12 @@ export module RR {
spec: ConfigSpec
}
export interface SideloadPackageReq {
manifest: Manifest
icon: string // base64
}
export type SideloadPacakgeRes = string //guid
// marketplace
export type GetMarketplaceDataReq = { 'server-id': string }

View File

@@ -27,6 +27,9 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
// for getting static files: ex icons, instructions, licenses
abstract getStatic(url: string): Promise<string>
// for sideloading packages
abstract uploadPackage(guid: string, body: ArrayBuffer): Promise<string>
// db
abstract getRevisions(since: number): Promise<RR.GetRevisionsRes>
@@ -260,6 +263,10 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
deleteRecoveredPackage = (params: RR.UninstallPackageReq) =>
this.syncResponse(() => this.deleteRecoveredPackageRaw(params))()
abstract sideloadPackage(
params: RR.SideloadPackageReq,
): Promise<RR.SideloadPacakgeRes>
// Helper allowing quick decoration to sync the response patch and return the response contents.
// Pass in a tempUpdate function which returns a UpdateTemp corresponding to a temporary
// state change you'd like to enact prior to request and expired when request terminates.

View File

@@ -23,6 +23,15 @@ export class LiveApiService extends ApiService {
})
}
async uploadPackage(guid: string, body: ArrayBuffer): Promise<string> {
return this.http.httpRequest({
method: Method.POST,
body,
url: `/rest/rpc/${guid}`,
responseType: 'text',
})
}
// db
async getRevisions(since: number): Promise<RR.GetRevisionsRes> {
@@ -333,4 +342,13 @@ export class LiveApiService extends ApiService {
params,
})
}
async sideloadPackage(
params: RR.SideloadPackageReq,
): Promise<RR.SideloadPacakgeRes> {
return this.http.rpcRequest({
method: 'package.sideload',
params,
})
}
}

View File

@@ -48,6 +48,11 @@ export class MockApiService extends ApiService {
return markdown
}
async uploadPackage(guid: string, body: ArrayBuffer): Promise<string> {
await pauseFor(2000)
return 'success'
}
// db
async getRevisions(since: number): Promise<RR.GetRevisionsRes> {
@@ -750,6 +755,13 @@ export class MockApiService extends ApiService {
}
}
async sideloadPackage(
params: RR.SideloadPackageReq,
): Promise<RR.SideloadPacakgeRes> {
await pauseFor(2000)
return '4120e092-05ab-4de2-9fbd-c3f1f4b1df9e' // no significance, randomly generated
}
private async updateProgress(id: string): Promise<void> {
const progress = { ...PROGRESS }
const phases = [

View File

@@ -47,7 +47,10 @@ function getDependencyStatus(pkg: PackageDataEntry): DependencyStatus | null {
return depIds.length ? DependencyStatus.Warning : DependencyStatus.Satisfied
}
function getHealthStatus(status: Status): HealthStatus | null {
function getHealthStatus(
status: Status,
hasHealthChecks: boolean,
): HealthStatus | null {
if (status.main.status !== PackageMainStatus.Running || !status.main.health) {
return null
}

View File

@@ -12,5 +12,11 @@
"pathRewrite": {
"^/public": ""
}
},
"/rest/rpc/*": {
"target": "http://<CHANGE_ME>/rest/rpc",
"pathRewrite": {
"^/rest/rpc": ""
}
}
}