first chrome implementation

This commit is contained in:
DEV Sam Hayes
2025-01-10 19:37:10 +01:00
parent dc7a980dc5
commit a652718bc7
175 changed files with 18526 additions and 610 deletions

View File

@@ -0,0 +1,5 @@
export class NavComponent {
navigateBack() {
window.history.back();
}
}

View File

@@ -0,0 +1,28 @@
<!-- eslint-disable @angular-eslint/template/elements-content -->
<div [id]="idString" class="modal fade" data-bs-backdrop="static" tabindex="-1">
<div
class="modal-dialog modal-sm modal-dialog-centered modal-dialog-scrollable"
>
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Please confirm</h5>
<button
type="button"
class="btn-close"
(click)="modal?.hide(); no.emit()"
></button>
</div>
<div class="modal-body">
<span>{{ message }}</span>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="modal?.hide()">
No
</button>
<button type="button" class="btn btn-primary" (click)="onClickYes()">
Yes
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,5 @@
:host {
.modal {
--bs-modal-margin: 32px;
}
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ConfirmComponent } from './confirm.component';
describe('ConfirmComponent', () => {
let component: ConfirmComponent;
let fixture: ComponentFixture<ConfirmComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ConfirmComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ConfirmComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,42 @@
import { AfterViewInit, Component, EventEmitter, Output } from '@angular/core';
import * as bootstrap from 'bootstrap';
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'lib-confirm',
imports: [],
templateUrl: './confirm.component.html',
styleUrl: './confirm.component.scss',
})
export class ConfirmComponent implements AfterViewInit {
@Output() yes = new EventEmitter<void>();
@Output() no = new EventEmitter<void>();
message: string | undefined;
onYes: ((() => Promise<void>) | (() => void)) | undefined;
modal: bootstrap.Modal | undefined;
readonly idString = crypto.randomUUID();
ngAfterViewInit(): void {
const myModalEl = document.getElementById(this.idString);
if (!myModalEl) {
return;
}
this.modal = new bootstrap.Modal(myModalEl);
}
onClickYes() {
this.modal?.hide();
if (typeof this.onYes !== 'undefined') {
this.onYes();
}
}
show(message: string, onYes: (() => Promise<void>) | (() => void)): void {
this.message = message;
this.onYes = onYes;
this.modal?.show();
}
}

View File

@@ -0,0 +1,3 @@
<div class="icon-button">
<i [class]="'bi bi-' + icon"></i>
</div>

View File

@@ -0,0 +1,18 @@
:host {
.icon-button {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
//padding: 10px;
border-radius: 100%;
cursor: pointer;
&:hover {
background: gray;
}
}
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { IconButtonComponent } from './icon-button.component';
describe('IconButtonComponent', () => {
let component: IconButtonComponent;
let fixture: ComponentFixture<IconButtonComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [IconButtonComponent]
})
.compileComponents();
fixture = TestBed.createComponent(IconButtonComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,12 @@
import { Component, Input } from '@angular/core';
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'lib-icon-button',
imports: [],
templateUrl: './icon-button.component.html',
styleUrl: './icon-button.component.scss',
})
export class IconButtonComponent {
@Input({ required: true }) icon!: string;
}

View File

@@ -0,0 +1,12 @@
<span class="sam-no-select text">
{{ text }}
</span>
<div class="sam-flex-grow"></div>
<div class="buttons sam-flex-row gap-h">
<lib-icon-button
icon="arrow-right"
style="pointer-events: none"
></lib-icon-button>
</div>

View File

@@ -0,0 +1,23 @@
:host {
cursor: pointer;
height: 48px;
min-height: 48px;
display: flex;
flex-direction: row;
align-items: center;
padding-left: 16px;
padding-right: 8px;
background: var(--background-light);
border-radius: 8px;
margin-bottom: 8px;
&:hover {
background: var(--background-light-hover);
}
.text {
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NavItemComponent } from './nav-item.component';
describe('NavItemComponent', () => {
let component: NavItemComponent;
let fixture: ComponentFixture<NavItemComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NavItemComponent]
})
.compileComponents();
fixture = TestBed.createComponent(NavItemComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,13 @@
import { Component, Input } from '@angular/core';
import { IconButtonComponent } from "../icon-button/icon-button.component";
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'lib-nav-item',
imports: [IconButtonComponent],
templateUrl: './nav-item.component.html',
styleUrl: './nav-item.component.scss',
})
export class NavItemComponent {
@Input({ required: true }) text!: string;
}

View File

@@ -0,0 +1,3 @@
<span [style.color]="color">{{ npubString }}</span>
<lib-icon-button icon="copy" (click)="copyToClipboard()"></lib-icon-button>

View File

@@ -0,0 +1,6 @@
:host {
display: flex;
flex-direction: row;
align-items: center;
column-gap: calc(var(--size) / 2);
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PubkeyComponent } from './pubkey.component';
describe('PubkeyComponent', () => {
let component: PubkeyComponent;
let fixture: ComponentFixture<PubkeyComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PubkeyComponent]
})
.compileComponents();
fixture = TestBed.createComponent(PubkeyComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,38 @@
import { Component, Input, OnInit } from '@angular/core';
import { NostrHelper } from '@common';
import { IconButtonComponent } from "../icon-button/icon-button.component";
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'lib-pubkey',
imports: [IconButtonComponent],
templateUrl: './pubkey.component.html',
styleUrl: './pubkey.component.scss',
})
export class PubkeyComponent implements OnInit {
@Input({ required: true }) value!: string;
@Input() first = 9;
@Input() last = 5;
@Input() color = '#dee2e6bf';
npub: string | undefined;
npubString: string | undefined;
ngOnInit(): void {
const pubkeyObject = NostrHelper.getNostrPubkeyObject(this.value);
this.npub = pubkeyObject.npub;
this.npubString = NostrHelper.splitKey(
pubkeyObject.npub,
this.first,
this.last
);
}
copyToClipboard() {
if (!this.npub) {
return;
}
navigator.clipboard.writeText(this.npub);
}
}

View File

@@ -0,0 +1 @@
<span>{{ type }}</span>

View File

@@ -0,0 +1,31 @@
:host {
border-radius: 4px;
padding: 0px 4px;
font-size: 12px;
text-align: center;
border: 1px solid transparent;
min-width: 40px;
min-height: 20px;
cursor: pointer;
&.read {
&:not(.is-selected) {
border: 1px solid var(--bs-green);
}
&.is-selected {
background: var(--bs-green);
}
}
&:not(.read) {
&:not(.is-selected) {
border: 1px solid var(--bs-primary);
}
&.is-selected {
background: var(--bs-primary);
}
}
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RelayRwComponent } from './relay-rw.component';
describe('RelayRwComponent', () => {
let component: RelayRwComponent;
let fixture: ComponentFixture<RelayRwComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RelayRwComponent]
})
.compileComponents();
fixture = TestBed.createComponent(RelayRwComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,34 @@
import {
Component,
EventEmitter,
HostBinding,
HostListener,
Input,
Output,
} from '@angular/core';
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'lib-relay-rw',
imports: [],
templateUrl: './relay-rw.component.html',
styleUrl: './relay-rw.component.scss',
})
export class RelayRwComponent {
@Input({ required: true }) type!: 'read' | 'write';
@Input({ required: true }) model!: boolean;
@Output() modelChange = new EventEmitter<boolean>();
@HostBinding('class.read') get isRead() {
return this.type === 'read';
}
@HostBinding('class.is-selected') get isSelected() {
return this.model;
}
@HostListener('click') onClick() {
this.model = !this.model;
this.modelChange.emit(this.model);
}
}

View File

@@ -0,0 +1,8 @@
<div
[id]="idString"
class="toast hide"
style="width: 100%"
role="alert"
>
<div class="toast-body">{{ message }}</div>
</div>

View File

@@ -0,0 +1,5 @@
:host {
position: absolute;
align-self: center;
//bottom: 76px;
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ToastComponent } from './toast.component';
describe('ToastComponent', () => {
let component: ToastComponent;
let fixture: ComponentFixture<ToastComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ToastComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ToastComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,37 @@
import { AfterViewInit, Component, HostBinding, Input } from '@angular/core';
import * as bootstrap from 'bootstrap';
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'lib-toast',
imports: [],
templateUrl: './toast.component.html',
styleUrl: './toast.component.scss',
})
export class ToastComponent implements AfterViewInit {
@Input() message: string | undefined;
@Input()
@HostBinding('style.bottom.px')
bottom = 76;
readonly idString = crypto.randomUUID();
toast: bootstrap.Toast | undefined;
ngAfterViewInit(): void {
const myToastEl = document.getElementById(this.idString);
if (!myToastEl) {
return;
}
this.toast = new bootstrap.Toast(myToastEl, { delay: 2000 });
}
show(message?: string) {
if (message) {
this.message = message;
}
this.toast?.show();
}
}

View File

@@ -0,0 +1,93 @@
import { Buffer } from 'buffer';
export class CryptoHelper {
/**
* Generate a base64 encoded IV.
*/
static generateIV(): string {
const iv = crypto.getRandomValues(new Uint8Array(12));
return Buffer.from(iv).toString('base64');
}
/**
* Hash (SHA-256) a text string.
*/
static async hash(text: string): Promise<string> {
const textUint8 = new TextEncoder().encode(text); // encode as (utf-8) Uint8Array
const hashBuffer = await crypto.subtle.digest('SHA-256', textUint8); // hash the message
const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, '0'))
.join(''); // convert bytes to hex string
return hashHex;
}
static v4(): string {
return crypto.randomUUID();
}
static async deriveKey(password: string): Promise<CryptoKey> {
const algo = {
name: 'PBKDF2',
hash: 'SHA-256',
salt: new TextEncoder().encode('3e7cdebd-3b4c-4125-a18c-05750cad8ec3'),
iterations: 1000,
};
return crypto.subtle.deriveKey(
algo,
await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
{
name: algo.name,
},
false,
['deriveKey']
),
{
name: 'AES-GCM',
length: 256,
},
false,
['encrypt', 'decrypt']
);
}
static async encrypt(
text: string,
ivBase64String: string,
password: string
): Promise<string> {
const algo = {
name: 'AES-GCM',
length: 256,
iv: Buffer.from(ivBase64String, 'base64'),
};
const cipherText = await crypto.subtle.encrypt(
algo,
await CryptoHelper.deriveKey(password),
new TextEncoder().encode(text)
);
return Buffer.from(cipherText).toString('base64');
}
static async decrypt(
encryptedBase64String: string,
ivBase64String: string,
password: string
): Promise<string> {
const algo = {
name: 'AES-GCM',
length: 256,
iv: Buffer.from(ivBase64String, 'base64'),
};
return new TextDecoder().decode(
await crypto.subtle.decrypt(
algo,
await CryptoHelper.deriveKey(password),
Buffer.from(encryptedBase64String, 'base64')
)
);
}
}

View File

@@ -0,0 +1,10 @@
export class DateHelper {
static dateToISOLikeButLocal(date: Date): string {
const offsetMs = date.getTimezoneOffset() * 60 * 1000;
const msLocal = date.getTime() - offsetMs;
const dateLocal = new Date(msLocal);
const iso = dateLocal.toISOString();
const isoLocal = iso.slice(0, 19).replace('T', ' ').replaceAll(':', '.');
return isoLocal;
}
}

View File

@@ -0,0 +1,128 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { bech32 } from '@scure/base';
import * as utils from '@noble/curves/abstract/utils';
import { getPublicKey } from 'nostr-tools';
export interface NostrHexObject {
represents: string;
hex: string;
}
export interface NostrPubkeyObject {
hex: string;
npub: string;
}
export interface NostrPrivkeyObject {
hex: string;
nsec: string;
}
export class NostrHelper {
static getNostrPrivkeyObject(nsec_OR_hex: string): NostrPrivkeyObject {
// 1. Assume we got an nsec.
// Try to generate hex value.
try {
const hexObject = this.#nSomething2hexObject(nsec_OR_hex);
if (hexObject.represents !== 'nsec') {
throw new Error('The provided string is NOT an nsec.');
}
// Everything is fine. The provided string IS an nsec.
return {
hex: hexObject.hex,
nsec: nsec_OR_hex,
};
} catch (error) {
// Continue.
}
// 2. Assume we got an hex.
// Try to generate the nsec.
try {
const nsec = NostrHelper.privkey2nsec(nsec_OR_hex);
return {
hex: nsec_OR_hex,
nsec,
};
} catch (error) {
// Continue;
}
throw new Error('Could not convert the provided string into nsec/hex.');
}
static getNostrPubkeyObject(npub_OR_hex: string): NostrPubkeyObject {
// 1. Assume we got an npub.
// Try to generate hex value.
try {
const hexObject = this.#nSomething2hexObject(npub_OR_hex);
if (hexObject.represents !== 'npub') {
throw new Error('The provided string is NOT an npub.');
}
// Everything is fine. The provided string IS an npub.
return {
hex: hexObject.hex,
npub: npub_OR_hex,
};
} catch (error) {
// Continue.
}
// 2. Assume we got an hex.
// Try to generate the npub.
try {
const npub = NostrHelper.pubkey2npub(npub_OR_hex);
return {
hex: npub_OR_hex,
npub,
};
} catch (error) {
// Continue;
}
throw new Error('Could not convert the provided string into npub/hex.');
}
static pubkey2npub(hex: string): string {
const data = utils.hexToBytes(hex);
const words = bech32.toWords(data);
return bech32.encode('npub', words, 5000);
}
static privkey2nsec(hex: string): string {
const data = utils.hexToBytes(hex);
const words = bech32.toWords(data);
return bech32.encode('nsec', words, 5000);
}
static pubkeyFromPrivkey(hex: string): string {
const privkeyBytes = utils.hexToBytes(hex);
return getPublicKey(privkeyBytes);
}
static hex2bytes(hex: string): Uint8Array {
return utils.hexToBytes(hex);
}
static splitKey(text: string, first: number, last: number): string {
const part1 = text.slice(0, first);
const part2 = '...';
const part3 = text.slice(-last);
return `${part1}${part2}${part3}`;
}
static #nSomething2hexObject(nSomething: string): NostrHexObject {
const { prefix, words } = bech32.decode(
nSomething as `${string}1${string}`,
5000
);
const data = new Uint8Array(bech32.fromWords(words));
return {
represents: prefix,
hex: utils.bytesToHex(data),
};
}
}

View File

@@ -0,0 +1,8 @@
export class TextHelper {
/**
* Takes a string returns something like "\<first-x-chars>...\<last-y-chars>""
*/
static split(text: string, first: number, last: number): string {
return `${text.slice(0, first)}...${text.slice(-last)}`;
}
}

View File

@@ -0,0 +1,8 @@
export type Nip07Method =
| 'signEvent'
| 'getPublicKey'
| 'getRelays'
| 'nip04.encrypt'
| 'nip04.decrypt';
export type Nip07MethodPolicy = 'allow' | 'deny';

View File

@@ -0,0 +1,18 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'visualNip05',
})
export class VisualNip05Pipe implements PipeTransform {
transform(value: string | undefined): string {
if (typeof value === 'undefined') {
return '';
}
if (value.startsWith('_@')) {
return value.split('_@')[1];
}
return value;
}
}

View File

@@ -0,0 +1,10 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'visualRelay',
})
export class VisualRelayPipe implements PipeTransform {
transform(value: string): string {
return value.toLowerCase().replaceAll('wss://', '');
}
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { LoggerService } from './logger.service';
describe('LoggerService', () => {
let service: LoggerService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(LoggerService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,29 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class LoggerService {
#namespace: string | undefined;
initialize(namespace: string): void {
this.#namespace = namespace;
}
log(value: any) {
this.#assureInitialized();
const nowString = new Date().toLocaleString();
console.log(`[${this.#namespace} - ${nowString}]`, JSON.stringify(value));
}
#assureInitialized() {
if (!this.#namespace) {
throw new Error(
'LoggerService not initialized. Please call initialize(..) first.'
);
}
}
}

View File

@@ -0,0 +1,3 @@
export abstract class BrowserLocalHandler {
}

View File

@@ -0,0 +1,31 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { BrowserSessionData } from './types';
export abstract class BrowserSessionHandler {
get browserSessionData(): BrowserSessionData | undefined {
return this.#browserSessionData;
}
#browserSessionData?: BrowserSessionData;
/**
* Load the data from the browser session storage. It should be an empty object,
* if no data is available yet (e.g. because the vault (from the browser sync data)
* was not unlocked via password).
*
* ATTENTION: Make sure to call "setFullData(..)" afterwards to update the in-memory data.
*/
abstract loadFullData(): Promise<Partial<Record<string, any>>>;
setFullData(data: BrowserSessionData) {
this.#browserSessionData = JSON.parse(JSON.stringify(data));
}
/**
* Persist the full data to the session data storage.
*
* ATTENTION: Make sure to call "setFullData(..)" afterwards of before to update the in-memory data.
*/
abstract saveFullData(data: BrowserSessionData): Promise<void>;
abstract clearData(): Promise<void>;
}

View File

@@ -0,0 +1,111 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSyncData,
Identity_ENCRYPTED,
Permission_ENCRYPTED,
Relay_ENCRYPTED,
} from './types';
/**
* This class handles the data that is synced between browser instances.
* In addition to the sensitive data that is encrypted, it also contains
* some unencrypted properties (like, version and the vault hash).
*/
export abstract class BrowserSyncHandler {
get browserSyncData(): BrowserSyncData | undefined {
return this.#browserSyncData;
}
get ignoreProperties(): string[] {
return this.#ignoreProperties;
}
#browserSyncData?: BrowserSyncData;
#ignoreProperties: string[] = [];
setIgnoreProperties(properties: string[]) {
this.#ignoreProperties = properties;
}
/**
* Load data from the sync data storage. This data might be
* outdated (i.e. it is unmigrated), so check the unencrypted property "version" after loading.
* Also make sure to handle the "ignore properties" (if available).
*/
abstract loadUnmigratedData(): Promise<Partial<Record<string, any>>>;
/**
* Persist the full data to the sync data storage.
*
* ATTENTION: In your implementation, make sure to call "setFullData(..)" at the end to update the in-memory data.
*/
abstract saveAndSetFullData(data: BrowserSyncData): Promise<void>;
setFullData(data: BrowserSyncData) {
this.#browserSyncData = JSON.parse(JSON.stringify(data));
}
/**
* Persist the permissions to the sync data storage.
*
* ATTENTION: In your implementation, make sure to call "setPartialData_Permissions(..)" at the end to update the in-memory data.
*/
abstract saveAndSetPartialData_Permissions(data: {
permissions: Permission_ENCRYPTED[];
}): Promise<void>;
setPartialData_Permissions(data: { permissions: Permission_ENCRYPTED[] }) {
if (!this.#browserSyncData) {
return;
}
this.#browserSyncData.permissions = Array.from(data.permissions);
}
/**
* Persist the identities to the sync data storage.
*
* ATTENTION: In your implementation, make sure to call "setPartialData_Identities(..)" at the end to update the in-memory data.
*/
abstract saveAndSetPartialData_Identities(data: {
identities: Identity_ENCRYPTED[];
}): Promise<void>;
setPartialData_Identities(data: { identities: Identity_ENCRYPTED[] }) {
if (!this.#browserSyncData) {
return;
}
this.#browserSyncData.identities = Array.from(data.identities);
}
/**
* Persist the selected identity id to the sync data storage.
*
* ATTENTION: In your implementation, make sure to call "setPartialData_SelectedIdentityId(..)" at the end to update the in-memory data.
*/
abstract saveAndSetPartialData_SelectedIdentityId(data: {
selectedIdentityId: string | null;
}): Promise<void>;
setPartialData_SelectedIdentityId(data: {
selectedIdentityId: string | null;
}) {
if (!this.#browserSyncData) {
return;
}
this.#browserSyncData.selectedIdentityId = data.selectedIdentityId;
}
abstract saveAndSetPartialData_Relays(data: {
relays: Relay_ENCRYPTED[];
}): Promise<void>;
setPartialData_Relays(data: { relays: Relay_ENCRYPTED[] }) {
if (!this.#browserSyncData) {
return;
}
this.#browserSyncData.relays = Array.from(data.relays);
}
/**
* Clear all data from the sync data storage.
*/
abstract clearData(): Promise<void>;
}

View File

@@ -0,0 +1,43 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { BrowserSyncFlow, GootiMetaData } from './types';
export abstract class GootiMetaHandler {
get gootiMetaData(): GootiMetaData | undefined {
return this.#gootiMetaData;
}
#gootiMetaData?: GootiMetaData;
readonly metaProperties = ['syncFlow'];
/**
* Load the full data from the storage. If the storage is used for storing
* other data (e.g. browser sync data when the user decided to NOT sync),
* make sure to handle the "meta properties" to only load these.
*
* ATTENTION: Make sure to call "setFullData(..)" afterwards to update the in-memory data.
*/
abstract loadFullData(): Promise<Partial<Record<string, any>>>;
setFullData(data: GootiMetaData) {
this.#gootiMetaData = data;
}
abstract saveFullData(data: GootiMetaData): Promise<void>;
/**
* Sets the browser sync flow for the user and immediately saves it.
*/
async setBrowserSyncFlow(flow: BrowserSyncFlow): Promise<void> {
if (!this.#gootiMetaData) {
this.#gootiMetaData = {
syncFlow: flow,
};
} else {
this.#gootiMetaData.syncFlow = flow;
}
await this.saveFullData(this.#gootiMetaData);
}
abstract clearData(): Promise<void>;
}

View File

@@ -0,0 +1,232 @@
import {
CryptoHelper,
Identity_DECRYPTED,
Identity_ENCRYPTED,
NostrHelper,
StorageService,
} from '@common';
export const addIdentity = async function (
this: StorageService,
data: {
nick: string;
privkeyString: string;
}
): Promise<void> {
this.assureIsInitialized();
const privkey = NostrHelper.getNostrPrivkeyObject(
data.privkeyString.toLowerCase()
).hex;
// Check if an identity with the same privkey already exists.
const existingIdentity = (
this.getBrowserSessionHandler().browserSessionData?.identities ?? []
).find((x) => x.privkey === privkey);
if (existingIdentity) {
throw new Error(
`An identity with the same private key already exists: ${existingIdentity.nick}`
);
}
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
if (!browserSessionData) {
throw new Error('Browser session data is undefined.');
}
const decryptedIdentity: Identity_DECRYPTED = {
id: CryptoHelper.v4(),
nick: data.nick,
privkey,
createdAt: new Date().toISOString(),
};
// Add the new identity to the session data.
browserSessionData.identities.push(decryptedIdentity);
let isFirstIdentity = false;
if (browserSessionData.identities.length === 1) {
isFirstIdentity = true;
browserSessionData.selectedIdentityId = decryptedIdentity.id;
}
this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Encrypt the new identity and add it to the sync data.
const encryptedIdentity = await encryptIdentity.call(this, decryptedIdentity);
const encryptedIdentities = [
...(this.getBrowserSyncHandler().browserSyncData?.identities ?? []),
encryptedIdentity,
];
await this.getBrowserSyncHandler().saveAndSetPartialData_Identities({
identities: encryptedIdentities,
});
if (isFirstIdentity) {
await this.getBrowserSyncHandler().saveAndSetPartialData_SelectedIdentityId(
{
selectedIdentityId: encryptedIdentity.id,
}
);
}
};
export const deleteIdentity = async function (
this: StorageService,
identityId: string | undefined
): Promise<void> {
this.assureIsInitialized();
if (!identityId) {
return;
}
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSessionData || !browserSyncData) {
throw new Error('Browser session or sync data is undefined.');
}
browserSessionData.identities = browserSessionData.identities.filter(
(x) => x.id !== identityId
);
browserSessionData.permissions = browserSessionData.permissions.filter(
(x) => x.identityId !== identityId
);
browserSessionData.relays = browserSessionData.relays.filter(
(x) => x.identityId !== identityId
);
if (browserSessionData.selectedIdentityId === identityId) {
// Choose another identity to be selected or null if there is none.
browserSessionData.selectedIdentityId =
browserSessionData.identities.length > 0
? browserSessionData.identities[0].id
: null;
}
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Handle Sync data.
const encryptedIdentityId = await this.encrypt(identityId);
await this.getBrowserSyncHandler().saveAndSetPartialData_Identities({
identities: browserSyncData.identities.filter(
(x) => x.id !== encryptedIdentityId
),
});
await this.getBrowserSyncHandler().saveAndSetPartialData_Permissions({
permissions: browserSyncData.permissions.filter(
(x) => x.identityId !== encryptedIdentityId
),
});
await this.getBrowserSyncHandler().saveAndSetPartialData_Relays({
relays: browserSyncData.relays.filter(
(x) => x.identityId !== encryptedIdentityId
),
});
await this.getBrowserSyncHandler().saveAndSetPartialData_SelectedIdentityId({
selectedIdentityId:
browserSessionData.selectedIdentityId === null
? null
: await this.encrypt(browserSessionData.selectedIdentityId),
});
};
export const switchIdentity = async function (
this: StorageService,
identityId: string | null
): Promise<void> {
this.assureIsInitialized();
// Check, if the identity really exists.
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
if (!browserSessionData?.identities.find((x) => x.id === identityId)) {
return;
}
browserSessionData.selectedIdentityId = identityId;
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
const encryptedIdentityId =
identityId === null ? null : await this.encrypt(identityId);
await this.getBrowserSyncHandler().saveAndSetPartialData_SelectedIdentityId({
selectedIdentityId: encryptedIdentityId,
});
};
export const encryptIdentity = async function (
this: StorageService,
identity: Identity_DECRYPTED
): Promise<Identity_ENCRYPTED> {
const encryptedIdentity: Identity_ENCRYPTED = {
id: await this.encrypt(identity.id),
nick: await this.encrypt(identity.nick),
createdAt: await this.encrypt(identity.createdAt),
privkey: await this.encrypt(identity.privkey),
};
return encryptedIdentity;
};
export const decryptIdentities = async function (
this: StorageService,
identities: Identity_ENCRYPTED[],
withLockedVault: { iv: string; password: string } | undefined = undefined
): Promise<Identity_DECRYPTED[]> {
const decryptedIdentities: Identity_DECRYPTED[] = [];
for (const identity of identities) {
const decryptedIdentity = await decryptIdentity.call(
this,
identity,
withLockedVault
);
decryptedIdentities.push(decryptedIdentity);
}
return decryptedIdentities;
};
export const decryptIdentity = async function (
this: StorageService,
identity: Identity_ENCRYPTED,
withLockedVault: { iv: string; password: string } | undefined = undefined
): Promise<Identity_DECRYPTED> {
if (typeof withLockedVault === 'undefined') {
const decryptedIdentity: Identity_DECRYPTED = {
id: await this.decrypt(identity.id, 'string'),
nick: await this.decrypt(identity.nick, 'string'),
createdAt: await this.decrypt(identity.createdAt, 'string'),
privkey: await this.decrypt(identity.privkey, 'string'),
};
return decryptedIdentity;
}
const decryptedIdentity: Identity_DECRYPTED = {
id: await this.decryptWithLockedVault(
identity.id,
'string',
withLockedVault.iv,
withLockedVault.password
),
nick: await this.decryptWithLockedVault(
identity.nick,
'string',
withLockedVault.iv,
withLockedVault.password
),
createdAt: await this.decryptWithLockedVault(
identity.createdAt,
'string',
withLockedVault.iv,
withLockedVault.password
),
privkey: await this.decryptWithLockedVault(
identity.privkey,
'string',
withLockedVault.iv,
withLockedVault.password
),
};
return decryptedIdentity;
};

View File

@@ -0,0 +1,111 @@
import {
Permission_DECRYPTED,
Permission_ENCRYPTED,
StorageService,
} from '@common';
export const deletePermission = async function (
this: StorageService,
permissionId: string
): Promise<void> {
this.assureIsInitialized();
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSessionData || !browserSyncData) {
throw new Error('Browser session or sync data is undefined.');
}
browserSessionData.permissions = browserSessionData.permissions.filter(
(x) => x.id !== permissionId
);
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
const encryptedPermissionId = await this.encrypt(permissionId);
await this.getBrowserSyncHandler().saveAndSetPartialData_Permissions({
permissions: browserSyncData.permissions.filter(
(x) => x.id !== encryptedPermissionId
),
});
};
export const decryptPermission = async function (
this: StorageService,
permission: Permission_ENCRYPTED,
withLockedVault: { iv: string; password: string } | undefined = undefined
): Promise<Permission_DECRYPTED> {
if (typeof withLockedVault === 'undefined') {
const decryptedPermission: Permission_DECRYPTED = {
id: await this.decrypt(permission.id, 'string'),
identityId: await this.decrypt(permission.identityId, 'string'),
method: await this.decrypt(permission.method, 'string'),
methodPolicy: await this.decrypt(permission.methodPolicy, 'string'),
host: await this.decrypt(permission.host, 'string'),
};
if (permission.kind) {
decryptedPermission.kind = await this.decrypt(permission.kind, 'number');
}
return decryptedPermission;
}
const decryptedPermission: Permission_DECRYPTED = {
id: await this.decryptWithLockedVault(
permission.id,
'string',
withLockedVault.iv,
withLockedVault.password
),
identityId: await this.decryptWithLockedVault(
permission.identityId,
'string',
withLockedVault.iv,
withLockedVault.password
),
method: await this.decryptWithLockedVault(
permission.method,
'string',
withLockedVault.iv,
withLockedVault.password
),
methodPolicy: await this.decryptWithLockedVault(
permission.methodPolicy,
'string',
withLockedVault.iv,
withLockedVault.password
),
host: await this.decryptWithLockedVault(
permission.host,
'string',
withLockedVault.iv,
withLockedVault.password
),
};
if (permission.kind) {
decryptedPermission.kind = await this.decryptWithLockedVault(
permission.kind,
'number',
withLockedVault.iv,
withLockedVault.password
);
}
return decryptedPermission;
};
export const decryptPermissions = async function (
this: StorageService,
permissions: Permission_ENCRYPTED[],
withLockedVault: { iv: string; password: string } | undefined = undefined
): Promise<Permission_DECRYPTED[]> {
const decryptedPermissions: Permission_DECRYPTED[] = [];
for (const permission of permissions) {
const decryptedPermission = await decryptPermission.call(
this,
permission,
withLockedVault
);
decryptedPermissions.push(decryptedPermission);
}
return decryptedPermissions;
};

View File

@@ -0,0 +1,209 @@
import {
CryptoHelper,
Relay_DECRYPTED,
Relay_ENCRYPTED,
StorageService,
} from '@common';
export const addRelay = async function (
this: StorageService,
data: {
identityId: string;
url: string;
write: boolean;
read: boolean;
}
): Promise<void> {
this.assureIsInitialized();
// Check, if a relay with the same URL already exists for the identity.
const existingRelay =
this.getBrowserSessionHandler().browserSessionData?.relays.find(
(x) =>
x.url.toLowerCase() === data.url.toLowerCase() &&
x.identityId === data.identityId
);
if (existingRelay) {
throw new Error('A relay with the same URL already exists.');
}
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
if (!browserSessionData) {
throw new Error('Browser session data is undefined.');
}
const decryptedRelay: Relay_DECRYPTED = {
id: CryptoHelper.v4(),
identityId: data.identityId,
url: data.url,
write: data.write,
read: data.read,
};
// Add the new relay to the session data.
browserSessionData.relays.push(decryptedRelay);
this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Encrypt the new relay and add it to the sync data.
const encryptedRelay = await encryptRelay.call(this, decryptedRelay);
const encryptedRelays = [
...(this.getBrowserSyncHandler().browserSyncData?.relays ?? []),
encryptedRelay,
];
await this.getBrowserSyncHandler().saveAndSetPartialData_Relays({
relays: encryptedRelays,
});
};
export const deleteRelay = async function (
this: StorageService,
relayId: string
): Promise<void> {
this.assureIsInitialized();
if (!relayId) {
return;
}
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSessionData || !browserSyncData) {
throw new Error('Browser session or sync data is undefined.');
}
browserSessionData.relays = browserSessionData.relays.filter(
(x) => x.id !== relayId
);
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Handle Sync data.
const encryptedRelayId = await this.encrypt(relayId);
await this.getBrowserSyncHandler().saveAndSetPartialData_Relays({
relays: browserSyncData.relays.filter((x) => x.id !== encryptedRelayId),
});
};
export const updateRelay = async function (
this: StorageService,
relayClone: Relay_DECRYPTED
): Promise<void> {
this.assureIsInitialized();
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSessionData || !browserSyncData) {
throw new Error('Browser session or sync data is undefined.');
}
const sessionRelay = browserSessionData.relays.find(
(x) => x.id === relayClone.id
);
const encryptedRelayId = await this.encrypt(relayClone.id);
const syncRelay = browserSyncData.relays.find(
(x) => x.id === encryptedRelayId
);
if (!sessionRelay || !syncRelay) {
throw new Error(
'Relay not found in browser session or sync data for update.'
);
}
// Handle Session update.
sessionRelay.read = relayClone.read;
sessionRelay.write = relayClone.write;
sessionRelay.url = relayClone.url;
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Handle Sync update.
syncRelay.read = await this.encrypt(relayClone.read.toString());
syncRelay.write = await this.encrypt(relayClone.write.toString());
syncRelay.url = await this.encrypt(relayClone.url);
await this.getBrowserSyncHandler().saveAndSetPartialData_Relays({
relays: browserSyncData.relays,
});
};
export const decryptRelay = async function (
this: StorageService,
relay: Relay_ENCRYPTED,
withLockedVault: { iv: string; password: string } | undefined = undefined
): Promise<Relay_DECRYPTED> {
if (typeof withLockedVault === 'undefined') {
const decryptedRelay: Relay_DECRYPTED = {
id: await this.decrypt(relay.id, 'string'),
identityId: await this.decrypt(relay.identityId, 'string'),
url: await this.decrypt(relay.url, 'string'),
read: await this.decrypt(relay.read, 'boolean'),
write: await this.decrypt(relay.write, 'boolean'),
};
return decryptedRelay;
}
const decryptedRelay: Relay_DECRYPTED = {
id: await this.decryptWithLockedVault(
relay.id,
'string',
withLockedVault.iv,
withLockedVault.password
),
identityId: await this.decryptWithLockedVault(
relay.identityId,
'string',
withLockedVault.iv,
withLockedVault.password
),
url: await this.decryptWithLockedVault(
relay.url,
'string',
withLockedVault.iv,
withLockedVault.password
),
read: await this.decryptWithLockedVault(
relay.read,
'boolean',
withLockedVault.iv,
withLockedVault.password
),
write: await this.decryptWithLockedVault(
relay.write,
'boolean',
withLockedVault.iv,
withLockedVault.password
),
};
return decryptedRelay;
};
export const decryptRelays = async function (
this: StorageService,
relays: Relay_ENCRYPTED[],
withLockedVault: { iv: string; password: string } | undefined = undefined
): Promise<Relay_DECRYPTED[]> {
const decryptedRelays: Relay_DECRYPTED[] = [];
for (const relay of relays) {
const decryptedRelay = await decryptRelay.call(
this,
relay,
withLockedVault
);
decryptedRelays.push(decryptedRelay);
}
return decryptedRelays;
};
export const encryptRelay = async function (
this: StorageService,
relay: Relay_DECRYPTED
): Promise<Relay_ENCRYPTED> {
const encryptedRelay: Relay_ENCRYPTED = {
id: await this.encrypt(relay.id),
identityId: await this.encrypt(relay.identityId),
url: await this.encrypt(relay.url),
read: await this.encrypt(relay.read.toString()),
write: await this.encrypt(relay.write.toString()),
};
return encryptedRelay;
};

View File

@@ -0,0 +1,128 @@
import {
BrowserSessionData,
BrowserSyncData,
CryptoHelper,
StorageService,
} from '@common';
import { decryptIdentities } from './identity';
import { decryptPermissions } from './permission';
import { decryptRelays } from './relay';
export const createNewVault = async function (
this: StorageService,
password: string
): Promise<void> {
this.assureIsInitialized();
const vaultHash = await CryptoHelper.hash(password);
const sessionData: BrowserSessionData = {
iv: CryptoHelper.generateIV(),
vaultPassword: password,
identities: [],
permissions: [],
relays: [],
selectedIdentityId: null,
};
await this.getBrowserSessionHandler().saveFullData(sessionData);
this.getBrowserSessionHandler().setFullData(sessionData);
const syncData: BrowserSyncData = {
version: this.latestVersion,
iv: sessionData.iv,
vaultHash,
identities: [],
permissions: [],
relays: [],
selectedIdentityId: null,
};
await this.getBrowserSyncHandler().saveAndSetFullData(syncData);
};
export const unlockVault = async function (
this: StorageService,
password: string
): Promise<void> {
this.assureIsInitialized();
let browserSessionData = this.getBrowserSessionHandler().browserSessionData;
if (browserSessionData) {
throw new Error(
'Browser session data is available. Should only happen when the vault is unlocked'
);
}
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSyncData) {
throw new Error(
'Browser sync data is not available. Should have been loaded before.'
);
}
const passwordHash = await CryptoHelper.hash(password);
if (passwordHash !== browserSyncData.vaultHash) {
throw new Error('Invalid password.');
}
// Ok. Everything is fine. We can unlock the vault now.
// Decrypt the identities.
const withLockedVault = {
iv: browserSyncData.iv,
password,
};
const decryptedIdentities = await decryptIdentities.call(
this,
browserSyncData.identities,
withLockedVault
);
const decryptedPermissions = await decryptPermissions.call(
this,
browserSyncData.permissions,
withLockedVault
);
const decryptedRelays = await decryptRelays.call(
this,
browserSyncData.relays,
withLockedVault
);
const decryptedSelectedIdentityId =
browserSyncData.selectedIdentityId === null
? null
: await this.decryptWithLockedVault(
browserSyncData.selectedIdentityId,
'string',
browserSyncData.iv,
password
);
browserSessionData = {
vaultPassword: password,
iv: browserSyncData.iv,
permissions: decryptedPermissions,
identities: decryptedIdentities,
selectedIdentityId: decryptedSelectedIdentityId,
relays: decryptedRelays,
};
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
this.getBrowserSessionHandler().setFullData(browserSessionData);
};
export const deleteVault = async function (
this: StorageService,
doNotSetIsInitializedToFalse: boolean
): Promise<void> {
this.assureIsInitialized();
const syncFlow = this.getGootiMetaHandler().gootiMetaData?.syncFlow;
if (typeof syncFlow === 'undefined') {
throw new Error('Sync flow is not set.');
}
await this.getBrowserSyncHandler().clearData();
await this.getBrowserSessionHandler().clearData();
await this.getGootiMetaHandler().clearData();
if (!doNotSetIsInitializedToFalse) {
this.isInitialized = false;
}
};

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { StorageService } from './storage.service';
describe('StorageService', () => {
let service: StorageService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(StorageService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,335 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Injectable } from '@angular/core';
import { BrowserSyncHandler } from './browser-sync-handler';
import { BrowserSessionHandler } from './browser-session-handler';
import {
BrowserSessionData,
BrowserSyncData,
BrowserSyncFlow,
GootiMetaData,
Relay_DECRYPTED,
} from './types';
import { GootiMetaHandler } from './gooti-meta-handler';
import { CryptoHelper } from '@common';
import {
addIdentity,
deleteIdentity,
switchIdentity,
} from './related/identity';
import { deletePermission } from './related/permission';
import { createNewVault, deleteVault, unlockVault } from './related/vault';
import { addRelay, deleteRelay, updateRelay } from './related/relay';
export interface StorageServiceConfig {
browserSessionHandler: BrowserSessionHandler;
browserSyncYesHandler: BrowserSyncHandler;
browserSyncNoHandler: BrowserSyncHandler;
gootiMetaHandler: GootiMetaHandler;
}
@Injectable({
providedIn: 'root',
})
export class StorageService {
readonly latestVersion = 1;
isInitialized = false;
#browserSessionHandler!: BrowserSessionHandler;
#browserSyncYesHandler!: BrowserSyncHandler;
#browserSyncNoHandler!: BrowserSyncHandler;
#gootiMetaHandler!: GootiMetaHandler;
initialize(config: StorageServiceConfig): void {
if (this.isInitialized) {
return;
}
this.#browserSessionHandler = config.browserSessionHandler;
this.#browserSyncYesHandler = config.browserSyncYesHandler;
this.#browserSyncNoHandler = config.browserSyncNoHandler;
this.#gootiMetaHandler = config.gootiMetaHandler;
this.isInitialized = true;
}
async enableBrowserSyncFlow(flow: BrowserSyncFlow): Promise<void> {
this.assureIsInitialized();
this.#gootiMetaHandler.setBrowserSyncFlow(flow);
}
async loadGootiMetaData(): Promise<GootiMetaData | undefined> {
this.assureIsInitialized();
const data = await this.#gootiMetaHandler.loadFullData();
if (Object.keys(data).length === 0) {
// No data available yet.
return undefined;
}
this.#gootiMetaHandler.setFullData(data as GootiMetaData);
return data as GootiMetaData;
}
async loadBrowserSessionData(): Promise<BrowserSessionData | undefined> {
this.assureIsInitialized();
const data = await this.#browserSessionHandler.loadFullData();
if (Object.keys(data).length === 0) {
// No data available yet (e.g. because the vault was not unlocked).
return undefined;
}
// Set the existing data for in-memory usage.
this.#browserSessionHandler.setFullData(data as BrowserSessionData);
return data as BrowserSessionData;
}
/**
* Load and migrate the browser sync data. If no data is available yet,
* the returned object is undefined.
*/
async loadAndMigrateBrowserSyncData(): Promise<BrowserSyncData | undefined> {
this.assureIsInitialized();
const unmigratedBrowserSyncData =
await this.getBrowserSyncHandler().loadUnmigratedData();
const { browserSyncData, migrationWasPerformed } =
this.#migrateBrowserSyncData(unmigratedBrowserSyncData);
if (!browserSyncData) {
// Nothing to do at this point.
return undefined;
}
// There is data. Check, if it was migrated.
if (migrationWasPerformed) {
// Persist the migrated data back to the browser sync storage.
this.getBrowserSyncHandler().saveAndSetFullData(browserSyncData);
} else {
// Set the data for in-memory usage.
this.getBrowserSyncHandler().setFullData(browserSyncData);
}
return browserSyncData;
}
async deleteVault(doNotSetIsInitializedToFalse = false) {
await deleteVault.call(this, doNotSetIsInitializedToFalse);
}
async unlockVault(password: string): Promise<void> {
await unlockVault.call(this, password);
}
async createNewVault(password: string): Promise<void> {
await createNewVault.call(this, password);
}
async addIdentity(data: {
nick: string;
privkeyString: string;
}): Promise<void> {
await addIdentity.call(this, data);
}
async deleteIdentity(identityId: string | undefined): Promise<void> {
await deleteIdentity.call(this, identityId);
}
async switchIdentity(identityId: string | null): Promise<void> {
await switchIdentity.call(this, identityId);
}
async deletePermission(permissionId: string) {
await deletePermission.call(this, permissionId);
}
async addRelay(data: {
identityId: string;
url: string;
write: boolean;
read: boolean;
}): Promise<void> {
await addRelay.call(this, data);
}
async deleteRelay(relayId: string): Promise<void> {
await deleteRelay.call(this, relayId);
}
async updateRelay(relayClone: Relay_DECRYPTED): Promise<void> {
await updateRelay.call(this, relayClone);
}
exportVault(): string {
this.assureIsInitialized();
const vaultJson = JSON.stringify(
this.getBrowserSyncHandler().browserSyncData,
undefined,
4
);
return vaultJson;
}
async importVault(allegedBrowserSyncData: BrowserSyncData) {
this.assureIsInitialized();
const isValidData = this.#allegedBrowserSyncDataIsValid(
allegedBrowserSyncData
);
if (!isValidData) {
throw new Error('The imported data is not valid.');
}
await this.getBrowserSyncHandler().saveAndSetFullData(
allegedBrowserSyncData
);
}
getBrowserSyncHandler(): BrowserSyncHandler {
this.assureIsInitialized();
switch (this.#gootiMetaHandler.gootiMetaData?.syncFlow) {
case BrowserSyncFlow.NO_SYNC:
return this.#browserSyncNoHandler;
case BrowserSyncFlow.BROWSER_SYNC:
default:
return this.#browserSyncYesHandler;
}
}
getBrowserSessionHandler(): BrowserSessionHandler {
this.assureIsInitialized();
return this.#browserSessionHandler;
}
getGootiMetaHandler(): GootiMetaHandler {
this.assureIsInitialized();
return this.#gootiMetaHandler;
}
/**
* Throws an exception if the service is not initialized.
*/
assureIsInitialized(): void {
if (!this.isInitialized) {
throw new Error(
'StorageService is not initialized. Please call "initialize(...)" before doing anything else.'
);
}
}
async encrypt(value: string): Promise<string> {
const browserSessionData =
this.getBrowserSessionHandler().browserSessionData;
if (!browserSessionData || !browserSessionData.vaultPassword) {
throw new Error('Browser session data is undefined.');
}
return CryptoHelper.encrypt(
value,
browserSessionData.iv,
browserSessionData.vaultPassword
);
}
async decrypt(
value: string,
returnType: 'string' | 'number' | 'boolean'
): Promise<any> {
const browserSessionData =
this.getBrowserSessionHandler().browserSessionData;
if (!browserSessionData || !browserSessionData.vaultPassword) {
throw new Error('Browser session data is undefined.');
}
return this.decryptWithLockedVault(
value,
returnType,
browserSessionData.iv,
browserSessionData.vaultPassword
);
}
async decryptWithLockedVault(
value: string,
returnType: 'string' | 'number' | 'boolean',
iv: string,
password: string
): Promise<any> {
const decryptedValue = await CryptoHelper.decrypt(value, iv, password);
switch (returnType) {
case 'number':
return parseInt(decryptedValue);
case 'boolean':
return decryptedValue === 'true';
case 'string':
default:
return decryptedValue;
}
}
/**
* Migrate the browser sync data to the latest version.
*/
#migrateBrowserSyncData(browserSyncData: Partial<Record<string, any>>): {
browserSyncData?: BrowserSyncData;
migrationWasPerformed: boolean;
} {
if (Object.keys(browserSyncData).length === 0) {
// First run. There is no browser sync data yet.
return {
browserSyncData: undefined,
migrationWasPerformed: false,
};
}
// Will be implemented if migration is required.
return {
browserSyncData: browserSyncData as BrowserSyncData,
migrationWasPerformed: false,
};
}
#allegedBrowserSyncDataIsValid(data: BrowserSyncData): boolean {
if (typeof data.iv === 'undefined') {
return false;
}
if (typeof data.version !== 'number') {
return false;
}
if (typeof data.vaultHash === 'undefined') {
return false;
}
if (typeof data.selectedIdentityId === 'undefined') {
return false;
}
if (
typeof data.identities === 'undefined' ||
!Array.isArray(data.identities)
) {
return false;
}
if (
typeof data.permissions === 'undefined' ||
!Array.isArray(data.permissions)
) {
return false;
}
if (typeof data.relays === 'undefined' || !Array.isArray(data.relays)) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,84 @@
import { Nip07Method, Nip07MethodPolicy } from '@common';
export interface Permission_DECRYPTED {
id: string;
identityId: string;
host: string;
method: Nip07Method;
methodPolicy: Nip07MethodPolicy;
kind?: number;
}
export interface Permission_ENCRYPTED {
id: string;
identityId: string;
host: string;
method: string;
methodPolicy: string;
kind?: string;
}
export interface Identity_DECRYPTED {
id: string;
createdAt: string;
nick: string;
privkey: string;
}
export type Identity_ENCRYPTED = Identity_DECRYPTED;
export interface Relay_DECRYPTED {
id: string;
identityId: string;
url: string;
read: boolean;
write: boolean;
}
export interface Relay_ENCRYPTED {
id: string;
identityId: string;
url: string;
read: string;
write: string;
}
export interface BrowserSyncData_PART_Unencrypted {
version: number;
iv: string;
vaultHash: string;
}
export interface BrowserSyncData_PART_Encrypted {
selectedIdentityId: string | null;
permissions: Permission_ENCRYPTED[];
identities: Identity_ENCRYPTED[];
relays: Relay_ENCRYPTED[];
}
export type BrowserSyncData = BrowserSyncData_PART_Unencrypted &
BrowserSyncData_PART_Encrypted;
export enum BrowserSyncFlow {
NO_SYNC = 0,
BROWSER_SYNC = 1,
GOOTI_SYNC = 2,
CUSTOM_SYNC = 3,
}
export interface BrowserSessionData {
// The following properties purely come from the browser session storage
// and will never be going into the browser sync storage.
vaultPassword?: string;
// The following properties initially come from the browser sync storage.
iv: string;
permissions: Permission_DECRYPTED[];
identities: Identity_DECRYPTED[];
selectedIdentityId: string | null;
relays: Relay_DECRYPTED[];
}
export interface GootiMetaData {
syncFlow?: number; // 0 = no sync, 1 = browser sync, (future: 2 = Gooti sync, 3 = Custom sync (bring your own sync))
}

View File

@@ -0,0 +1,7 @@
.sam-color-primary {
color: var(--primary);
}
.sam-color-danger {
color: var(--bs-danger);
}

View File

@@ -0,0 +1,37 @@
.sam-text-header {
background: var(--background);
z-index: 20;
padding-top: var(--size);
padding-bottom: var(--size);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
span {
font-size: 20px;
font-weight: 500;
}
}
.sam-footer-grid-2 {
height: 60px;
min-height: 60px;
background: var(--background-light);
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto;
align-items: center;
column-gap: var(--size);
padding-left: var(--size);
padding-right: var(--size);
}
.sam-card {
padding: var(--size);
background: var(--background-light);
border-radius: 8px;
color: #ffffff;
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,38 @@
.sam-flex-column {
display: flex;
flex-direction: column;
&.gap {
row-gap: var(--size);
}
&.gap-h {
row-gap: var(--size-h);
}
&.center {
align-items: center;
}
}
.sam-flex-row {
display: flex;
flex-direction: row;
align-items: center;
&.gap {
column-gap: var(--size);
}
&.gap-h {
column-gap: var(--size-h);
}
}
.sam-flex-grow {
flex-grow: 1;
}
.sam-align-self-center {
align-self: center;
}

View File

@@ -0,0 +1,55 @@
.sam-mt {
margin-top: var(--size);
}
.sam-mt-2 {
margin-top: var(--size-2);
}
.sam-mt-h {
margin-top: var(--size-h);
}
.sam-mb {
margin-bottom: var(--size);
}
.sam-mb-2 {
margin-bottom: var(--size-2);
}
.sam-mb-h {
margin-bottom: var(--size-h);
}
.sam-mr {
margin-right: var(--size);
}
.sam-mr-h {
margin-right: var(--size-h);
}
.sam-ml {
margin-left: var(--size);
}
.sam-ml-h {
margin-left: var(--size-h);
}
.sam-pl {
padding-left: var(--size);
}
.sam-pl-h {
padding-left: var(--size-h);
}
.sam-pr {
padding-right: var(--size);
}
.sam-pr-h {
padding-right: var(--size-h);
}

View File

@@ -0,0 +1,19 @@
.sam-text-muted {
color: gray;
}
.sam-text-lg {
font-size: 20px;
}
.sam-text-md {
font-size: 14px;
}
.sam-text-sm {
font-size: 12px;
}
.sam-text-align-center {
text-align: center;
}

View File

@@ -0,0 +1,17 @@
@use "_flex.scss";
@use "_common.scss";
@use "_typography.scss";
@use "_spacing.scss";
@use "_color.scss";
:root {
--size-2: 32px;
--size: 16px;
--size-h: 8px;
--background: #161c26;
--background-light: #202733;
--background-light-hover: #383844;
--border: #525b6a;
--primary: #0d6efd;
}