feat: New one-click Home Server, now in Labs. Launch your own self-hosted server instance with just 1 click from the Preferences window. (#2341)

This commit is contained in:
Mo
2023-07-03 08:30:48 -05:00
committed by GitHub
parent d79e7b14b1
commit 96f42643a9
367 changed files with 5895 additions and 570 deletions

View File

@@ -30,6 +30,7 @@ import { DeinitMode } from './DeinitMode'
import { DeinitSource } from './DeinitSource'
import { UserClientInterface } from '../User/UserClientInterface'
import { SessionsClientInterface } from '../Session/SessionsClientInterface'
import { HomeServerServiceInterface } from '../HomeServer/HomeServerServiceInterface'
import { User } from '@standardnotes/responses'
export interface ApplicationInterface {
@@ -61,6 +62,9 @@ export interface ApplicationInterface {
getUser(): User | undefined
hasAccount(): boolean
setCustomHost(host: string): Promise<void>
isThirdPartyHostUsed(): boolean
isUsingHomeServer(): Promise<boolean>
importData(data: BackupFile, awaitSync?: boolean): Promise<ImportDataReturnType>
/**
@@ -94,6 +98,7 @@ export interface ApplicationInterface {
get subscriptions(): SubscriptionClientInterface
get fileBackups(): BackupServiceInterface | undefined
get sessions(): SessionsClientInterface
get homeServer(): HomeServerServiceInterface | undefined
get vaults(): VaultServiceInterface
get challenges(): ChallengeServiceInterface
get alerts(): AlertService

View File

@@ -11,7 +11,7 @@ import { InternalEventBusInterface } from '..'
import { AlertService } from '../Alert/AlertService'
import { ApiServiceInterface } from '../Api/ApiServiceInterface'
import { SyncServiceInterface } from '../Sync/SyncServiceInterface'
import { FileBackupsDevice } from '@standardnotes/files'
import { DirectoryManagerInterface, FileBackupsDevice } from '@standardnotes/files'
describe('backup service', () => {
let apiService: ApiServiceInterface
@@ -23,7 +23,7 @@ describe('backup service', () => {
let encryptor: EncryptionProviderInterface
let internalEventBus: InternalEventBusInterface
let backupService: FilesBackupService
let device: FileBackupsDevice
let device: FileBackupsDevice & DirectoryManagerInterface
let session: SessionsClientInterface
let storage: StorageServiceInterface
let payloads: PayloadManagerInterface
@@ -42,7 +42,7 @@ describe('backup service', () => {
status = {} as jest.Mocked<StatusServiceInterface>
device = {} as jest.Mocked<FileBackupsDevice>
device = {} as jest.Mocked<FileBackupsDevice & DirectoryManagerInterface>
device.getFileBackupReadToken = jest.fn()
device.readNextChunk = jest.fn()
device.joinPaths = jest.fn()
@@ -80,6 +80,7 @@ describe('backup service', () => {
session,
payloads,
history,
device,
internalEventBus,
)
backupService.getFilesBackupsLocation = jest.fn().mockReturnValue('/')

View File

@@ -22,6 +22,7 @@ import {
BackupServiceInterface,
DesktopWatchedDirectoriesChanges,
SuperConverterServiceInterface,
DirectoryManagerInterface,
} from '@standardnotes/files'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { ItemManagerInterface } from '../Item/ItemManagerInterface'
@@ -59,6 +60,7 @@ export class FilesBackupService extends AbstractService implements BackupService
private session: SessionsClientInterface,
private payloads: PayloadManagerInterface,
private history: HistoryServiceInterface,
private directory: DirectoryManagerInterface,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
@@ -161,15 +163,15 @@ export class FilesBackupService extends AbstractService implements BackupService
const textBackupsLocation = this.getTextBackupsLocation()
if (fileBackupsLocation) {
void this.device.openLocation(fileBackupsLocation)
void this.directory.openLocation(fileBackupsLocation)
}
if (plaintextBackupsLocation) {
void this.device.openLocation(plaintextBackupsLocation)
void this.directory.openLocation(plaintextBackupsLocation)
}
if (textBackupsLocation) {
void this.device.openLocation(textBackupsLocation)
void this.directory.openLocation(textBackupsLocation)
}
}
@@ -194,7 +196,7 @@ export class FilesBackupService extends AbstractService implements BackupService
async enableTextBackups(): Promise<void> {
let location = this.getTextBackupsLocation()
if (!location) {
location = await this.device.presentDirectoryPickerForLocationChangeAndTransferOld(
location = await this.directory.presentDirectoryPickerForLocationChangeAndTransferOld(
await this.prependWorkspacePathForPath(TextBackupsDirectoryName),
)
if (!location) {
@@ -217,13 +219,13 @@ export class FilesBackupService extends AbstractService implements BackupService
async openTextBackupsLocation(): Promise<void> {
const location = this.getTextBackupsLocation()
if (location) {
void this.device.openLocation(location)
void this.directory.openLocation(location)
}
}
async changeTextBackupsLocation(): Promise<string | undefined> {
const oldLocation = this.getTextBackupsLocation()
const newLocation = await this.device.presentDirectoryPickerForLocationChangeAndTransferOld(
const newLocation = await this.directory.presentDirectoryPickerForLocationChangeAndTransferOld(
await this.prependWorkspacePathForPath(TextBackupsDirectoryName),
oldLocation,
)
@@ -253,7 +255,7 @@ export class FilesBackupService extends AbstractService implements BackupService
public async enablePlaintextBackups(): Promise<void> {
let location = this.getPlaintextBackupsLocation()
if (!location) {
location = await this.device.presentDirectoryPickerForLocationChangeAndTransferOld(
location = await this.directory.presentDirectoryPickerForLocationChangeAndTransferOld(
await this.prependWorkspacePathForPath(PlaintextBackupsDirectoryName),
)
if (!location) {
@@ -279,13 +281,13 @@ export class FilesBackupService extends AbstractService implements BackupService
async openPlaintextBackupsLocation(): Promise<void> {
const location = this.getPlaintextBackupsLocation()
if (location) {
void this.device.openLocation(location)
void this.directory.openLocation(location)
}
}
async changePlaintextBackupsLocation(): Promise<string | undefined> {
const oldLocation = this.getPlaintextBackupsLocation()
const newLocation = await this.device.presentDirectoryPickerForLocationChangeAndTransferOld(
const newLocation = await this.directory.presentDirectoryPickerForLocationChangeAndTransferOld(
await this.prependWorkspacePathForPath(PlaintextBackupsDirectoryName),
oldLocation,
)
@@ -302,7 +304,7 @@ export class FilesBackupService extends AbstractService implements BackupService
public async enableFilesBackups(): Promise<void> {
let location = this.getFilesBackupsLocation()
if (!location) {
location = await this.device.presentDirectoryPickerForLocationChangeAndTransferOld(
location = await this.directory.presentDirectoryPickerForLocationChangeAndTransferOld(
await this.prependWorkspacePathForPath(FileBackupsDirectoryName),
)
if (!location) {
@@ -328,7 +330,7 @@ export class FilesBackupService extends AbstractService implements BackupService
public async changeFilesBackupsLocation(): Promise<string | undefined> {
const oldLocation = this.getFilesBackupsLocation()
const newLocation = await this.device.presentDirectoryPickerForLocationChangeAndTransferOld(
const newLocation = await this.directory.presentDirectoryPickerForLocationChangeAndTransferOld(
await this.prependWorkspacePathForPath(FileBackupsDirectoryName),
oldLocation,
)
@@ -344,7 +346,7 @@ export class FilesBackupService extends AbstractService implements BackupService
public async openFilesBackupsLocation(): Promise<void> {
const location = this.getFilesBackupsLocation()
if (location) {
void this.device.openLocation(location)
void this.directory.openLocation(location)
}
}
@@ -389,7 +391,7 @@ export class FilesBackupService extends AbstractService implements BackupService
public async openFileBackup(record: FileBackupRecord): Promise<void> {
const location = await this.getFileBackupAbsolutePath(record)
await this.device.openLocation(location)
await this.directory.openLocation(location)
}
private async handleChangedFiles(files: FileItem[]): Promise<void> {

View File

@@ -4,6 +4,7 @@ import { ChallengeInterface } from './ChallengeInterface'
import { ChallengePrompt } from './Prompt/ChallengePrompt'
import { ChallengeReason } from './Types/ChallengeReason'
import { ChallengeValidation } from './Types/ChallengeValidation'
import { ChallengeValue } from './Types/ChallengeValue'
/**
* A challenge is a stateless description of what the client needs to provide
@@ -11,6 +12,7 @@ import { ChallengeValidation } from './Types/ChallengeValidation'
*/
export class Challenge implements ChallengeInterface {
public readonly id = Math.random()
customHandler?: (challenge: ChallengeInterface, values: ChallengeValue[]) => Promise<void>
constructor(
public readonly prompts: ChallengePrompt[],
@@ -18,9 +20,7 @@ export class Challenge implements ChallengeInterface {
public readonly cancelable: boolean,
public readonly _heading?: string,
public readonly _subheading?: string,
) {
Object.freeze(this)
}
) {}
/** Outside of the modal, this is the title of the modal itself */
get modalTitle(): string {

View File

@@ -1,6 +1,7 @@
import { ChallengePromptInterface } from './Prompt/ChallengePromptInterface'
import { ChallengeReason } from './Types/ChallengeReason'
import { ChallengeValidation } from './Types/ChallengeValidation'
import { ChallengeValue } from './Types/ChallengeValue'
export interface ChallengeInterface {
readonly id: number
@@ -8,6 +9,8 @@ export interface ChallengeInterface {
readonly reason: ChallengeReason
readonly cancelable: boolean
customHandler?: (challenge: ChallengeInterface, values: ChallengeValue[]) => Promise<void>
/** Outside of the modal, this is the title of the modal itself */
get modalTitle(): string

View File

@@ -1,5 +1,7 @@
import { Environment } from '@standardnotes/models'
import { HomeServerManagerInterface } from '../HomeServer/HomeServerManagerInterface'
import { WebClientRequiresDesktopMethods } from './DesktopWebCommunication'
import { DeviceInterface } from './DeviceInterface'
import { WebOrDesktopDeviceInterface } from './WebOrDesktopDeviceInterface'
@@ -10,6 +12,9 @@ export function isDesktopDevice(x: DeviceInterface): x is DesktopDeviceInterface
return x.environment === Environment.Desktop
}
export interface DesktopDeviceInterface extends WebOrDesktopDeviceInterface, WebClientRequiresDesktopMethods {
export interface DesktopDeviceInterface
extends WebOrDesktopDeviceInterface,
WebClientRequiresDesktopMethods,
HomeServerManagerInterface {
environment: Environment.Desktop
}

View File

@@ -1,7 +1,7 @@
import { DecryptedTransferPayload } from '@standardnotes/models'
import { DesktopWatchedDirectoriesChanges, FileBackupsDevice } from '@standardnotes/files'
import { DesktopWatchedDirectoriesChanges, DirectoryManagerInterface, FileBackupsDevice } from '@standardnotes/files'
export interface WebClientRequiresDesktopMethods extends FileBackupsDevice {
export interface WebClientRequiresDesktopMethods extends FileBackupsDevice, DirectoryManagerInterface {
syncComponents(payloads: unknown[]): void
onSearch(text?: string): void
@@ -21,4 +21,6 @@ export interface DesktopClientRequiresWebMethods {
onComponentInstallationComplete(componentData: DecryptedTransferPayload, error: unknown): Promise<void>
handleWatchedDirectoriesChanges(changes: DesktopWatchedDirectoriesChanges): Promise<void>
handleHomeServerStarted(serverUrl: string): Promise<void>
}

View File

@@ -1,3 +1,4 @@
import { ApplicationInterface } from './../Application/ApplicationInterface'
import { ApplicationIdentifier } from '@standardnotes/common'
import {
FullyFormedTransferPayload,
@@ -31,6 +32,11 @@ export interface DeviceInterface {
removeAllRawStorageValues(): Promise<void>
removeRawStorageValuesForIdentifier(identifier: ApplicationIdentifier): Promise<void>
setApplication(application: ApplicationInterface): void
removeApplication(application: ApplicationInterface): void
/**
* On web platforms, databased created may be new.
* New databases can be because of new sessions, or if the browser deleted it.

View File

@@ -0,0 +1,17 @@
export interface HomeServerEnvironmentConfiguration {
jwtSecret: string
authJwtSecret: string
encryptionServerKey: string
pseudoKeyParamsKey: string
valetTokenSecret: string
port: number
logLevel?: string
databaseEngine: 'sqlite' | 'mysql'
mysqlConfiguration?: {
host: string
port: number
username: string
password: string
database: string
}
}

View File

@@ -0,0 +1,12 @@
export interface HomeServerManagerInterface {
startHomeServer(): Promise<string | undefined>
setHomeServerConfiguration(configurationJSONString: string): Promise<void>
getHomeServerConfiguration(): Promise<string | undefined>
setHomeServerDataLocation(location: string): Promise<void>
stopHomeServer(): Promise<string | undefined>
activatePremiumFeatures(username: string): Promise<string | undefined>
getHomeServerLogs(): Promise<string[]>
isHomeServerRunning(): Promise<boolean>
getHomeServerUrl(): Promise<string | undefined>
getHomeServerLastErrorMessage(): Promise<string | undefined>
}

View File

@@ -0,0 +1,171 @@
import { Result } from '@standardnotes/domain-core'
import { ApplicationStage } from '../Application/ApplicationStage'
import { DesktopDeviceInterface } from '../Device/DesktopDeviceInterface'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { AbstractService } from '../Service/AbstractService'
import { RawStorageKey } from '../Storage/StorageKeys'
import { HomeServerServiceInterface } from './HomeServerServiceInterface'
import { HomeServerEnvironmentConfiguration } from './HomeServerEnvironmentConfiguration'
import { HomeServerStatus } from './HomeServerStatus'
export class HomeServerService extends AbstractService implements HomeServerServiceInterface {
private readonly HOME_SERVER_DATA_DIRECTORY_NAME = '.homeserver'
constructor(
private desktopDevice: DesktopDeviceInterface,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
}
override deinit() {
;(this.desktopDevice as unknown) = undefined
super.deinit()
}
override async handleApplicationStage(stage: ApplicationStage) {
await super.handleApplicationStage(stage)
switch (stage) {
case ApplicationStage.StorageDecrypted_09: {
await this.setHomeServerDataLocationOnDevice()
break
}
case ApplicationStage.Launched_10: {
await this.startHomeServerIfItIsEnabled()
break
}
}
}
async getHomeServerStatus(): Promise<HomeServerStatus> {
const isHomeServerRunning = await this.desktopDevice.isHomeServerRunning()
if (!isHomeServerRunning) {
return { status: 'off', errorMessage: await this.desktopDevice.getHomeServerLastErrorMessage() }
}
return {
status: 'on',
url: await this.getHomeServerUrl(),
}
}
async getHomeServerLogs(): Promise<string[]> {
return this.desktopDevice.getHomeServerLogs()
}
async getHomeServerUrl(): Promise<string | undefined> {
return this.desktopDevice.getHomeServerUrl()
}
async startHomeServer(): Promise<string | undefined> {
return this.desktopDevice.startHomeServer()
}
async stopHomeServer(): Promise<string | undefined> {
return this.desktopDevice.stopHomeServer()
}
async isHomeServerRunning(): Promise<boolean> {
return this.desktopDevice.isHomeServerRunning()
}
async activatePremiumFeatures(username: string): Promise<Result<string>> {
const result = await this.desktopDevice.activatePremiumFeatures(username)
if (result !== undefined) {
return Result.fail(result)
}
return Result.ok('Premium features activated')
}
async setHomeServerConfiguration(config: HomeServerEnvironmentConfiguration): Promise<void> {
await this.desktopDevice.setHomeServerConfiguration(JSON.stringify(config))
}
async getHomeServerConfiguration(): Promise<HomeServerEnvironmentConfiguration | undefined> {
const configurationJSONString = await this.desktopDevice.getHomeServerConfiguration()
if (!configurationJSONString) {
return undefined
}
return JSON.parse(configurationJSONString) as HomeServerEnvironmentConfiguration
}
async enableHomeServer(): Promise<void> {
await this.desktopDevice.setRawStorageValue(RawStorageKey.HomeServerEnabled, 'true')
await this.startHomeServer()
}
async isHomeServerEnabled(): Promise<boolean> {
const value = await this.desktopDevice.getRawStorageValue(RawStorageKey.HomeServerEnabled)
return value === 'true'
}
async getHomeServerDataLocation(): Promise<string | undefined> {
return this.desktopDevice.getRawStorageValue(RawStorageKey.HomeServerDataLocation)
}
async disableHomeServer(): Promise<Result<string>> {
await this.desktopDevice.setRawStorageValue(RawStorageKey.HomeServerEnabled, 'false')
const result = await this.stopHomeServer()
if (result !== undefined) {
return Result.fail(result)
}
return Result.ok('Home server disabled')
}
async changeHomeServerDataLocation(): Promise<Result<string>> {
const oldLocation = await this.getHomeServerDataLocation()
const newLocation = await this.desktopDevice.presentDirectoryPickerForLocationChangeAndTransferOld(
this.HOME_SERVER_DATA_DIRECTORY_NAME,
oldLocation,
)
if (!newLocation) {
const lastErrorMessage = await this.desktopDevice.getDirectoryManagerLastErrorMessage()
return Result.fail(lastErrorMessage ?? 'No location selected')
}
await this.desktopDevice.setRawStorageValue(RawStorageKey.HomeServerDataLocation, newLocation)
await this.desktopDevice.setHomeServerDataLocation(newLocation)
return Result.ok(newLocation)
}
async openHomeServerDataLocation(): Promise<void> {
const location = await this.getHomeServerDataLocation()
if (location) {
void this.desktopDevice.openLocation(location)
}
}
private async startHomeServerIfItIsEnabled(): Promise<void> {
const homeServerIsEnabled = await this.isHomeServerEnabled()
if (homeServerIsEnabled) {
await this.startHomeServer()
}
}
private async setHomeServerDataLocationOnDevice(): Promise<void> {
let location = await this.getHomeServerDataLocation()
if (!location) {
const documentsDirectory = await this.desktopDevice.getUserDocumentsDirectory()
location = `${documentsDirectory}/${this.HOME_SERVER_DATA_DIRECTORY_NAME}`
}
await this.desktopDevice.setRawStorageValue(RawStorageKey.HomeServerDataLocation, location)
await this.desktopDevice.setHomeServerDataLocation(location)
}
}

View File

@@ -0,0 +1,22 @@
import { Result } from '@standardnotes/domain-core'
import { HomeServerEnvironmentConfiguration } from './HomeServerEnvironmentConfiguration'
import { HomeServerStatus } from './HomeServerStatus'
export interface HomeServerServiceInterface {
activatePremiumFeatures(username: string): Promise<Result<string>>
isHomeServerRunning(): Promise<boolean>
isHomeServerEnabled(): Promise<boolean>
getHomeServerDataLocation(): Promise<string | undefined>
enableHomeServer(): Promise<void>
disableHomeServer(): Promise<Result<string>>
startHomeServer(): Promise<string | undefined>
stopHomeServer(): Promise<string | undefined>
changeHomeServerDataLocation(): Promise<Result<string>>
openHomeServerDataLocation(): Promise<void>
getHomeServerConfiguration(): Promise<HomeServerEnvironmentConfiguration | undefined>
setHomeServerConfiguration(config: HomeServerEnvironmentConfiguration): Promise<void>
getHomeServerUrl(): Promise<string | undefined>
getHomeServerStatus(): Promise<HomeServerStatus>
getHomeServerLogs(): Promise<string[]>
}

View File

@@ -0,0 +1,5 @@
export type HomeServerStatus = {
status: 'on' | 'off'
url?: string
errorMessage?: string
}

View File

@@ -2,14 +2,14 @@
import { log, removeFromArray } from '@standardnotes/utils'
import { EventObserver } from '../Event/EventObserver'
import { ServiceInterface } from './ServiceInterface'
import { ApplicationServiceInterface } from './ApplicationServiceInterface'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { ApplicationStage } from '../Application/ApplicationStage'
import { InternalEventPublishStrategy } from '../Internal/InternalEventPublishStrategy'
import { DiagnosticInfo } from '../Diagnostics/ServiceDiagnostics'
export abstract class AbstractService<EventName = string, EventData = unknown>
implements ServiceInterface<EventName, EventData>
implements ApplicationServiceInterface<EventName, EventData>
{
private eventObservers: EventObserver<EventName, EventData>[] = []
public loggingEnabled = false

View File

@@ -2,7 +2,7 @@ import { ApplicationStage } from '../Application/ApplicationStage'
import { ServiceDiagnostics } from '../Diagnostics/ServiceDiagnostics'
import { EventObserver } from '../Event/EventObserver'
export interface ServiceInterface<E, D> extends ServiceDiagnostics {
export interface ApplicationServiceInterface<E, D> extends ServiceDiagnostics {
loggingEnabled: boolean
addEventObserver(observer: EventObserver<E, D>): () => void
blockDeinit(): Promise<void>

View File

@@ -12,6 +12,7 @@ export interface SessionsClientInterface {
populateSessionFromDemoShareToken(token: Base64String): Promise<void>
getUser(): User | undefined
isSignedIn(): boolean
get userUuid(): string
getSureUser(): User
@@ -24,7 +25,6 @@ export interface SessionsClientInterface {
ephemeral: boolean,
minAllowedVersion?: ProtocolVersion,
): Promise<SessionManagerResponse>
isSignedIn(): boolean
bypassChecksAndSignInWithRootKey(
email: string,
rootKey: RootKeyInterface,

View File

@@ -6,6 +6,8 @@ export enum RawStorageKey {
StorageObject = 'storage',
DescriptorRecord = 'descriptors',
SnjsVersion = 'snjs_version',
HomeServerEnabled = 'home_server_enabled',
HomeServerDataLocation = 'home_serve_data_location',
}
/**

View File

@@ -51,7 +51,9 @@ export * from './Device/DeviceInterface'
export * from './Device/MobileDeviceInterface'
export * from './Device/TypeCheck'
export * from './Device/WebOrDesktopDeviceInterface'
export * from './Device/DatabaseLoadOptions'
export * from './Device/DatabaseItemMetadata'
export * from './Device/DatabaseLoadSorter'
export * from './Diagnostics/ServiceDiagnostics'
export * from './Encryption/DecryptBackupFileUseCase'
@@ -77,7 +79,11 @@ export * from './Feature/SetOfflineFeaturesFunctionResponse'
export * from './Files/FileService'
export * from './History/HistoryServiceInterface'
export * from './HomeServer/HomeServerEnvironmentConfiguration'
export * from './HomeServer/HomeServerManagerInterface'
export * from './HomeServer/HomeServerService'
export * from './HomeServer/HomeServerServiceInterface'
export * from './HomeServer/HomeServerStatus'
export * from './Integrity/IntegrityApiInterface'
export * from './Integrity/IntegrityEvent'
export * from './Integrity/IntegrityEventPayload'
@@ -114,8 +120,7 @@ export * from './Revision/RevisionClientInterface'
export * from './Revision/RevisionManager'
export * from './Service/AbstractService'
export * from './Service/ServiceInterface'
export * from './Service/ApplicationServiceInterface'
export * from './Session/SessionManagerResponse'
export * from './Session/SessionsClientInterface'
export * from './Session/SessionEvent'