This commit is contained in:
Matt Hill
2025-02-10 22:41:29 -07:00
parent 95722802dc
commit 7d1096dbd8
32 changed files with 239 additions and 1044 deletions

View File

@@ -1,62 +0,0 @@
<ion-item *ngIf="iFace">
<ion-icon
slot="start"
size="large"
[name]="
iFace.type === 'ui'
? 'desktop-outline'
: iFace.type === 'api'
? 'terminal-outline'
: 'people-outline'
"
></ion-icon>
<ion-label>
<h1>{{ iFace.name }}</h1>
<h2>{{ iFace.description }}</h2>
<ion-button style="margin-right: 8px" (click)="presentDomainForm()">
Add Domain
</ion-button>
<ion-button
[color]="iFace.public ? 'danger' : 'success'"
(click)="togglePublic()"
>
Make {{ iFace.public ? 'Private' : 'Public' }}
</ion-button>
</ion-label>
</ion-item>
<div *ngIf="iFace" style="padding-left: 64px">
<ion-item *ngFor="let address of iFace.addresses">
<ion-label>
<h2>{{ address.name }}</h2>
<p>{{ address.url }}</p>
<ion-button
*ngIf="address.isDomain"
color="danger"
(click)="removeStandard(address.url)"
>
Remove
</ion-button>
<ion-button
*ngIf="address.isOnion"
color="danger"
(click)="removeOnion(address.url)"
>
Remove
</ion-button>
</ion-label>
<ion-buttons slot="end">
<ion-button *ngIf="address.isDomain" (click)="showAcme(address.acme)">
<ion-icon name="finger-print"></ion-icon>
</ion-button>
<ion-button *ngIf="iFace.type === 'ui'" (click)="launch(address.url)">
<ion-icon name="open-outline"></ion-icon>
</ion-button>
<ion-button (click)="showQR(address.url)">
<ion-icon name="qr-code-outline"></ion-icon>
</ion-button>
<ion-button (click)="copy(address.url)">
<ion-icon name="copy-outline"></ion-icon>
</ion-button>
</ion-buttons>
</ion-item>
</div>

View File

@@ -1,3 +0,0 @@
p {
font-family: 'Courier New';
}

View File

@@ -1,393 +0,0 @@
import { Component, Inject, Input } from '@angular/core'
import { WINDOW } from '@ng-web-apis/common'
import {
AlertController,
ModalController,
ToastController,
} from '@ionic/angular'
import {
copyToClipboard,
ErrorService,
LoadingService,
} from '@start9labs/shared'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { PatchDB } from 'patch-db-client'
import { QRComponent } from 'src/app/components/qr/qr.component'
import { firstValueFrom } from 'rxjs'
import { ISB, T, utils } from '@start9labs/start-sdk'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { FormComponent } from 'src/app/components/form.component'
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
import { toAcmeName } from 'src/app/util/acme'
import { ConfigService } from 'src/app/services/config.service'
export type MappedInterface = T.ServiceInterface & {
addresses: MappedAddress[]
public: boolean
}
export type MappedAddress = {
name: string
url: string
isDomain: boolean
isOnion: boolean
acme: string | null
}
@Component({
selector: 'interface-info',
templateUrl: './interface-info.component.html',
styleUrls: ['./interface-info.component.scss'],
})
export class InterfaceInfoComponent {
@Input() pkgId?: string
@Input() iFace!: MappedInterface
constructor(
private readonly toastCtrl: ToastController,
private readonly modalCtrl: ModalController,
private readonly errorService: ErrorService,
private readonly loader: LoadingService,
private readonly api: ApiService,
private readonly formDialog: FormDialogService,
private readonly alertCtrl: AlertController,
private readonly patch: PatchDB<DataModel>,
private readonly config: ConfigService,
@Inject(WINDOW) private readonly windowRef: Window,
) {}
launch(url: string): void {
this.windowRef.open(url, '_blank', 'noreferrer')
}
async togglePublic() {
const loader = this.loader
.open(`Making ${this.iFace.public ? 'private' : 'public'}`)
.subscribe()
const params = {
internalPort: this.iFace.addressInfo.internalPort,
public: !this.iFace.public,
}
try {
if (this.pkgId) {
await this.api.pkgBindingSetPubic({
...params,
host: this.iFace.addressInfo.hostId,
package: this.pkgId,
})
} else {
await this.api.serverBindingSetPubic(params)
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
async presentDomainForm() {
const acme = await firstValueFrom(this.patch.watch$('serverInfo', 'acme'))
const spec = getDomainSpec(Object.keys(acme))
this.formDialog.open(FormComponent, {
label: 'Add Domain',
data: {
spec: await configBuilderToSpec(spec),
buttons: [
{
text: 'Save',
handler: async (val: typeof spec._TYPE) => {
if (val.type.selection === 'standard') {
return this.saveStandard(
val.type.value.domain,
val.type.value.acme,
)
} else {
return this.saveTor(val.type.value.key)
}
},
},
],
},
})
}
async removeStandard(url: string) {
const loader = this.loader.open('Removing').subscribe()
const params = {
domain: new URL(url).hostname,
}
try {
if (this.pkgId) {
await this.api.pkgRemoveDomain({
...params,
package: this.pkgId,
host: this.iFace.addressInfo.hostId,
})
} else {
await this.api.serverRemoveDomain(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
async removeOnion(url: string) {
const loader = this.loader.open('Removing').subscribe()
const params = {
onion: new URL(url).hostname,
}
try {
if (this.pkgId) {
await this.api.pkgRemoveOnion({
...params,
package: this.pkgId,
host: this.iFace.addressInfo.hostId,
})
} else {
await this.api.serverRemoveOnion(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
async showAcme(url: string | null): Promise<void> {
const alert = await this.alertCtrl.create({
header: 'ACME Provider',
message: toAcmeName(url),
})
await alert.present()
}
async showQR(text: string): Promise<void> {
const modal = await this.modalCtrl.create({
component: QRComponent,
componentProps: {
text,
},
cssClass: 'qr-modal',
})
await modal.present()
}
async copy(address: string): Promise<void> {
let message = ''
await copyToClipboard(address || '').then(success => {
message = success
? 'Copied to clipboard!'
: 'Failed to copy to clipboard.'
})
const toast = await this.toastCtrl.create({
header: message,
position: 'bottom',
duration: 1000,
})
await toast.present()
}
private async saveStandard(domain: string, acme: string) {
const loader = this.loader.open('Saving').subscribe()
const params = {
domain,
acme: acme === 'none' ? null : acme,
private: false,
}
try {
if (this.pkgId) {
await this.api.pkgAddDomain({
...params,
package: this.pkgId,
host: this.iFace.addressInfo.hostId,
})
} else {
await this.api.serverAddDomain(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
private async saveTor(key: string | null) {
const loader = this.loader.open('Creating onion address').subscribe()
try {
let onion = key
? await this.api.addTorKey({ key })
: await this.api.generateTorKey({})
onion = `${onion}.onion`
if (this.pkgId) {
await this.api.pkgAddOnion({
onion,
package: this.pkgId,
host: this.iFace.addressInfo.hostId,
})
} else {
await this.api.serverAddOnion({ onion })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}
function getDomainSpec(acme: string[]) {
return ISB.InputSpec.of({
type: ISB.Value.union(
{ name: 'Type', default: 'standard' },
ISB.Variants.of({
standard: {
name: 'Standard',
spec: ISB.InputSpec.of({
domain: ISB.Value.text({
name: 'Domain',
description: 'The domain or subdomain you want to use',
placeholder: `e.g. 'mydomain.com' or 'sub.mydomain.com'`,
required: true,
default: null,
patterns: [utils.Patterns.domain],
}),
acme: ISB.Value.select({
name: 'ACME Provider',
description:
'Select which ACME provider to use for obtaining your SSL certificate. Add new ACME providers in the System tab. Optionally use your system Root CA. Note: only devices that have trusted your Root CA will be able to access the domain without security warnings.',
values: acme.reduce(
(obj, url) => ({
...obj,
[url]: toAcmeName(url),
}),
{ none: 'None (use system Root CA)' } as Record<string, string>,
),
default: '',
}),
}),
},
onion: {
name: 'Onion',
spec: ISB.InputSpec.of({
key: ISB.Value.text({
name: 'Private Key (optional)',
description:
'Optionally provide a base64-encoded ed25519 private key for generating the Tor V3 (.onion) address. If not provided, a random key will be generated and used.',
required: false,
default: null,
patterns: [utils.Patterns.base64],
}),
}),
},
}),
),
})
}
export function getAddresses(
serviceInterface: T.ServiceInterface,
host: T.Host,
config: ConfigService,
): MappedAddress[] {
const addressInfo = serviceInterface.addressInfo
let hostnames = host.hostnameInfo[addressInfo.internalPort]
hostnames = hostnames.filter(
h =>
config.isLocalhost() ||
h.kind !== 'ip' ||
h.hostname.kind !== 'ipv6' ||
!h.hostname.value.startsWith('fe80::'),
)
if (config.isLocalhost()) {
const local = hostnames.find(
h => h.kind === 'ip' && h.hostname.kind === 'local',
)
if (local) {
hostnames.unshift({
kind: 'ip',
networkInterfaceId: 'lo',
public: false,
hostname: {
kind: 'local',
port: local.hostname.port,
sslPort: local.hostname.sslPort,
value: 'localhost',
},
})
}
}
const mappedAddresses = hostnames.flatMap(h => {
let name = ''
let isDomain = false
let isOnion = false
let acme: string | null = null
if (h.kind === 'onion') {
name = `Tor`
isOnion = true
} else {
const hostnameKind = h.hostname.kind
if (hostnameKind === 'domain') {
name = 'Domain'
isDomain = true
acme = host.domains[h.hostname.domain]?.acme
} else {
name =
hostnameKind === 'local'
? 'Local'
: `${h.networkInterfaceId} (${hostnameKind})`
}
}
const addresses = utils.addressHostToUrl(addressInfo, h)
if (addresses.length > 1) {
return addresses.map(url => ({
name: `${name} (${new URL(url).protocol
.replace(':', '')
.toUpperCase()})`,
url,
isDomain,
isOnion,
acme,
}))
} else {
return addresses.map(url => ({
name,
url,
isDomain,
isOnion,
acme,
}))
}
})
return mappedAddresses.filter(
(value, index, self) => index === self.findIndex(t => t.url === value.url),
)
}

View File

@@ -1,11 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { InterfaceInfoComponent } from './interface-info.component'
@NgModule({
declarations: [InterfaceInfoComponent],
imports: [CommonModule, IonicModule],
exports: [InterfaceInfoComponent],
})
export class InterfaceInfoModule {}

View File

@@ -1,18 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { RouterModule, Routes } from '@angular/router'
import { ACMEPage } from './acme.page'
const routes: Routes = [
{
path: '',
component: ACMEPage,
},
]
@NgModule({
imports: [CommonModule, IonicModule, RouterModule.forChild(routes)],
declarations: [ACMEPage],
})
export class ACMEPageModule {}

View File

@@ -1,53 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="system"></ion-back-button>
</ion-buttons>
<ion-title>ACME</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top with-widgets">
<ion-item-group>
<!-- always -->
<ion-item>
<ion-label>
<h2>
Register with one or more ACME providers such as Let's Encrypt in
order to generate SSL (https) certificates on-demand for clearnet
hosting
<a [href]="docsUrl" target="_blank" rel="noreferrer">
View instructions
</a>
</h2>
</ion-label>
</ion-item>
<ion-item-divider>Saved Providers</ion-item-divider>
<ng-container *ngIf="acme$ | async as acme">
<ion-item button detail="false" (click)="addAcme(acme)">
<ion-icon slot="start" name="add" color="dark"></ion-icon>
<ion-label>
<b>Add Provider</b>
</ion-label>
</ion-item>
<ion-item *ngFor="let provider of acme">
<ion-icon slot="start" name="finger-print" size="medium"></ion-icon>
<ion-label>
<h2>{{ toAcmeName(provider.url) }}</h2>
<p>Contact: {{ provider.contactString }}</p>
</ion-label>
<ion-buttons slot="end">
<ion-button (click)="editAcme(provider.url, provider.contact)">
<ion-icon slot="start" name="pencil"></ion-icon>
</ion-button>
<ion-button (click)="removeAcme(provider.url)">
<ion-icon slot="start" name="trash-outline"></ion-icon>
</ion-button>
</ion-buttons>
</ion-item>
</ng-container>
</ion-item-group>
</ion-content>

View File

@@ -1,42 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { TuiInputModule } from '@taiga-ui/kit'
import {
TuiNotificationModule,
TuiTextfieldControllerModule,
} from '@taiga-ui/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { EmailPage } from './email.page'
import { FormModule } from 'src/app/components/form/form.module'
import { IonicModule } from '@ionic/angular'
import { TuiErrorModule, TuiModeModule } from '@taiga-ui/core'
import { TuiAppearanceModule, TuiButtonModule } from '@taiga-ui/experimental'
const routes: Routes = [
{
path: '',
component: EmailPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
CommonModule,
FormsModule,
ReactiveFormsModule,
TuiButtonModule,
TuiInputModule,
FormModule,
TuiNotificationModule,
TuiTextfieldControllerModule,
TuiAppearanceModule,
TuiModeModule,
TuiErrorModule,
],
declarations: [EmailPage],
})
export class EmailPageModule {}

View File

@@ -1,70 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-title>Email</ion-title>
<ion-buttons slot="start">
<ion-back-button defaultHref="system"></ion-back-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<tui-notification>
Fill out the form below to connect to an external SMTP server. With your
permission, installed services can use the SMTP server to send emails. To
grant permission to a particular service, visit that service's "Actions"
page. Not all services support sending emails.
<a
href="https://docs.start9.com/latest/user-manual/0.3.5.x/smtp"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</tui-notification>
<ng-container *ngIf="form$ | async as form">
<form [formGroup]="form" [style.text-align]="'right'">
<h3 class="g-title">SMTP Credentials</h3>
<form-group
*ngIf="spec | async as resolved"
[spec]="resolved"
></form-group>
<button
*ngIf="isSaved"
tuiButton
appearance="destructive"
[style.margin-top.rem]="1"
[style.margin-right.rem]="1"
(click)="save(null)"
>
Delete
</button>
<button
tuiButton
[style.margin-top.rem]="1"
[disabled]="form.invalid"
(click)="save(form.value)"
>
Save
</button>
</form>
<form [style.text-align]="'right'">
<h3 class="g-title">Send Test Email</h3>
<tui-input
[(ngModel)]="testAddress"
[ngModelOptions]="{ standalone: true }"
>
To Address
<input tuiTextfield inputmode="email" />
</tui-input>
<button
tuiButton
appearance="secondary"
[style.margin-top.rem]="1"
[disabled]="!testAddress || form.invalid"
(click)="sendTestEmail(form.value)"
>
Send
</button>
</form>
</ng-container>
</ion-content>

View File

@@ -1,9 +0,0 @@
form {
padding-top: 24px;
margin: auto;
max-width: 30rem;
}
h3 {
display: flex;
}

View File

@@ -1,83 +0,0 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { IST, inputSpec } from '@start9labs/start-sdk'
import { TuiDialogService } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { switchMap, tap } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormService } from 'src/app/services/form.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
@Component({
selector: 'email-page',
templateUrl: './email.page.html',
styleUrls: ['./email.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EmailPage {
private readonly dialogs = inject(TuiDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly formService = inject(FormService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService)
isSaved = false
testAddress = ''
readonly spec: Promise<IST.InputSpec> = configBuilderToSpec(
inputSpec.constants.customSmtp,
)
readonly form$ = this.patch.watch$('serverInfo', 'smtp').pipe(
tap(value => (this.isSaved = !!value)),
switchMap(async value =>
this.formService.createForm(await this.spec, value),
),
)
async save(
value: typeof inputSpec.constants.customSmtp._TYPE | null,
): Promise<void> {
const loader = this.loader.open('Saving...').subscribe()
try {
if (value) {
await this.api.setSmtp(value)
this.isSaved = true
} else {
await this.api.clearSmtp({})
this.isSaved = false
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
async sendTestEmail(value: typeof inputSpec.constants.customSmtp._TYPE) {
const loader = this.loader.open('Sending email...').subscribe()
try {
await this.api.testSmtp({
to: this.testAddress,
...value,
})
} catch (e: any) {
return this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
this.dialogs
.open(
`A test email has been sent to ${this.testAddress}.<br /><br /><b>Check your spam folder and mark as not spam</b>`,
{
label: 'Success',
size: 's',
},
)
.subscribe()
}
}

View File

@@ -104,10 +104,10 @@ export class InterfaceComponent {
packageId: string
interfaceId: string
}
@Input({ required: true }) serviceInterface!: ServiceInterfaceWithAddresses
@Input({ required: true }) serviceInterface!: MappedServiceInterface
}
export type ServiceInterfaceWithAddresses = T.ServiceInterface & {
export type MappedServiceInterface = T.ServiceInterface & {
addresses: {
clearnet: AddressDetails[]
local: AddressDetails[]

View File

@@ -1,6 +1,7 @@
import { ISB, IST, T, utils } from '@start9labs/start-sdk'
import { TuiDialogOptions } from '@taiga-ui/core'
import { TuiConfirmData } from '@taiga-ui/kit'
import { ConfigService } from 'src/app/services/config.service'
import { NetworkInfo } from 'src/app/services/patch-db/data-model'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
@@ -49,44 +50,80 @@ export function getClearnetSpec({
)
}
export type AddressDetails = {
label?: string
url: string
}
export function getMultihostAddresses(
// @TODO Aiden audit
export function getAddresses(
serviceInterface: T.ServiceInterface,
host: T.Host,
config: ConfigService,
): {
clearnet: AddressDetails[]
clearnet: (AddressDetails & { acme: string | null })[]
local: AddressDetails[]
tor: AddressDetails[]
} {
const addressInfo = serviceInterface.addressInfo
const hostnamesInfo = host.hostnameInfo[addressInfo.internalPort]
const clearnet: AddressDetails[] = []
let hostnames = host.hostnameInfo[addressInfo.internalPort]
hostnames = hostnames.filter(
h =>
config.isLocalhost() ||
h.kind !== 'ip' ||
h.hostname.kind !== 'ipv6' ||
!h.hostname.value.startsWith('fe80::'),
)
if (config.isLocalhost()) {
const local = hostnames.find(
h => h.kind === 'ip' && h.hostname.kind === 'local',
)
if (local) {
hostnames.unshift({
kind: 'ip',
networkInterfaceId: 'lo',
public: false,
hostname: {
kind: 'local',
port: local.hostname.port,
sslPort: local.hostname.sslPort,
value: 'localhost',
},
})
}
}
const clearnet: (AddressDetails & { acme: string | null })[] = []
const local: AddressDetails[] = []
const tor: AddressDetails[] = []
hostnamesInfo.forEach(hostnameInfo => {
utils.addressHostToUrl(addressInfo, hostnameInfo).forEach(url => {
// Onion
if (hostnameInfo.kind === 'onion') {
tor.push({ url })
// IP
hostnames.forEach(h => {
const addresses = utils.addressHostToUrl(addressInfo, h)
addresses.forEach(url => {
if (h.kind === 'onion') {
tor.push({
label: `Tor${
addresses.length > 1
? ` (${new URL(url).protocol.replace(':', '').toUpperCase()})`
: ''
}`,
url,
})
} else {
// Domain
if (hostnameInfo.hostname.kind === 'domain') {
clearnet.push({ url })
// Local
const hostnameKind = h.hostname.kind
if (hostnameKind === 'domain') {
clearnet.push({
label: 'Domain',
url,
acme: host.domains[h.hostname.domain]?.acme,
})
} else {
const hostnameKind = hostnameInfo.hostname.kind
local.push({
label:
hostnameKind === 'local'
? 'Local'
: `${hostnameInfo.networkInterfaceId} (${hostnameKind})`,
: `${h.networkInterfaceId} (${hostnameKind})`,
url,
})
}
@@ -99,4 +136,16 @@ export function getMultihostAddresses(
local,
tor,
}
// @TODO Aiden what was going on here in 036?
// return mappedAddresses.filter(
// (value, index, self) => index === self.findIndex(t => t.url === value.url),
// )
}
function getLabel(name: string, url: string, multiple: boolean) {}
export type AddressDetails = {
label: string
url: string
}

View File

@@ -79,13 +79,18 @@ export class UILaunchComponent {
@tuiPure
getInterfaces(pkg?: PackageDataEntry): T.ServiceInterface[] {
return pkg
? Object.values(pkg.serviceInterfaces).filter(({ type }) => type === 'ui')
? Object.values(pkg.serviceInterfaces).filter(
i =>
i.type === 'ui' &&
(i.addressInfo.scheme === 'http' ||
i.addressInfo.sslScheme === 'https'),
)
: []
}
getHref(info?: T.ServiceInterface): string | null {
return info && this.isRunning
? this.config.launchableAddress(info, this.pkg.hosts)
getHref(ui?: T.ServiceInterface): string | null {
return ui && this.isRunning
? this.config.launchableAddress(ui, this.pkg.hosts)
: null
}
}

View File

@@ -6,7 +6,8 @@ import { PatchDB } from 'patch-db-client'
import { combineLatest, map } from 'rxjs'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { getMultihostAddresses } from '../../../components/interfaces/interface.utils'
import { getAddresses } from '../../../components/interfaces/interface.utils'
import { ConfigService } from 'src/app/services/config.service'
@Component({
template: `
@@ -22,6 +23,7 @@ import { getMultihostAddresses } from '../../../components/interfaces/interface.
})
export class ServiceInterfaceRoute {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly config = inject(ConfigService)
readonly context = {
packageId: getPkgId(),
@@ -40,7 +42,11 @@ export class ServiceInterfaceRoute {
]).pipe(
map(([iFace, hosts]) => ({
...iFace,
addresses: getMultihostAddresses(iFace, hosts[iFace.addressInfo.hostId]),
addresses: getAddresses(
iFace,
hosts[iFace.addressInfo.hostId],
this.config,
),
})),
)
}

View File

@@ -1,12 +1,5 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { Exver } from '@start9labs/shared'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { TuiIcon } from '@taiga-ui/core'
import { BackupTarget } from 'src/app/services/api/api.types'
import { BackupType } from '../types/backup-type'
@Component({

View File

@@ -102,7 +102,8 @@ export class BackupsUpcomingComponent {
readonly targets = toSignal(from(this.api.getBackupTargets({})))
readonly current = toSignal(
inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'statusInfo', 'currentBackup', 'job')
// @TODO remove "as any" once this feature is real
.watch$('serverInfo', 'statusInfo', 'currentBackup' as any, 'job')
.pipe(map(job => job || {})),
)

View File

@@ -22,7 +22,6 @@ import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { ClientStorageService } from 'src/app/services/client-storage.service'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { hasCurrentDeps } from 'src/app/utils/has-deps'
import { getAllPackages, getManifest } from 'src/app/utils/get-package-data'
@@ -60,16 +59,14 @@ import { ToManifestPipe } from 'src/app/routes/portal/pipes/to-manifest'
</button>
}
@case (0) {
@if (showDevTools$ | async) {
<button
tuiButton
type="button"
appearance="tertiary-solid"
(click)="tryInstall()"
>
Reinstall
</button>
}
<button
tuiButton
type="button"
appearance="tertiary-solid"
(click)="tryInstall()"
>
Reinstall
</button>
}
}
}
@@ -114,8 +111,6 @@ export class MarketplaceControlsComponent {
@Input()
localFlavor!: boolean
readonly showDevTools$ = inject(ClientStorageService).showDevTools$
async tryInstall() {
const currentUrl = await firstValueFrom(
this.marketplaceService.getRegistryUrl$(),

View File

@@ -1,21 +1,31 @@
import { Component } from '@angular/core'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { PatchDB } from 'patch-db-client'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from '../../../services/patch-db/data-model'
import { FormDialogService } from '../../../services/form-dialog.service'
import { FormComponent } from '../../../components/form.component'
import { configBuilderToSpec } from '../../../util/configBuilderToSpec'
import { ISB, utils } from '@start9labs/start-sdk'
import { knownACME, toAcmeName } from 'src/app/util/acme'
import { knownACME, toAcmeName } from 'src/app/utils/acme'
import { map } from 'rxjs'
import { CommonModule } from '@angular/common'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
@Component({
selector: 'acme',
templateUrl: 'acme.page.html',
styleUrls: ['acme.page.scss'],
template: ``,
styles: [],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule],
})
export class ACMEPage {
export class SettingsACMEComponent {
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService)
readonly docsUrl = 'https://docs.start9.com/0.3.6/user-manual/acme'
acme$ = this.patch.watch$('serverInfo', 'acme').pipe(
@@ -36,14 +46,6 @@ export class ACMEPage {
toAcmeName = toAcmeName
constructor(
private readonly loader: LoadingService,
private readonly errorService: ErrorService,
private readonly api: ApiService,
private readonly patch: PatchDB<DataModel>,
private readonly formDialog: FormDialogService,
) {}
async addAcme(
providers: {
url: string

View File

@@ -28,6 +28,16 @@ import { EmailInfoComponent } from './info.component'
*ngIf="spec | async as resolved"
[spec]="resolved"
></form-group>
<button
*ngIf="isSaved"
tuiButton
appearance="destructive"
[style.margin-top.rem]="1"
[style.margin-right.rem]="1"
(click)="save(null)"
>
Delete
</button>
<button
tuiButton
[style.margin-top.rem]="1"
@@ -80,6 +90,8 @@ export class SettingsEmailComponent {
private readonly api = inject(ApiService)
testAddress = ''
isSaved = false
readonly spec: Promise<IST.InputSpec> = configBuilderToSpec(
inputSpec.constants.customSmtp,
)
@@ -91,13 +103,19 @@ export class SettingsEmailComponent {
),
)
async save(value: unknown): Promise<void> {
async save(
value: typeof inputSpec.constants.customSmtp._TYPE | null,
): Promise<void> {
const loader = this.loader.open('Saving...').subscribe()
try {
await this.api.configureEmail(
inputSpec.constants.customSmtp.validator.unsafeCast(value),
)
if (value) {
await this.api.setSmtp(value)
this.isSaved = true
} else {
await this.api.clearSmtp({})
this.isSaved = false
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
@@ -109,7 +127,7 @@ export class SettingsEmailComponent {
const loader = this.loader.open('Sending...').subscribe()
try {
await this.api.testEmail({
await this.api.testSmtp({
to: this.testAddress,
...form.value,
})

View File

@@ -1,118 +0,0 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
TemplateRef,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { ErrorService, LoadingService } from '@start9labs/shared'
import {
TuiAlertService,
TuiDialogService,
TuiIcon,
TuiLabel,
TuiTitle,
} from '@taiga-ui/core'
import { TUI_CONFIRM, TuiCheckbox } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { filter } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Component({
template: `
<ng-container *ngIf="server$ | async as server">
<button class="g-action" (click)="reset(tor)">
<tui-icon icon="@tui.rotate-cw" />
<div tuiTitle>
<strong>Reset Tor</strong>
<div tuiSubtitle>
Resetting the Tor daemon on your server may resolve Tor connectivity
issues.
</div>
</div>
</button>
<button class="g-action" (click)="zram(server.zram)">
<tui-icon [icon]="server.zram ? '@tui.zap-off' : '@tui.zap'" />
<div tuiTitle>
<strong>{{ server.zram ? 'Disable' : 'Enable' }} zram</strong>
<div tuiSubtitle>
Zram creates compressed swap in memory, resulting in faster I/O for
low RAM devices
</div>
</div>
</button>
</ng-container>
<ng-template #tor>
<p *ngIf="isTor">
You are currently connected over Tor. If you reset the Tor daemon, you
will lose connectivity until it comes back online.
</p>
<p *ngIf="!isTor">Reset Tor?</p>
<p>
Optionally wipe state to forcibly acquire new guard nodes. It is
recommended to try without wiping state first.
</p>
<label tuiLabel>
<input type="checkbox" tuiCheckbox [(ngModel)]="wipe" />
Wipe state
</label>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
FormsModule,
TuiTitle,
TuiIcon,
TuiLabel,
TuiCheckbox,
],
})
export class SettingsExperimentalComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly dialogs = inject(TuiDialogService)
private readonly alerts = inject(TuiAlertService)
readonly server$ = inject<PatchDB<DataModel>>(PatchDB).watch$('server-info')
readonly isTor = inject(ConfigService).isTor()
wipe = false
reset(content: TemplateRef<any>) {
this.wipe = false
this.dialogs
.open(TUI_CONFIRM, {
label: this.isTor ? 'Warning' : 'Confirm',
data: {
content,
yes: 'Reset',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.resetTor(this.wipe))
}
private async resetTor(wipeState: boolean) {
const loader = this.loader.open('Resetting Tor...').subscribe()
try {
await this.api.resetTor({
'wipe-state': wipeState,
reason: 'User triggered',
})
this.alerts.open('Tor reset in progress').subscribe()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -5,11 +5,29 @@ import { PatchDB } from 'patch-db-client'
import { Observable, map } from 'rxjs'
import {
InterfaceComponent,
ServiceInterfaceWithAddresses,
MappedServiceInterface,
} from 'src/app/routes/portal/components/interfaces/interface.component'
import { getMultihostAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils'
import { getAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils'
import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
const iface: T.ServiceInterface = {
id: '',
name: 'StartOS User Interface',
description:
'The primary user interface for your StartOS server, accessible from any browser.',
type: 'ui' as const,
masked: false,
addressInfo: {
hostId: '',
internalPort: 80,
scheme: 'http',
sslScheme: 'https',
suffix: '',
username: null,
},
}
@Component({
template: `
<app-interface
@@ -23,43 +41,17 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
imports: [CommonModule, InterfaceComponent],
})
export class StartOsUiComponent {
readonly ui$: Observable<ServiceInterfaceWithAddresses> = inject(
PatchDB<DataModel>,
private readonly config = inject(ConfigService)
readonly ui$: Observable<MappedServiceInterface> = inject<PatchDB<DataModel>>(
PatchDB,
)
.watch$('serverInfo', 'ui')
.watch$('serverInfo')
.pipe(
map(hosts => {
const serviceInterface: T.ServiceInterface = {
id: 'startos-ui',
name: 'StartOS UI',
description: 'The primary web user interface for StartOS',
type: 'ui',
hasPrimary: false,
masked: false,
addressInfo: {
hostId: '',
username: null,
internalPort: 80,
scheme: 'http',
sslScheme: 'https',
suffix: '',
},
}
// @TODO Aiden confirm this is correct
const host: T.Host = {
kind: 'multi',
bindings: {},
hostnameInfo: {
80: hosts,
},
addresses: [],
}
return {
...serviceInterface,
addresses: getMultihostAddresses(serviceInterface, host),
}
}),
map(server => ({
...iface,
public: server.host.bindings[iface.addressInfo.internalPort].net.public,
addresses: getAddresses(iface, server.host, this.config),
})),
)
}

View File

@@ -12,7 +12,7 @@ import { RouterPortComponent } from './table.component'
<ng-container *ngIf="server$ | async as server">
<router-info [enabled]="!server.network.wanConfig.upnp" />
<table
*ngIf="server.ui | primaryIp as ip"
*ngIf="server.host.hostnameInfo[80] | primaryIp as ip"
tuiTextfieldAppearance="unstyled"
tuiTextfieldSize="m"
[tuiTextfieldLabelOutside]="true"

View File

@@ -5,6 +5,13 @@ export default [
path: '',
component: SettingsComponent,
children: [
{
path: 'acme',
loadComponent: () =>
import('./routes/acme/acme.component').then(
m => m.SettingsACMEComponent,
),
},
{
path: 'email',
loadComponent: () =>

View File

@@ -107,27 +107,6 @@ export class SettingsService {
icon: '@tui.monitor',
routerLink: 'ui',
},
{
title: 'Restart',
icon: '@tui.refresh-cw',
description: 'Restart Start OS server',
action: () => this.promptPower('Restart'),
},
{
title: 'Shutdown',
icon: '@tui.power',
description: 'Turn Start OS server off',
action: () => this.promptPower('Shutdown'),
},
{
title: 'Logout',
icon: '@tui.log-out',
description: 'Log off from Start OS',
action: () => {
this.api.logout({}).catch(e => console.error('Failed to log out', e))
this.auth.setUnverified()
},
},
],
'Privacy and Security': [
{
@@ -150,6 +129,29 @@ export class SettingsService {
routerLink: 'sessions',
},
],
Power: [
{
title: 'Restart',
icon: '@tui.refresh-cw',
description: 'Restart Start OS server',
action: () => this.promptPower('Restart'),
},
{
title: 'Shutdown',
icon: '@tui.power',
description: 'Turn Start OS server off',
action: () => this.promptPower('Shutdown'),
},
{
title: 'Logout',
icon: '@tui.log-out',
description: 'Log off from Start OS',
action: () => {
this.api.logout({}).catch(e => console.error('Failed to log out', e))
this.auth.setUnverified()
},
},
],
}
private async setOutboundProxy(): Promise<void> {

View File

@@ -17,7 +17,7 @@ const routes: Routes = [
},
{
path: 'login',
canActivate: [UnauthGuard],
canActivate: [UnauthGuard, stateNot(['error', 'initializing'])],
loadChildren: () =>
import('./routes/login/login.module').then(m => m.LoginPageModule),
},

View File

@@ -7,7 +7,6 @@ export const mockPatchData: DataModel = {
ui: {
name: `Matt's Server`,
theme: 'Dark',
desktop: ['lnd'],
marketplace: {
selectedUrl: 'https://registry.start9.com/',
knownHosts: {
@@ -32,6 +31,23 @@ export const mockPatchData: DataModel = {
id: 'abcdefgh',
version,
lastBackup: new Date(new Date().valueOf() - 604800001).toISOString(),
network: {
wifi: {
enabled: false,
interface: 'wlan0',
ssids: [],
selected: null,
lastRegion: null,
},
start9ToSubdomain: null,
domains: [],
wanConfig: {
upnp: true,
forwards: [],
},
proxies: [],
outboundProxy: null,
},
networkInterfaces: {
eth0: {
public: false,
@@ -72,11 +88,12 @@ export const mockPatchData: DataModel = {
packageVersionCompat: '>=0.3.0 <=0.3.6',
postInitMigrationTodos: [],
statusInfo: {
currentBackup: null,
// currentBackup: null,
updated: false,
updateProgress: null,
restarting: false,
shuttingDown: false,
backupProgress: {},
},
hostname: 'random-words',
host: {
@@ -194,9 +211,8 @@ export const mockPatchData: DataModel = {
platform: 'x86_64-nonfree',
zram: true,
governor: 'performance',
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
arch: 'x86_64',
ram: 8 * 1024 * 1024 * 1024,
devices: [],
},
packageData: {
bitcoind: {

View File

@@ -101,19 +101,7 @@ export class ConfigService {
}
/** ${scheme}://${username}@${host}:${externalPort}${suffix} */
launchableAddress(
interfaces: PackageDataEntry['serviceInterfaces'],
hosts: T.Hosts,
): string {
const ui = Object.values(interfaces).find(
i =>
i.type === 'ui' &&
(i.addressInfo.scheme === 'http' ||
i.addressInfo.sslScheme === 'https'),
)
if (!ui) return ''
launchableAddress(ui: T.ServiceInterface, hosts: T.Hosts): string {
const host = hosts[ui.addressInfo.hostId]
if (!host) return ''

View File

@@ -20,7 +20,7 @@ export class EOSService {
)
readonly backingUp$ = this.patch
.watch$('serverInfo', 'statusInfo', 'currentBackup')
.watch$('serverInfo', 'statusInfo', 'backupProgress')
.pipe(
map(obj => !!obj),
distinctUntilChanged(),

View File

@@ -19,7 +19,6 @@ import {
switchMap,
distinctUntilChanged,
ReplaySubject,
tap,
} from 'rxjs'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'

View File

@@ -3,7 +3,16 @@ import { T } from '@start9labs/start-sdk'
export type DataModel = {
ui: UIData
serverInfo: ServerInfo
serverInfo: Omit<
T.Public['serverInfo'],
'wifi' | 'unreadNotificationCount'
> & {
network: NetworkInfo
unreadNotifications: {
count: number
recent: ServerNotifications
}
}
packageData: Record<string, PackageDataEntry>
}
@@ -17,7 +26,6 @@ export type UIData = {
}
ackInstructions: Record<string, boolean>
theme: string
desktop: readonly string[]
}
export type UIMarketplaceData = {
@@ -33,30 +41,6 @@ export type UIStore = {
name?: string
}
export type ServerInfo = {
id: string
version: string
country: string
ui: T.HostnameInfo[]
network: NetworkInfo
lastBackup: string | null
unreadNotifications: {
count: number
recent: ServerNotifications
}
statusInfo: ServerStatusInfo
eosVersionCompat: string
pubkey: string
caFingerprint: string
ntpSynced: boolean
smtp: T.SmtpValue | null
passwordHash: string
platform: string
arch: string
governor: string | null
zram: boolean
}
export type NetworkInfo = {
wifi: WiFiInfo
start9ToSubdomain: Omit<Domain, 'provider'> | null

View File

@@ -1,9 +1,9 @@
import { PatchDB } from 'patch-db-client'
import { DataModel, ServerInfo } from 'src/app/services/patch-db/data-model'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { firstValueFrom } from 'rxjs'
export async function getServerInfo(
patch: PatchDB<DataModel>,
): Promise<ServerInfo> {
): Promise<DataModel['serverInfo']> {
return firstValueFrom(patch.watch$('serverInfo'))
}