chrome-0.0.1

This commit is contained in:
DEV Sam Hayes
2025-02-01 23:24:51 +01:00
175 changed files with 18526 additions and 610 deletions

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npm run lint

View File

@@ -1,59 +1,48 @@
# GootiExtension
# Gooti
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.0.7.
## Nostr Identity Manager & Signer
## Development server
Gooti is a browser extension for managing multiple [Nostr](https://github.com/nostr-protocol/nostr) identities and for signing events on web apps without having to give them your keys.
To start a local development server, run:
It implements these mandatory [NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md) methods:
```bash
ng serve
```typescript
async window.nostr.getPublicKey(): string
async window.nostr.signEvent(event: { created_at: number, kind: number, tags: string[][], content: string }): Event
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
It also implements these optional methods:
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```typescript
async window.nostr.getRelays(): { [url: string]: {read: boolean, write: boolean} }
async window.nostr.nip04.encrypt(pubkey, plaintext): string
async window.nostr.nip04.decrypt(pubkey, ciphertext): string
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
The repository is configured to hold the extensions for Chrome and Firefox. While the Chrome extension is yet already available, the Firefox extension will follow later.
```bash
ng generate --help
[Chrome Extension](https://chromewebstore.google.com/detail/cgikhnoggbhdblnckhcahgkipmiiohbk)
Firefox Extension (yet to come)
## Develop Chrome Extension
To run the Chrome extension from this code:
```
git clone https://github.com/sam-hayes-org/gooti-extension
cd gooti-extension
npm i
npm run watch:chrome
```
## Building
then
To build the project run:
1. within Chrome go to `chrome://extensions`
2. ensure "developer mode" is enabled on the top right
3. click on "Load unpackaged"
4. select the `dist/chrome` folder of this repository
```bash
ng build
```
---
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
LICENSE: Public Domain

View File

@@ -2,6 +2,238 @@
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"cli": {
"schematicCollections": ["angular-eslint"]
},
"projects": {
"chrome": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "projects/chrome",
"sourceRoot": "projects/chrome/src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-builders/custom-webpack:browser",
"options": {
"customWebpackConfig": {
"path": "projects/chrome/custom-webpack.config.ts"
},
"outputPath": "dist/chrome",
"index": "projects/chrome/src/index.html",
"main": "projects/chrome/src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "projects/chrome/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "projects/chrome/public"
}
],
"styles": ["projects/chrome/src/styles.scss"],
"scripts": ["node_modules/bootstrap/dist/js/bootstrap.bundle.js"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "5MB",
"maximumError": "10MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"optimization": {
"scripts": true,
"styles": false,
"fonts": true
}
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "chrome:build:production"
},
"development": {
"buildTarget": "chrome:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": ["zone.js", "zone.js/testing"],
"tsConfig": "projects/chrome/tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "projects/chrome/public"
}
],
"styles": ["projects/chrome/src/styles.scss"],
"scripts": []
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"projects/chrome/**/*.ts",
"projects/chrome/**/*.html"
],
"eslintConfig": "projects/chrome/eslint.config.js"
}
}
}
},
"firefox": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "projects/firefox",
"sourceRoot": "projects/firefox/src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/firefox",
"index": "projects/firefox/src/index.html",
"browser": "projects/firefox/src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "projects/firefox/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "projects/firefox/public"
}
],
"styles": ["projects/firefox/src/styles.scss"],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "firefox:build:production"
},
"development": {
"buildTarget": "firefox:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": ["zone.js", "zone.js/testing"],
"tsConfig": "projects/firefox/tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "projects/firefox/public"
}
],
"styles": ["projects/firefox/src/styles.scss"],
"scripts": []
}
}
}
},
"common": {
"projectType": "library",
"root": "projects/common",
"sourceRoot": "projects/common/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"project": "projects/common/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "projects/common/tsconfig.lib.prod.json"
},
"development": {
"tsConfig": "projects/common/tsconfig.lib.json"
}
},
"defaultConfiguration": "production"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"tsConfig": "projects/common/tsconfig.spec.json",
"polyfills": ["zone.js", "zone.js/testing"]
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"projects/common/**/*.ts",
"projects/common/**/*.html"
],
"eslintConfig": "projects/common/eslint.config.js"
}
}
}
}
}
}

7
chrome_prepare_manifest.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/bin/bash
version=$( cat package.json | jq '.custom.chrome.version' | tr -d '"')
jq '.version = $newVersion' --arg newVersion $version ./projects/chrome/public/manifest.json > ./projects/chrome/public/tmp.manifest.json && mv ./projects/chrome/public/tmp.manifest.json ./projects/chrome/public/manifest.json
echo $version

26
eslint.config.js Normal file
View File

@@ -0,0 +1,26 @@
// @ts-check
const eslint = require("@eslint/js");
const tseslint = require("typescript-eslint");
const angular = require("angular-eslint");
module.exports = tseslint.config(
{
files: ["**/*.ts"],
extends: [
eslint.configs.recommended,
...tseslint.configs.recommended,
...tseslint.configs.stylistic,
...angular.configs.tsRecommended,
],
processor: angular.processInlineTemplates,
rules: {},
},
{
files: ["**/*.html"],
extends: [
...angular.configs.templateRecommended,
...angular.configs.templateAccessibility,
],
rules: {},
}
);

11610
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,28 @@
{
"name": "gooti-extension",
"version": "0.0.0",
"version": "0.0.1",
"custom": {
"chrome": {
"version": "0.0.1"
},
"firefox": {
"version": "0.0.0"
}
},
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
"clean:chrome": "rimraf dist/chrome",
"clean:firefox": "rimraf dist/firefox",
"start:chrome": "ng serve chrome",
"start:firefox": "ng serve firefox",
"prepare:chrome": "./chrome_prepare_manifest.sh",
"build:chrome": "npm run prepare:chrome && ng build chrome",
"build:firefox": "ng build firefox",
"watch:chrome": "npm run prepare:chrome && ng build chrome --watch --configuration development",
"watch:firefox": "ng build firefox --watch --configuration development",
"test": "ng test",
"lint": "ng lint",
"prepare": "husky"
},
"private": true,
"dependencies": {
@@ -18,20 +34,38 @@
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
"@nostr-dev-kit/ndk": "^2.11.0",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"buffer": "^6.0.3",
"nostr-tools": "^2.10.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"webextension-polyfill": "^0.12.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-builders/custom-webpack": "^19.0.0",
"@angular-devkit/build-angular": "^19.0.7",
"@angular/cli": "^19.0.7",
"@angular/compiler-cli": "^19.0.0",
"@types/bootstrap": "^5.2.10",
"@types/chrome": "^0.0.293",
"@types/jasmine": "~5.1.0",
"@types/webextension-polyfill": "^0.12.1",
"angular-eslint": "19.0.2",
"eslint": "^9.16.0",
"husky": "^9.1.7",
"jasmine-core": "~5.4.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.6.2"
"ng-packagr": "^19.0.0",
"rimraf": "^6.0.1",
"typescript": "~5.6.2",
"typescript-eslint": "8.18.0"
}
}

View File

@@ -0,0 +1,22 @@
import type { Configuration } from 'webpack';
module.exports = {
entry: {
background: {
import: 'src/background.ts',
runtime: false,
},
'gooti-extension': {
import: 'src/gooti-extension.ts',
runtime: false,
},
'gooti-content-script': {
import: 'src/gooti-content-script.ts',
runtime: false,
},
prompt: {
import: 'src/prompt.ts',
runtime: false,
},
},
} as Configuration;

View File

@@ -0,0 +1,32 @@
// @ts-check
const tseslint = require("typescript-eslint");
const rootConfig = require("../../eslint.config.js");
module.exports = tseslint.config(
...rootConfig,
{
files: ["**/*.ts"],
rules: {
"@angular-eslint/directive-selector": [
"error",
{
type: "attribute",
prefix: "app",
style: "camelCase",
},
],
"@angular-eslint/component-selector": [
"error",
{
type: "element",
prefix: "app",
style: "kebab-case",
},
],
},
},
{
files: ["**/*.html"],
rules: {},
}
);

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" id="Origami-Paper-Bird--Streamline-Cyber.svg" height="24" width="24"><desc>Origami Paper Bird Streamline Icon: https://streamlinehq.com</desc><path fill="#ffffff" d="M4.66378 6.62012 0.751282 10.9774H6.11588l0.5042 7.3802 11.73752 4.8911L9.55488 9.01202l-4.8911 -2.3919Z" stroke-width="1"></path><path fill="#ffffff" d="M18.8423 0.751343 9.55499 9.01194l5.32571 8.61246L18.8423 0.751343Z" stroke-width="1"></path><path fill="#bbd8ff" d="m9.555 9.01187 -1.4675 -0.7178 -1.7681 5.66933 0.3007 4.3942L9.555 9.01187Z" stroke-width="1"></path><path fill="#bbd8ff" d="m4.66378 6.62012 1.4521 4.35728H0.751282L4.66378 6.62012Z" stroke-width="1"></path><path fill="#bbd8ff" d="m15.3767 18.4282 7.872 -15.23167 -5.5814 2.5565 -2.7866 11.87137 0.496 0.8038Z" stroke-width="1"></path><path stroke="#092f63" stroke-linejoin="round" stroke-miterlimit="10" d="m9.55488 9.01202 -4.8911 -2.3919L0.751282 10.9774H6.11588l0.5042 7.3802 11.73752 4.8911L9.55488 9.01202Z" stroke-width="1"></path><path stroke="#092f63" stroke-linejoin="round" stroke-miterlimit="10" d="M9.55499 9.01194 18.8423 0.751343 14.8807 17.6244" stroke-width="1"></path><path stroke="#092f63" stroke-linejoin="round" stroke-miterlimit="10" d="m17.6673 5.75303 5.5814 -2.5565 -7.872 15.23167" stroke-width="1"></path><path stroke="#092f63" stroke-linejoin="round" stroke-miterlimit="10" d="m4.66382 6.62012 1.4521 4.35728" stroke-width="1"></path><path stroke="#092f63" stroke-linejoin="round" stroke-miterlimit="10" d="m6.62109 18.3564 2.9338 -9.34456" stroke-width="1"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 983 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" id="Origami-Paper-Bird--Streamline-Cyber.svg" height="24" width="24"><desc>Origami Paper Bird Streamline Icon: https://streamlinehq.com</desc><path fill="#ffffff" d="M4.66378 6.62012 0.751282 10.9774H6.11588l0.5042 7.3802 11.73752 4.8911L9.55488 9.01202l-4.8911 -2.3919Z" stroke-width="1"></path><path fill="#ffffff" d="M18.8423 0.751343 9.55499 9.01194l5.32571 8.61246L18.8423 0.751343Z" stroke-width="1"></path><path fill="#bbd8ff" d="m9.555 9.01187 -1.4675 -0.7178 -1.7681 5.66933 0.3007 4.3942L9.555 9.01187Z" stroke-width="1"></path><path fill="#bbd8ff" d="m4.66378 6.62012 1.4521 4.35728H0.751282L4.66378 6.62012Z" stroke-width="1"></path><path fill="#bbd8ff" d="m15.3767 18.4282 7.872 -15.23167 -5.5814 2.5565 -2.7866 11.87137 0.496 0.8038Z" stroke-width="1"></path><path stroke="#0d6efd" stroke-linejoin="round" stroke-miterlimit="10" d="m9.55488 9.01202 -4.8911 -2.3919L0.751282 10.9774H6.11588l0.5042 7.3802 11.73752 4.8911L9.55488 9.01202Z" stroke-width="1"></path><path stroke="#0d6efd" stroke-linejoin="round" stroke-miterlimit="10" d="M9.55499 9.01194 18.8423 0.751343 14.8807 17.6244" stroke-width="1"></path><path stroke="#0d6efd" stroke-linejoin="round" stroke-miterlimit="10" d="m17.6673 5.75303 5.5814 -2.5565 -7.872 15.23167" stroke-width="1"></path><path stroke="#0d6efd" stroke-linejoin="round" stroke-miterlimit="10" d="m4.66382 6.62012 1.4521 4.35728" stroke-width="1"></path><path stroke="#0d6efd" stroke-linejoin="round" stroke-miterlimit="10" d="m6.62109 18.3564 2.9338 -9.34456" stroke-width="1"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,45 @@
{
"manifest_version": 3,
"name": "Gooti",
"description": "Nostr Identity Manager & Signer",
"version": "0.0.1",
"homepage_url": "https://getgooti.com",
"permissions": [
"activeTab",
"windows",
"storage"
],
"action": {
"default_popup": "index.html",
"default_icon": "gooti-with-bg.png"
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"run_at": "document_start",
"matches": [
"<all_urls>"
],
"js": [
"gooti-content-script.js"
],
"all_frames": true
}
],
"web_accessible_resources": [
{
"resources": [
"gooti-extension.js"
],
"matches": [
"https://*/*",
"http://localhost:*/*",
"http://0.0.0.0:*/*",
"http://127.0.0.1:*/*",
"http://*.localhost/*"
]
}
]
}

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" fill="#ffffff" class="bi bi-person-fill" viewBox="0 0 16 16">
<path d="M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>
</svg>

After

Width:  |  Height:  |  Size: 219 B

View File

@@ -0,0 +1,221 @@
<!DOCTYPE html>
<html data-bs-theme="dark">
<head>
<title>Gooti</title>
<link rel="stylesheet" type="text/css" href="styles.css" />
<script src="scripts.js"></script>
<style>
body {
background: var(--background);
height: 100vh;
width: 100vw;
color: #ffffff;
font-size: 16px;
}
.color-primary {
color: var(--primary);
}
.page {
height: 100%;
display: grid;
grid-template-rows: 1fr 60px;
grid-template-columns: 1fr;
overflow-y: hidden;
}
.card {
padding: var(--size);
background: var(--background-light);
border-radius: 8px;
color: #ffffff;
display: flex;
flex-direction: column;
}
.json {
white-space: pre;
overflow-y: auto;
font-size: 12px;
color: gray;
}
.text {
white-space: normal;
overflow-y: auto;
font-size: 12px;
color: gray;
}
</style>
</head>
<body>
<div class="page">
<div class="sam-flex-column" style="overflow-y: auto">
<div class="sam-text-header">
<span id="titleSpan" style="font-weight: 400 !important"></span>
</div>
<span
class="host-INSERT sam-align-self-center sam-text-muted"
style="font-weight: 500"
></span>
<!-- Card for getPublicKey -->
<div id="cardGetPublicKey" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">read your public key</b> <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
</div>
<!-- Card for getRelays -->
<div id="cardGetRelays" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">read your relays</b> <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
</div>
<!-- Card for signEvent -->
<div id="cardSignEvent" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">sign an event</b> (kind
<span id="kindSpan"></span>) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
</div>
<!-- Card2 for signEvent -->
<div id="card2SignEvent" class="card sam-mt sam-ml sam-mr">
<div id="card2SignEvent_json" class="json"></div>
</div>
<!-- Card for nip04.encrypt -->
<div id="cardNip04Encrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">encrypt a text</b> (NIP04) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
</div>
<!-- Card2 for nip04.encrypt -->
<div id="card2Nip04Encrypt" class="card sam-mt sam-ml sam-mr">
<div id="card2Nip04Encrypt_text" class="text"></div>
</div>
<!-- Card for nip04.decrypt -->
<div id="cardNip04Decrypt" class="card sam-mt sam-ml sam-mr">
<span style="text-align: center">
<b><span class="host-INSERT color-primary"></span></b>
is requesting permission to<br />
<br />
<b class="color-primary">decrypt a text</b> (NIP04) <br />
<br />
<span>
for the selected identity
<span
style="font-weight: 500"
class="nick-INSERT color-primary"
></span>
</span>
</span>
</div>
<!-- Card2 for nip04.decrypt -->
<div id="card2Nip04Decrypt" class="card sam-mt sam-ml sam-mr">
<div id="card2Nip04Decrypt_text" class="text"></div>
</div>
</div>
<!------------->
<!-- ACTIONS -->
<!------------->
<div class="sam-footer-grid-2">
<div class="btn-group">
<button id="rejectButton" type="button" class="btn btn-secondary">
Reject
</button>
<button
type="button"
class="btn btn-secondary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button id="rejectJustOnceButton" class="dropdown-item">
just once
</button>
</li>
</ul>
</div>
<div class="btn-group">
<button id="approveButton" type="button" class="btn btn-primary">
Approve
</button>
<button
type="button"
class="btn btn-primary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu">
<li>
<button id="approveJustOnceButton" class="dropdown-item" href="#">
just once
</button>
</li>
</ul>
</div>
</div>
</div>
<script src="prompt.js"></script>
</body>
</html>

View File

@@ -0,0 +1 @@
<router-outlet></router-outlet>

View File

@@ -0,0 +1,29 @@
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have the 'chrome' title`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('chrome');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, chrome');
});
});

View File

@@ -0,0 +1,20 @@
import { Component, inject, OnInit } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { LoggerService } from '@common';
import { StartupService } from './services/startup/startup.service';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
})
export class AppComponent implements OnInit {
readonly #startup = inject(StartupService);
readonly #logger = inject(LoggerService);
ngOnInit(): void {
this.#logger.initialize('Gooti Chrome Extension');
this.#startup.startOver();
}
}

View File

@@ -0,0 +1,8 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)]
};

View File

@@ -0,0 +1,90 @@
import { Routes } from '@angular/router';
import { HomeComponent } from './components/home/home.component';
import { VaultLoginComponent } from './components/vault-login/vault-login.component';
import { VaultCreateComponent } from './components/vault-create/vault-create.component';
import { HomeComponent as VaultCreateHomeComponent } from './components/vault-create/home/home.component';
import { NewComponent as VaultCreateNewComponent } from './components/vault-create/new/new.component';
import { WelcomeComponent } from './components/welcome/welcome.component';
import { IdentitiesComponent } from './components/home/identities/identities.component';
import { IdentityComponent } from './components/home/identity/identity.component';
import { InfoComponent } from './components/home/info/info.component';
import { SettingsComponent } from './components/home/settings/settings.component';
import { NewIdentityComponent } from './components/new-identity/new-identity.component';
import { EditIdentityComponent } from './components/edit-identity/edit-identity.component';
import { HomeComponent as EditIdentityHomeComponent } from './components/edit-identity/home/home.component';
import { KeysComponent as EditIdentityKeysComponent } from './components/edit-identity/keys/keys.component';
import { PermissionsComponent as EditIdentityPermissionsComponent } from './components/edit-identity/permissions/permissions.component';
import { RelaysComponent as EditIdentityRelaysComponent } from './components/edit-identity/relays/relays.component';
export const routes: Routes = [
{
path: 'welcome',
component: WelcomeComponent,
},
{
path: 'vault-login',
component: VaultLoginComponent,
},
{
path: 'vault-create',
component: VaultCreateComponent,
children: [
{
path: 'home',
component: VaultCreateHomeComponent,
},
{
path: 'new',
component: VaultCreateNewComponent,
},
],
},
{
path: 'home',
component: HomeComponent,
children: [
{
path: 'identities',
component: IdentitiesComponent,
},
{
path: 'identity',
component: IdentityComponent,
},
{
path: 'info',
component: InfoComponent,
},
{
path: 'settings',
component: SettingsComponent,
},
],
},
{
path: 'new-identity',
component: NewIdentityComponent,
},
{
path: 'edit-identity/:id',
component: EditIdentityComponent,
children: [
{
path: 'home',
component: EditIdentityHomeComponent,
},
{
path: 'keys',
component: EditIdentityKeysComponent,
},
{
path: 'permissions',
component: EditIdentityPermissionsComponent,
},
{
path: 'relays',
component: EditIdentityRelaysComponent,
},
],
},
];

View File

@@ -0,0 +1,29 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { GootiMetaData, GootiMetaHandler } from '@common';
export class ChromeMetaHandler extends GootiMetaHandler {
async loadFullData(): Promise<Partial<Record<string, any>>> {
const dataWithPossibleAlienProperties = await chrome.storage.local.get(
null
);
if (Object.keys(dataWithPossibleAlienProperties).length === 0) {
return dataWithPossibleAlienProperties;
}
const data: Partial<Record<string, any>> = {};
this.metaProperties.forEach((property) => {
data[property] = dataWithPossibleAlienProperties[property];
});
return data;
}
async saveFullData(data: GootiMetaData): Promise<void> {
await chrome.storage.local.set(data);
}
async clearData(): Promise<void> {
await chrome.storage.local.remove(this.metaProperties);
}
}

View File

@@ -0,0 +1,16 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { BrowserSessionData, BrowserSessionHandler } from '@common';
export class ChromeSessionHandler extends BrowserSessionHandler {
async loadFullData(): Promise<Partial<Record<string, any>>> {
return chrome.storage.session.get(null);
}
async saveFullData(data: BrowserSessionData): Promise<void> {
await chrome.storage.session.set(data);
}
async clearData(): Promise<void> {
await chrome.storage.session.clear();
}
}

View File

@@ -0,0 +1,64 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSyncData,
BrowserSyncHandler,
Identity_ENCRYPTED,
Permission_ENCRYPTED,
Relay_ENCRYPTED,
} from '@common';
/**
* Handles the browser "sync data" when the user does not want to sync anything.
* It uses the chrome.storage.local API to store the data. Since we also use this API
* to store local Gooti system data (like the user's decision to not sync), we
* have to exclude these properties from the sync data.
*/
export class ChromeSyncNoHandler extends BrowserSyncHandler {
async loadUnmigratedData(): Promise<Partial<Record<string, any>>> {
const data = await chrome.storage.local.get(null);
// Remove any available "ignore properties".
this.ignoreProperties.forEach((property) => {
delete data[property];
});
return data;
}
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
await chrome.storage.local.set(data);
this.setFullData(data);
}
async saveAndSetPartialData_Permissions(data: {
permissions: Permission_ENCRYPTED[];
}): Promise<void> {
await chrome.storage.local.set(data);
this.setPartialData_Permissions(data);
}
async saveAndSetPartialData_Identities(data: {
identities: Identity_ENCRYPTED[];
}): Promise<void> {
await chrome.storage.local.set(data);
this.setPartialData_Identities(data);
}
async saveAndSetPartialData_SelectedIdentityId(data: {
selectedIdentityId: string | null;
}): Promise<void> {
await chrome.storage.local.set(data);
this.setPartialData_SelectedIdentityId(data);
}
async saveAndSetPartialData_Relays(data: {
relays: Relay_ENCRYPTED[];
}): Promise<void> {
await chrome.storage.local.set(data);
this.setPartialData_Relays(data);
}
async clearData(): Promise<void> {
const props = Object.keys(await this.loadUnmigratedData());
await chrome.storage.local.remove(props);
}
}

View File

@@ -0,0 +1,55 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSyncData,
Identity_ENCRYPTED,
Permission_ENCRYPTED,
BrowserSyncHandler,
Relay_ENCRYPTED,
} from '@common';
/**
* Handles the browser sync operations when the browser sync is enabled.
* If it's not enabled, it behaves like the local extension storage (which is fine).
*/
export class ChromeSyncYesHandler extends BrowserSyncHandler {
async loadUnmigratedData(): Promise<Partial<Record<string, any>>> {
return await chrome.storage.sync.get(null);
}
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
await chrome.storage.sync.set(data);
this.setFullData(data);
}
async saveAndSetPartialData_Permissions(data: {
permissions: Permission_ENCRYPTED[];
}): Promise<void> {
await chrome.storage.sync.set(data);
this.setPartialData_Permissions(data);
}
async saveAndSetPartialData_Identities(data: {
identities: Identity_ENCRYPTED[];
}): Promise<void> {
await chrome.storage.sync.set(data);
this.setPartialData_Identities(data);
}
async saveAndSetPartialData_SelectedIdentityId(data: {
selectedIdentityId: string | null;
}): Promise<void> {
await chrome.storage.sync.set(data);
this.setPartialData_SelectedIdentityId(data);
}
async saveAndSetPartialData_Relays(data: {
relays: Relay_ENCRYPTED[];
}): Promise<void> {
await chrome.storage.sync.set(data);
this.setPartialData_Relays(data);
}
async clearData(): Promise<void> {
await chrome.storage.sync.clear();
}
}

View File

@@ -0,0 +1,95 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
declare global {
interface Array<T> {
/**
* Sorts the array by the provided property and returns a new sorted array.
* Default sorting is ASC. You can apply DESC sorting by using the optional parameter "order = 'desc'"
*/
sortBy<K>(keyFunction: (t: T) => K, order?: 'asc' | 'desc'): T[];
/** Check if the array is empty. */
empty(): boolean;
groupBy<K, R>(
keyFunction: (t: T) => K,
reduceFn: (items: T[]) => R
): Map<K, R>;
}
}
if (!Array.prototype.empty) {
Array.prototype.empty = function (): boolean {
return this.length === 0;
};
}
if (!Array.prototype.sortBy) {
Array.prototype.sortBy = function <T, K>(
keyFunction: (t: T) => K,
order?: string
): T[] {
if (this.length === 0) {
return [];
}
// determine sort order (asc or desc / asc is default)
let asc = true;
if (order === 'desc') {
asc = false;
}
const arrayClone = Array.from(this) as any[];
const firstSortProperty = keyFunction(arrayClone[0]);
if (typeof firstSortProperty === 'string') {
// string in-place sort
arrayClone.sort((a, b) => {
if (asc) {
return ('' + (keyFunction(a) as unknown as string)).localeCompare(
keyFunction(b) as unknown as string
);
}
return ('' + (keyFunction(b) as unknown as string)).localeCompare(
keyFunction(a) as unknown as string
);
});
} else if (typeof firstSortProperty === 'number') {
// number in-place sort
if (asc) {
arrayClone.sort(
(a, b) => Number(keyFunction(a)) - Number(keyFunction(b))
);
} else {
arrayClone.sort(
(a, b) => Number(keyFunction(b)) - Number(keyFunction(a))
);
}
} else {
throw new Error('sortBy is not implemented for that type!');
}
return arrayClone;
};
}
if (!Array.prototype.groupBy) {
Array.prototype.groupBy = function <T>(
fn: (item: T) => any,
reduceFn: (items: T[]) => any
): Map<any, any> {
const result = new Map<any, any>();
const distinctKeys = new Set<any>(this.map((x) => fn(x)));
for (const distinctKey of distinctKeys) {
const distinctKeyItems = this.filter((x) => fn(x) === distinctKey);
result.set(distinctKey, reduceFn(distinctKeyItems));
}
return result;
};
}
export {};

View File

@@ -0,0 +1,13 @@
<div class="custom-header">
<lib-icon-button
class="button"
icon="chevron-left"
(click)="onClickCancel()"
></lib-icon-button>
<span class="text">{{ identity?.nick }} </span>
</div>
<div class="edit-identity-outlet">
<router-outlet></router-outlet>
</div>

View File

@@ -0,0 +1,47 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
overflow-y: hidden;
overflow-x: hidden;
.custom-header {
padding-top: var(--size);
padding-bottom: var(--size);
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto;
align-items: center;
background: var(--background);
.button {
grid-column-start: 1;
grid-column-end: 2;
grid-row-start: 1;
grid-row-end: 2;
justify-self: start;
margin-left: 16px;
z-index: 1;
}
.text {
grid-column-start: 1;
grid-column-end: 2;
grid-row-start: 1;
grid-row-end: 2;
font-size: 20px;
font-weight: 500;
justify-self: center;
height: 32px;
overflow-x: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 70%;
}
}
.edit-identity-outlet {
flex-grow: 1;
overflow-y: hidden;
}
}

View File

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

View File

@@ -0,0 +1,43 @@
import { Component, inject, OnInit } from '@angular/core';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { IconButtonComponent, Identity_DECRYPTED, StorageService } from '@common';
@Component({
selector: 'app-edit-identity',
templateUrl: './edit-identity.component.html',
styleUrl: './edit-identity.component.scss',
imports: [RouterOutlet, IconButtonComponent],
})
export class EditIdentityComponent implements OnInit {
identity?: Identity_DECRYPTED;
previousRoute?: string;
readonly #activatedRoute = inject(ActivatedRoute);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
constructor() {
// Must be called in the constructor and NOT in ngOnInit.
this.previousRoute = this.#router
.getCurrentNavigation()
?.previousNavigation?.extractedUrl.toString();
}
ngOnInit(): void {
const selectedIdentityId = this.#activatedRoute.snapshot.params['id'];
if (!selectedIdentityId) {
return;
}
this.identity = this.#storage
.getBrowserSessionHandler()
.browserSessionData?.identities.find((x) => x.id === selectedIdentityId);
}
onClickCancel() {
if (!this.previousRoute) {
return;
}
this.#router.navigateByUrl(this.previousRoute);
}
}

View File

@@ -0,0 +1,28 @@
<lib-nav-item text="Keys" (click)="onClickNavigateTo('keys')"></lib-nav-item>
<lib-nav-item
text="Relays"
(click)="onClickNavigateTo('relays')"
></lib-nav-item>
<lib-nav-item
text="Permissions"
(click)="onClickNavigateTo('permissions')"
></lib-nav-item>
<div class="sam-flex-grow"></div>
<button
type="button"
class="btn btn-danger"
(click)="
confirm.show(
'Do you really want to delete this identity?',
onConfirmDeletion.bind(this)
)
"
>
Delete Identity
</button>
<lib-confirm #confirm> </lib-confirm>

View File

@@ -0,0 +1,8 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
padding-left: var(--size);
padding-right: var(--size);
padding-bottom: var(--size);
}

View File

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

View File

@@ -0,0 +1,44 @@
import { Component, inject, OnInit } from '@angular/core';
import { NavItemComponent } from '../../../../../../common/src/lib/components/nav-item/nav-item.component';
import { ActivatedRoute, Router } from '@angular/router';
import { ConfirmComponent, Identity_DECRYPTED, StorageService } from '@common';
@Component({
selector: 'app-home',
imports: [NavItemComponent, ConfirmComponent],
templateUrl: './home.component.html',
styleUrl: './home.component.scss',
})
export class HomeComponent implements OnInit {
identity?: Identity_DECRYPTED;
readonly #activatedRoute = inject(ActivatedRoute);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
ngOnInit(): void {
const identityId = this.#activatedRoute.parent?.snapshot.params['id'];
if (!identityId) {
return;
}
this.#initialize(identityId);
}
onClickNavigateTo(destination: 'keys' | 'permissions' | 'relays') {
this.#router.navigateByUrl(
`/edit-identity/${this.identity?.id}/${destination}`
);
}
async onConfirmDeletion() {
await this.#storage.deleteIdentity(this.identity?.id);
await this.#router.navigateByUrl('/home/identities');
}
#initialize(selectedIdentityId: string) {
this.identity = this.#storage
.getBrowserSessionHandler()
.browserSessionData?.identities.find((x) => x.id === selectedIdentityId);
}
}

View File

@@ -0,0 +1,141 @@
<div class="header-pane">
<lib-icon-button
icon="chevron-left"
(click)="navigateBack()"
></lib-icon-button>
<span>Keys</span>
</div>
@if(identity) {
<span>Public Key</span>
<!-- PUBKEY NPUB -->
<div class="sam-mt-h sam-flex-row gap">
<span class="text-muted" style="width: 48px">NPUB</span>
<div class="input-group">
<input
id="pubkeyNpubInput"
#pubkeyNpubInput
type="text"
class="form-control"
[ngModel]="identity.pubkeyNpub"
[readOnly]="true"
/>
<button
class="btn btn-outline-secondary"
type="button"
(click)="
copyToClipboard(identity.pubkeyNpub); toast.show('Copied to clipboard')
"
>
<i
class="bi bi-copy"
[class.bi-eye]="pubkeyNpubInput.type === 'password'"
[class.bi-eye-slash]="pubkeyNpubInput.type === 'text'"
></i>
</button>
</div>
</div>
<!-- PUBKEY HEX -->
<div class="sam-mt-h sam-flex-row gap">
<span class="text-muted" style="width: 48px">HEX</span>
<div class="input-group">
<input
id="pubkeyHexInput"
#pubkeyHexInput
type="text"
class="form-control"
[ngModel]="identity.pubkeyHex"
[readOnly]="true"
/>
<button
class="btn btn-outline-secondary"
type="button"
(click)="
copyToClipboard(identity.pubkeyHex); toast.show('Copied to clipboard')
"
>
<i
class="bi bi-copy"
[class.bi-eye]="pubkeyHexInput.type === 'password'"
[class.bi-eye-slash]="pubkeyHexInput.type === 'text'"
></i>
</button>
</div>
</div>
<span class="sam-mt-2">Private Key</span>
<!-- PRIVATE NSEC -->
<div class="sam-mt-h sam-flex-row gap">
<span class="text-muted" style="width: 48px">NSEC</span>
<div class="input-group">
<input
id="privkeyNsecInput"
#privkeyNsecInput
type="password"
class="form-control"
[ngModel]="identity.privkeyNsec"
[readOnly]="true"
/>
<button
class="btn btn-outline-secondary"
type="button"
(click)="
copyToClipboard(identity.privkeyNsec); toast.show('Copied to clipboard')
"
>
<i class="bi bi-copy"></i>
</button>
<button
class="btn btn-outline-secondary"
type="button"
(click)="toggleType(privkeyNsecInput)"
>
<i
class="bi bi-eye"
[class.bi-eye]="privkeyNsecInput.type === 'password'"
[class.bi-eye-slash]="privkeyNsecInput.type === 'text'"
></i>
</button>
</div>
</div>
<!-- PRIVATE HEX -->
<div class="sam-mt-h sam-flex-row gap">
<span class="text-muted" style="width: 48px">HEX</span>
<div class="input-group">
<input
id="privkeyHexInput"
#privkeyHexInput
type="password"
class="form-control"
[ngModel]="identity.privkeyHex"
[readOnly]="true"
/>
<button
class="btn btn-outline-secondary"
type="button"
(click)="
copyToClipboard(identity.privkeyHex); toast.show('Copied to clipboard')
"
>
<i class="bi bi-copy"></i>
</button>
<button
class="btn btn-outline-secondary"
type="button"
(click)="toggleType(privkeyHexInput)"
>
<i
class="bi bi-eye"
[class.bi-eye]="privkeyHexInput.type === 'password'"
[class.bi-eye-slash]="privkeyHexInput.type === 'text'"
></i>
</button>
</div>
</div>
}
<lib-toast #toast [bottom]="16"></lib-toast>

View File

@@ -0,0 +1,19 @@
:host {
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
padding-left: var(--size);
padding-right: var(--size);
.header-pane {
display: flex;
flex-direction: row;
column-gap: var(--size-h);
align-items: center;
padding-bottom: var(--size);
background-color: var(--background);
position: sticky;
top: 0;
}
}

View File

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

View File

@@ -0,0 +1,74 @@
import { Component, inject, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import {
NavComponent,
NostrHelper,
StorageService,
ToastComponent,
} from '@common';
import { IconButtonComponent } from '../../../../../../common/src/lib/components/icon-button/icon-button.component';
import { FormsModule } from '@angular/forms';
interface CustomIdentity {
id: string;
nick: string;
privkeyNsec: string;
privkeyHex: string;
pubkeyNpub: string;
pubkeyHex: string;
}
@Component({
selector: 'app-keys',
imports: [IconButtonComponent, FormsModule, ToastComponent],
templateUrl: './keys.component.html',
styleUrl: './keys.component.scss',
})
export class KeysComponent extends NavComponent implements OnInit {
identity?: CustomIdentity;
readonly #activatedRoute = inject(ActivatedRoute);
readonly #storage = inject(StorageService);
ngOnInit(): void {
const identityId = this.#activatedRoute.parent?.snapshot.params['id'];
if (!identityId) {
return;
}
this.#initialize(identityId);
}
copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
toggleType(element: HTMLInputElement) {
if (element.type === 'password') {
element.type = 'text';
} else {
element.type = 'password';
}
}
async #initialize(identityId: string) {
const identity = this.#storage
.getBrowserSessionHandler()
.browserSessionData?.identities.find((x) => x.id === identityId);
if (!identity) {
return;
}
const pubkey = NostrHelper.pubkeyFromPrivkey(identity.privkey);
this.identity = {
id: identity.id,
nick: identity.nick,
privkeyHex: identity.privkey,
privkeyNsec: NostrHelper.privkey2nsec(identity.privkey),
pubkeyHex: pubkey,
pubkeyNpub: NostrHelper.pubkey2npub(pubkey),
};
}
}

View File

@@ -0,0 +1,37 @@
<div class="header-pane">
<lib-icon-button icon="chevron-left" (click)="navigateBack()"></lib-icon-button>
<span>Permissions</span>
</div>
@if(hostsPermissions.length === 0) {
<span class="text-muted" style="font-size: 12px">
Nothing configured so far.
</span>
} @for(hostPermissions of hostsPermissions; track hostPermissions) {
<div class="permissions-card">
<span style="margin-bottom: 4px; font-weight: 500">
{{ hostPermissions.host }}
</span>
@for(permission of hostPermissions.permissions; track permission) {
<div class="permission">
<span
[class.action-allow]="permission.methodPolicy === 'allow'"
[class.action-deny]="permission.methodPolicy === 'deny'"
>{{ permission.methodPolicy }}</span
>
<span class="text-muted">{{ permission.method }}</span>
@if(typeof permission.kind !== 'undefined') {
<span>(kind {{ permission.kind }})</span>
}
<div class="sam-flex-grow"></div>
<lib-icon-button
icon="trash"
title="Revoke permission"
(click)="onClickRevokePermission(permission)"
></lib-icon-button>
</div>
}
</div>
}

View File

@@ -0,0 +1,61 @@
:host {
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
padding-left: var(--size);
padding-right: var(--size);
.header-pane {
display: flex;
flex-direction: row;
column-gap: var(--size-h);
align-items: center;
padding-bottom: var(--size);
background-color: var(--background);
position: sticky;
top: 0;
}
.permissions-card {
background: var(--background-light);
border-radius: 8px;
padding: calc(var(--size) / 2) var(--size);
display: flex;
flex-direction: column;
margin-bottom: 4px;
.permission {
display: flex;
flex-direction: row;
align-items: center;
column-gap: var(--size);
font-size: 12px;
margin-left: -8px;
padding-left: 8px;
margin-right: -8px;
padding-right: 8px;
border-radius: 4px;
&:hover {
background: var(--background-light-hover);
}
.action-allow {
background: var(--bs-green);
border-radius: 4px;
padding: 0 4px;
width: 40px;
text-align: center;
}
.action-deny {
background: var(--bs-danger-border-subtle);
border-radius: 4px;
padding: 0 4px;
width: 40px;
text-align: center;
}
}
}
}

View File

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

View File

@@ -0,0 +1,81 @@
import { Component, inject, OnInit } from '@angular/core';
import {
IconButtonComponent,
Identity_DECRYPTED,
NavComponent,
Permission_DECRYPTED,
StorageService,
} from '@common';
import { ActivatedRoute } from '@angular/router';
interface HostPermissions {
host: string;
permissions: Permission_DECRYPTED[];
}
@Component({
selector: 'app-permissions',
imports: [IconButtonComponent],
templateUrl: './permissions.component.html',
styleUrl: './permissions.component.scss',
})
export class PermissionsComponent extends NavComponent implements OnInit {
identity?: Identity_DECRYPTED;
hostsPermissions: HostPermissions[] = [];
readonly #activatedRoute = inject(ActivatedRoute);
readonly #storage = inject(StorageService);
ngOnInit(): void {
const selectedIdentityId =
this.#activatedRoute.parent?.snapshot.params['id'];
if (!selectedIdentityId) {
return;
}
this.#initialize(selectedIdentityId);
}
async onClickRevokePermission(permission: Permission_DECRYPTED) {
await this.#storage.deletePermission(permission.id);
this.#buildHostsPermissions(this.identity?.id);
}
#initialize(identityId: string) {
this.identity = this.#storage
.getBrowserSessionHandler()
.browserSessionData?.identities.find((x) => x.id === identityId);
if (!this.identity) {
return;
}
this.#buildHostsPermissions(identityId);
}
#buildHostsPermissions(identityId: string | undefined) {
if (!identityId) {
return;
}
this.hostsPermissions = [];
const hostPermissions = (
this.#storage.getBrowserSessionHandler().browserSessionData
?.permissions ?? []
)
.filter((x) => x.identityId === identityId)
.sortBy((x) => x.host)
.groupBy(
(x) => x.host,
(y) => y
);
hostPermissions.forEach((permissions, host) => {
this.hostsPermissions.push({
host: host,
permissions: permissions.sortBy((x) => x.method),
});
});
}
}

View File

@@ -0,0 +1,79 @@
<!-- RELAY_TEMPLATE -->
<ng-template #relayTemplate let-relay="relay">
<div class="sam-flex-row gap relay">
<div class="sam-flex-column sam-flex-grow">
<span>{{ relay.url | visualRelay }}</span>
<div class="sam-flex-row gap-h">
<lib-relay-rw
type="read"
[(model)]="relay.read"
(modelChange)="onRelayChanged(relay)"
></lib-relay-rw>
<lib-relay-rw
type="write"
[(model)]="relay.write"
(modelChange)="onRelayChanged(relay)"
></lib-relay-rw>
</div>
</div>
<lib-icon-button
icon="trash"
title="Remove relay"
(click)="onClickRemoveRelay(relay)"
style="margin-top: 4px"
></lib-icon-button>
</div>
</ng-template>
<div class="header-pane">
<lib-icon-button
icon="chevron-left"
(click)="navigateBack()"
></lib-icon-button>
<span>Relays</span>
</div>
<div class="sam-mb-2 sam-flex-row gap">
<div class="sam-flex-column sam-flex-grow">
<input
type="text"
(focus)="addRelayInputHasFocus = true"
(blur)="addRelayInputHasFocus = false"
[placeholder]="addRelayInputHasFocus ? 'server.com' : 'Add a relay'"
class="form-control"
[(ngModel)]="newRelay.url"
(ngModelChange)="evaluateCanAdd()"
/>
<div class="sam-flex-row gap-h" style="margin-top: 4px">
<lib-relay-rw
class="sam-flex-grow"
type="read"
[(model)]="newRelay.read"
(modelChange)="evaluateCanAdd()"
></lib-relay-rw>
<lib-relay-rw
class="sam-flex-grow"
type="write"
[(model)]="newRelay.write"
(modelChange)="evaluateCanAdd()"
></lib-relay-rw>
</div>
</div>
<button
type="button"
class="btn btn-primary"
style="height: 100%"
(click)="onClickAddRelay()"
[disabled]="!canAdd"
>
Add
</button>
</div>
@for(relay of relays; track relay) {
<ng-container
*ngTemplateOutlet="relayTemplate; context: { relay: relay }"
></ng-container>
}

View File

@@ -0,0 +1,30 @@
:host {
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
padding-left: var(--size);
padding-right: var(--size);
.header-pane {
display: flex;
flex-direction: row;
column-gap: var(--size-h);
align-items: center;
padding-bottom: var(--size);
background-color: var(--background);
position: sticky;
top: 0;
}
.relay {
margin-bottom: 4px;
padding: 4px 8px 6px 8px;
border-radius: 8px;
background: var(--background-light);
&:hover {
background: var(--background-light-hover);
}
}
}

View File

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

View File

@@ -0,0 +1,131 @@
import { NgTemplateOutlet } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import {
IconButtonComponent,
Identity_DECRYPTED,
NavComponent,
Relay_DECRYPTED,
RelayRwComponent,
StorageService,
VisualRelayPipe,
} from '@common';
interface NewRelay {
url: string;
read: boolean;
write: boolean;
};
@Component({
selector: 'app-relays',
imports: [
IconButtonComponent,
FormsModule,
RelayRwComponent,
NgTemplateOutlet,
VisualRelayPipe,
],
templateUrl: './relays.component.html',
styleUrl: './relays.component.scss',
})
export class RelaysComponent extends NavComponent implements OnInit {
identity?: Identity_DECRYPTED;
relays: Relay_DECRYPTED[] = [];
addRelayInputHasFocus = false;
newRelay: NewRelay = {
url: '',
read: true,
write: true,
};
canAdd = false;
readonly #activatedRoute = inject(ActivatedRoute);
readonly #storage = inject(StorageService);
ngOnInit(): void {
const selectedIdentityId =
this.#activatedRoute.parent?.snapshot.params['id'];
if (!selectedIdentityId) {
return;
}
this.#loadData(selectedIdentityId);
}
evaluateCanAdd() {
let canAdd = true;
if (!this.newRelay.url) {
canAdd = false;
} else if (!this.newRelay.read && !this.newRelay.write) {
canAdd = false;
}
this.canAdd = canAdd;
}
async onClickRemoveRelay(relay: Relay_DECRYPTED) {
if (!this.identity) {
return;
}
try {
await this.#storage.deleteRelay(relay.id);
this.#loadData(this.identity.id);
} catch (error) {
console.log(error);
// TODO
}
}
async onClickAddRelay() {
if (!this.identity) {
return;
}
try {
await this.#storage.addRelay({
identityId: this.identity.id,
url: 'wss://' + this.newRelay.url.toLowerCase(),
read: this.newRelay.read,
write: this.newRelay.write,
});
this.newRelay = {
url: '',
read: true,
write: true,
};
this.evaluateCanAdd();
this.#loadData(this.identity.id);
} catch (error) {
console.log(error);
// TODO
}
}
async onRelayChanged(relay: Relay_DECRYPTED) {
try {
await this.#storage.updateRelay(relay);
} catch (error) {
console.log(error);
// TODO
}
}
#loadData(identityId: string) {
this.identity = this.#storage
.getBrowserSessionHandler()
.browserSessionData?.identities.find((x) => x.id === identityId);
const relays: Relay_DECRYPTED[] = [];
(this.#storage.getBrowserSessionHandler().browserSessionData?.relays ?? [])
.filter((x) => x.identityId === identityId)
.forEach((x) => {
relays.push(JSON.parse(JSON.stringify(x)));
});
this.relays = relays;
}
}

View File

@@ -0,0 +1,36 @@
<div class="tab-content">
<router-outlet></router-outlet>
</div>
<div class="tabs">
<a
class="tab"
routerLink="/home/identity"
routerLinkActive="active"
title="Your selected identity"
>
<i class="bi bi-person-circle"></i>
</a>
<a
class="tab"
routerLink="/home/identities"
routerLinkActive="active"
title="Identities"
>
<i class="bi bi-people-fill"></i>
</a>
<a
class="tab"
routerLink="/home/settings"
routerLinkActive="active"
title="Settings"
>
<i class="bi bi-gear"></i>
</a>
<a class="tab" routerLink="/home/info" routerLinkActive="active" title="Info">
<i class="bi bi-info-circle"></i>
</a>
</div>

View File

@@ -0,0 +1,43 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
.tab-content {
height: calc(100% - 60px);
}
.tabs {
height: 60px;
min-height: 60px;
background: var(--background-light);
display: flex;
flex-direction: row;
a {
all: unset;
}
.tab {
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: gray;
border-top: 3px solid transparent;
cursor: pointer;
&:hover {
background: var(--background-light-hover);
}
&.active {
color: #ffffff;
border-top: 3px solid #0d6efd;
}
}
}
}

View File

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

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { RouterModule, RouterOutlet } from '@angular/router';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrl: './home.component.scss',
imports: [RouterOutlet, RouterModule],
})
export class HomeComponent {}

View File

@@ -0,0 +1,78 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
<div class="custom-header" style="position: sticky; top: 0">
<span class="text">Identities </span>
<button class="button btn btn-primary btn-sm" (click)="onClickNewIdentity()">
<div class="sam-flex-row gap-h">
<i class="bi bi-plus-lg"></i>
<span>New</span>
</div>
</button>
</div>
@let sessionData = storage.getBrowserSessionHandler().browserSessionData;
<!-- - -->
@let identities = sessionData?.identities ?? []; @if(identities.length === 0) {
<div
style="
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
"
>
<span class="sam-text-muted">
Create your first identity by clicking on the button in the upper right
corner.
</span>
</div>
} @for(identity of identities; track identity) {
<div
class="identity"
style="overflow: hidden"
(click)="onClickEditIdentity(identity)"
>
@let isSelected = identity.id === sessionData?.selectedIdentityId;
<span
class="no-select"
style="overflow-x: hidden; text-overflow: ellipsis; white-space: nowrap"
[class.not-active]="!isSelected"
>
{{ identity.nick }}
</span>
<div class="sam-flex-grow"></div>
@if(isSelected) {
<lib-icon-button
icon="star-fill"
title="Edit identity"
style="pointer-events: none; color: var(--bs-pink)"
></lib-icon-button>
}
<div class="buttons sam-flex-row gap-h">
@if(!isSelected) {
<lib-icon-button
icon="star-fill"
title="Select identity"
(click)="
onClickSwitchIdentity(identity.id, $event);
toast.show('Identity changed')
"
></lib-icon-button>
}
</div>
<lib-icon-button
icon="arrow-right"
title="Edit identity"
style="pointer-events: none"
></lib-icon-button>
</div>
}
<lib-toast #toast></lib-toast>

View File

@@ -0,0 +1,68 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
padding-left: var(--size);
padding-right: var(--size);
.custom-header {
padding-top: var(--size);
padding-bottom: var(--size);
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto;
align-items: center;
background: var(--background);
.button {
grid-column-start: 1;
grid-column-end: 2;
grid-row-start: 1;
grid-row-end: 2;
justify-self: end;
}
.text {
grid-column-start: 1;
grid-column-end: 2;
grid-row-start: 1;
grid-row-end: 2;
font-size: 20px;
font-weight: 500;
justify-self: center;
height: 32px;
}
}
.identity {
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;
cursor: pointer;
.not-active {
//color: #525b6a;
opacity: 0.4;
}
&:hover {
background: var(--background-light-hover);
.buttons {
visibility: visible;
}
}
.buttons {
visibility: hidden;
}
}
}

View File

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

View File

@@ -0,0 +1,33 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import {
IconButtonComponent,
Identity_DECRYPTED,
StorageService,
ToastComponent,
} from '@common';
@Component({
selector: 'app-identities',
templateUrl: './identities.component.html',
styleUrl: './identities.component.scss',
imports: [IconButtonComponent, ToastComponent],
})
export class IdentitiesComponent {
readonly storage = inject(StorageService);
readonly #router = inject(Router);
onClickNewIdentity() {
this.#router.navigateByUrl('/new-identity');
}
onClickEditIdentity(identity: Identity_DECRYPTED) {
this.#router.navigateByUrl(`/edit-identity/${identity.id}/home`);
}
async onClickSwitchIdentity(identityId: string, event: MouseEvent) {
event.stopPropagation();
await this.storage.switchIdentity(identityId);
}
}

View File

@@ -0,0 +1,56 @@
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
<div class="sam-text-header">
<span>You</span>
</div>
<div class="vertically-centered">
<div class="sam-flex-column center">
<div class="sam-flex-column gap center">
<div class="picture-frame" [class.padding]="!loadedData.profile?.image">
<img
[src]="
!loadedData.profile?.image
? 'person-fill.svg'
: loadedData.profile?.image
"
alt=""
/>
</div>
<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->
<span class="name" (click)="onClickShowDetails()">
{{ selectedIdentity?.nick }}
</span>
@if(loadedData.profile) {
<div class="sam-flex-row gap-h">
@if(loadedData.validating) {
<i class="bi bi-circle color-activity"></i>
} @else { @if(loadedData.nip05isValidated) {
<i class="bi bi-patch-check sam-color-primary"></i>
} @else {
<i class="bi bi-exclamation-octagon-fill sam-color-danger"></i>
} }
<span class="sam-color-primary">{{
loadedData.profile.nip05 | visualNip05
}}</span>
</div>
} @else {
<span>&nbsp;</span>
}
<lib-pubkey
[value]="selectedIdentityNpub ?? 'na'"
[first]="14"
[last]="8"
(click)="
copyToClipboard(selectedIdentityNpub);
toast.show('Copied to clipboard')
"
></lib-pubkey>
</div>
</div>
</div>
<lib-toast #toast></lib-toast>

View File

@@ -0,0 +1,41 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
.vertically-centered {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.name {
font-size: 20px;
font-weight: 500;
cursor: pointer;
max-width: 343px;
overflow-x: hidden;
text-overflow: ellipsis;
}
.picture-frame {
height: 120px;
width: 120px;
border: 2px solid white;
border-radius: 100%;
&.padding {
padding: 12px;
}
img {
border-radius: 100%;
width: 100%;
height: 100%;
}
}
.color-activity {
color: var(--bs-border-color);
}
}

View File

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

View File

@@ -0,0 +1,117 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import {
Identity_DECRYPTED,
NostrHelper,
PubkeyComponent,
StorageService,
ToastComponent,
VisualNip05Pipe,
} from '@common';
import NDK, { NDKUserProfile } from '@nostr-dev-kit/ndk';
interface LoadedData {
profile: NDKUserProfile | undefined;
nip05: string | undefined;
nip05isValidated: boolean | undefined;
validating: boolean;
}
@Component({
selector: 'app-identity',
imports: [PubkeyComponent, VisualNip05Pipe, ToastComponent],
templateUrl: './identity.component.html',
styleUrl: './identity.component.scss',
})
export class IdentityComponent implements OnInit {
selectedIdentity: Identity_DECRYPTED | undefined;
selectedIdentityNpub: string | undefined;
loadedData: LoadedData = {
profile: undefined,
nip05: undefined,
nip05isValidated: undefined,
validating: false,
};
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
ngOnInit(): void {
this.#loadData();
}
copyToClipboard(pubkey: string | undefined) {
if (!pubkey) {
return;
}
navigator.clipboard.writeText(pubkey);
}
onClickShowDetails() {
if (!this.selectedIdentity) {
return;
}
this.#router.navigateByUrl(
`/edit-identity/${this.selectedIdentity.id}/home`
);
}
async #loadData() {
try {
const selectedIdentityId =
this.#storage.getBrowserSessionHandler().browserSessionData
?.selectedIdentityId ?? null;
const identity = this.#storage
.getBrowserSessionHandler()
.browserSessionData?.identities.find(
(x) => x.id === selectedIdentityId
);
if (!identity) {
return;
}
this.selectedIdentity = identity;
const pubkey = NostrHelper.pubkeyFromPrivkey(identity.privkey);
this.selectedIdentityNpub = NostrHelper.pubkey2npub(pubkey);
// Determine the user's relays to check for his profile.
const relays =
this.#storage
.getBrowserSessionHandler()
.browserSessionData?.relays.filter(
(x) => x.identityId === identity.id
) ?? [];
if (relays.length === 0) {
return;
}
const relevantRelays = relays.filter((x) => x.write).map((x) => x.url);
// Fetch the user's profile.
const ndk = new NDK({
explicitRelayUrls: relevantRelays,
});
await ndk.connect();
const user = ndk.getUser({
pubkey: NostrHelper.pubkeyFromPrivkey(identity.privkey),
//relayUrls: relevantRelays,
});
this.loadedData.profile = (await user.fetchProfile()) ?? undefined;
if (this.loadedData.profile?.nip05) {
this.loadedData.validating = true;
this.loadedData.nip05isValidated =
(await user.validateNip05(this.loadedData.profile.nip05)) ??
undefined;
this.loadedData.validating = false;
}
} catch (error) {
console.error(error);
// TODO
}
}
}

View File

@@ -0,0 +1,37 @@
<div class="sam-text-header">
<span> Gooti </span>
</div>
<span>Version {{ version }}</span>
<br />
<span> Website </span>
<a href="https://getgooti.com" target="_blank">www.getgooti.com</a>
<br />
<span> Source code</span>
<a
href="https://github.com/sam-hayes-org/gooti-extension"
target="_blank"
>
github.com/sam-hayes-org/gooti-extension
</a>
<div class="sam-flex-grow"></div>
<div class="sam-card sam-mb" style="align-items: center">
<span>
Made with <i class="bi bi-heart-fill" style="color: red"></i> by
<a href="https://sam-hayes.org" target="_blank">Sam Hayes</a>
</span>
<lib-pubkey
class="sam-mt-h"
value="npub1tgyjshvelwj73t3jy0n3xllgt03elkapfl3k3n0x2wkunegkgrwssfp0u4"
(click)="toast.show('Copied to clipboard')"
></lib-pubkey>
</div>
<lib-toast #toast [bottom]="188"></lib-toast>

View File

@@ -0,0 +1,9 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
overflow-y: auto;
padding-left: var(--size);
padding-right: var(--size);
}

View File

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

View File

@@ -0,0 +1,13 @@
import { Component } from '@angular/core';
import { PubkeyComponent, ToastComponent } from '@common';
import packageJson from '../../../../../../../package.json';
@Component({
selector: 'app-info',
imports: [PubkeyComponent, ToastComponent],
templateUrl: './info.component.html',
styleUrl: './info.component.scss',
})
export class InfoComponent {
version = packageJson.custom.chrome.version;
}

View File

@@ -0,0 +1,45 @@
<div class="sam-text-header">
<span> Settings </span>
</div>
<span>SYNC: {{ syncFlow }}</span>
<button class="btn btn-primary" (click)="onClickExportVault()">
Export Vault
</button>
<button
class="btn btn-primary"
(click)="
confirm.show(
'Do you really want to import a vault? All existing data will be overwritten.',
onImportVault.bind(fileInput)
)
"
>
Import Vault
</button>
<div class="sam-flex-grow"></div>
<button
class="btn btn-danger"
(click)="
confirm.show(
'Do you really want to delete your vault with all identities?',
onDeleteVault.bind(this)
)
"
>
Delete Vault
</button>
<lib-confirm #confirm> </lib-confirm>
<input
#fileInput
class="file-input"
type="file"
(change)="onImportFileChange($event)"
accept=".json"
/>

View File

@@ -0,0 +1,14 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
row-gap: var(--size);
overflow-y: auto;
padding-left: var(--size);
padding-right: var(--size);
.file-input {
position: absolute;
visibility: hidden;
}
}

View File

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

View File

@@ -0,0 +1,97 @@
import { Component, inject, OnInit } from '@angular/core';
import {
BrowserSyncData,
BrowserSyncFlow,
ConfirmComponent,
DateHelper,
StorageService,
} from '@common';
import { StartupService } from '../../../services/startup/startup.service';
@Component({
selector: 'app-settings',
imports: [ConfirmComponent],
templateUrl: './settings.component.html',
styleUrl: './settings.component.scss',
})
export class SettingsComponent implements OnInit {
syncFlow: string | undefined;
readonly #storage = inject(StorageService);
readonly #startup = inject(StartupService);
ngOnInit(): void {
const vault = JSON.stringify(
this.#storage.getBrowserSyncHandler().browserSyncData
);
console.log(vault.length / 1024 + ' KB');
switch (this.#storage.getGootiMetaHandler().gootiMetaData?.syncFlow) {
case BrowserSyncFlow.NO_SYNC:
this.syncFlow = 'Off';
break;
case BrowserSyncFlow.BROWSER_SYNC:
this.syncFlow = 'Google Chrome';
break;
default:
break;
}
}
async onDeleteVault() {
try {
await this.#storage.deleteVault();
this.#startup.startOver();
} catch (error) {
console.log(error);
// TODO
}
}
onImportVault() {
(this as unknown as HTMLInputElement).click();
}
async onImportFileChange(event: Event) {
try {
const element = event.currentTarget as HTMLInputElement;
const file = element.files !== null ? element.files[0] : undefined;
if (!file) {
return;
}
const text = await file.text();
const vault = JSON.parse(text) as BrowserSyncData;
await this.#storage.deleteVault(true);
await this.#storage.importVault(vault);
this.#storage.isInitialized = false;
this.#startup.startOver();
} catch (error) {
console.log(error);
// TODO
}
}
async onClickExportVault() {
const jsonVault = this.#storage.exportVault();
const dateTimeString = DateHelper.dateToISOLikeButLocal(new Date());
const fileName = `Gooti Chrome - Vault Export - ${dateTimeString}.json`;
this.#downloadJson(jsonVault, fileName);
}
#downloadJson(jsonString: string, fileName: string) {
const dataStr =
'data:text/json;charset=utf-8,' + encodeURIComponent(jsonString);
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute('href', dataStr);
downloadAnchorNode.setAttribute('download', fileName);
document.body.appendChild(downloadAnchorNode);
downloadAnchorNode.click();
downloadAnchorNode.remove();
}
}

View File

@@ -0,0 +1,85 @@
<div class="sam-text-header">
<span>New Identity</span>
</div>
<div class="content">
<input
id="nickElement"
type="text"
placeholder="Nick"
class="form-control form-control-lg"
style="font-size: 1rem"
[(ngModel)]="identity.nick"
autocomplete="off"
(ngModelChange)="validateCanSave()"
/>
<div class="sam-mt input-group mb-3">
<input
id="privkeyInputElement"
#privkeyInputElement
type="password"
placeholder="Private Key (HEX or NSEC)"
class="form-control form-control-lg"
style="font-size: 1rem"
[(ngModel)]="identity.privkeyInput"
autocomplete="off"
(ngModelChange)="validateCanSave()"
/>
<button
class="btn btn-outline-secondary"
type="button"
(click)="toggleType(privkeyInputElement)"
>
<i
class="bi bi-eye"
[class.bi-eye]="privkeyInputElement.type === 'password'"
[class.bi-eye-slash]="privkeyInputElement.type === 'text'"
></i>
</button>
</div>
<button
class="sam-mt"
(click)="onClickGeneratePrivkey()"
type="button"
class="btn btn-link"
>
Generate private key
</button>
</div>
<div class="sam-footer-grid-2">
<button type="button" class="btn btn-secondary" (click)="navigateBack()">
Cancel
</button>
<button
[disabled]="!canSave"
type="button"
class="btn btn-primary"
(click)="onClickSave()"
>
Save
</button>
</div>
<!----------->
<!-- ALERT -->
<!----------->
@if(alertMessage) {
<div
style="
position: absolute;
bottom: 60px;
align-self: center;
margin-left: 16px;
margin-right: 16px;
"
>
<div class="alert alert-danger sam-flex-row gap" role="alert">
<i class="bi bi-exclamation-triangle"></i>
<span>{{ alertMessage }}</span>
</div>
</div>
}

View File

@@ -0,0 +1,13 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
.content {
padding-left: var(--size);
padding-right: var(--size);
flex-grow: 1;
display: flex;
flex-direction: column;
}
}

View File

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

View File

@@ -0,0 +1,88 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { AfterViewInit, Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { NavComponent, NostrHelper, StorageService } from '@common';
import { generateSecretKey } from 'nostr-tools';
import { bytesToHex } from '@noble/hashes/utils';
@Component({
selector: 'app-new-identity',
templateUrl: './new-identity.component.html',
styleUrl: './new-identity.component.scss',
imports: [FormsModule],
})
export class NewIdentityComponent
extends NavComponent
implements AfterViewInit
{
readonly identity = {
nick: '',
privkeyInput: '',
};
canSave = false;
alertMessage: any | undefined;
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
ngAfterViewInit(): void {
document.getElementById('nickElement')?.focus();
}
toggleType(element: HTMLInputElement) {
if (element.type === 'password') {
element.type = 'text';
} else {
element.type = 'password';
}
}
onClickGeneratePrivkey() {
const sk = generateSecretKey();
const privkey = bytesToHex(sk);
this.identity.privkeyInput = NostrHelper.privkey2nsec(privkey);
this.validateCanSave();
}
validateCanSave() {
if (!this.identity.nick || !this.identity.privkeyInput) {
this.canSave = false;
return;
}
try {
NostrHelper.getNostrPrivkeyObject(
this.identity.privkeyInput.toLocaleLowerCase()
);
this.canSave = true;
} catch (error) {
console.log(error);
this.canSave = false;
}
}
async onClickSave() {
if (!this.canSave) {
return;
}
if (!this.identity.nick || !this.identity.privkeyInput) {
return;
}
try {
await this.#storage.addIdentity({
nick: this.identity.nick,
privkeyString: this.identity.privkeyInput,
});
this.#router.navigateByUrl('/home/identities');
} catch (error: any) {
this.alertMessage = error?.message;
setTimeout(() => {
this.alertMessage = undefined;
}, 4500);
}
}
}

View File

@@ -0,0 +1,42 @@
<div class="sam-text-header">
<span>Gooti</span>
</div>
<div class="vertically-centered">
<div class="sam-flex-column center">
<div class="sam-flex-column gap" style="align-items: center">
<div class="logo-frame">
<img src="gooti.svg" height="120" width="120" alt=""/>
</div>
<button
type="button"
class="sam-mt-2 btn btn-primary"
(click)="router.navigateByUrl('/vault-create/new')"
>
<div class="sam-flex-row gap-h">
<i class="bi bi-plus-circle" style="height: 22px"></i>
<span>Create a new vault</span>
</div>
</button>
<span class="sam-text-muted">or</span>
<button
type="button"
class="btn btn-secondary"
(click)="fileInput.click()"
>
<span>Import a vault</span>
</button>
</div>
</div>
</div>
<input
#fileInput
class="file-input"
type="file"
(change)="onImportFileChange($event)"
accept=".json"
/>

View File

@@ -0,0 +1,17 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
.vertically-centered {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.file-input {
position: absolute;
visibility: hidden;
}
}

View File

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

View File

@@ -0,0 +1,36 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { BrowserSyncData, StorageService } from '@common';
import { StartupService } from '../../../services/startup/startup.service';
@Component({
selector: 'app-home',
imports: [],
templateUrl: './home.component.html',
styleUrl: './home.component.scss',
})
export class HomeComponent {
readonly router = inject(Router);
readonly #storage = inject(StorageService);
readonly #startup = inject(StartupService);
async onImportFileChange(event: Event) {
try {
const element = event.currentTarget as HTMLInputElement;
const file = element.files !== null ? element.files[0] : undefined;
if (!file) {
return;
}
const text = await file.text();
const vault = JSON.parse(text) as BrowserSyncData;
console.log(vault);
await this.#storage.importVault(vault);
this.#startup.startOver();
} catch (error) {
console.log(error);
// TODO
}
}
}

View File

@@ -0,0 +1,48 @@
<div class="sam-text-header">
<span>Gooti</span>
</div>
<div class="content">
<div class="sam-flex-column gap" style="align-items: center">
<div class="logo-frame">
<img src="gooti.svg" height="120" width="120" alt=""/>
</div>
<span class="sam-mt-2"> Please define a password for your vault. </span>
<small class="sam-text-muted">
Sensitive data is encypted before it is stored.
</small>
<div class="sam-mt input-group">
<input
#passwordInputElement
type="password"
class="form-control form-control-lg"
style="font-size: 1rem"
placeholder="vault password"
[(ngModel)]="password"
/>
<button
class="btn btn-outline-secondary"
type="button"
(click)="toggleType(passwordInputElement)"
>
<i
class="bi bi-eye"
[class.bi-eye]="passwordInputElement.type === 'password'"
[class.bi-eye-slash]="passwordInputElement.type === 'text'"
></i>
</button>
</div>
<button
[disabled]="!password || password.length < 4"
type="button"
class="sam-mt btn btn-primary"
(click)="createVault()"
>
Create vault
</button>
</div>
</div>

View File

@@ -0,0 +1,48 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
color-scheme: dark;
.custom-header {
padding-top: var(--size);
padding-bottom: var(--size);
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto;
align-items: center;
.back {
grid-column-start: 1;
grid-column-end: 2;
grid-row-start: 1;
grid-row-end: 2;
justify-self: start;
padding-left: var(--size);
}
.text {
grid-column-start: 1;
grid-column-end: 2;
grid-row-start: 1;
grid-row-end: 2;
font-size: 20px;
font-weight: 500;
justify-self: center;
}
}
.content {
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
padding: 0 var(--size) var(--size) var(--size);
}
.logo-frame {
border: 2px solid #0d6efd;
border-radius: 100%;
}
}

View File

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

View File

@@ -0,0 +1,34 @@
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { NavComponent, StorageService } from '@common';
@Component({
selector: 'app-new',
imports: [FormsModule],
templateUrl: './new.component.html',
styleUrl: './new.component.scss',
})
export class NewComponent extends NavComponent {
password = '';
readonly #router = inject(Router);
readonly #storage = inject(StorageService);
toggleType(element: HTMLInputElement) {
if (element.type === 'password') {
element.type = 'text';
} else {
element.type = 'password';
}
}
async createVault() {
if (!this.password) {
return;
}
await this.#storage.createNewVault(this.password);
this.#router.navigateByUrl('/home/identities');
}
}

View File

@@ -0,0 +1 @@
<router-outlet></router-outlet>

View File

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

View File

@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-vault-create',
imports: [RouterOutlet],
templateUrl: './vault-create.component.html',
styleUrl: './vault-create.component.scss'
})
export class VaultCreateComponent {
}

View File

@@ -0,0 +1,72 @@
<div class="sam-text-header">
<span class="brand">Gooti</span>
</div>
<div class="content-login-vault">
<div class="sam-flex-column gap" style="align-items: center">
<div class="logo-frame">
<img src="gooti.svg" height="120" width="120" alt=""/>
</div>
<div class="sam-mt-2 input-group">
<input
#passwordInputElement
type="password"
class="form-control"
placeholder="vault password"
[(ngModel)]="loginPassword"
/>
<button
class="btn btn-outline-secondary"
type="button"
(click)="toggleType(passwordInputElement)"
>
<i
class="bi bi-eye"
[class.bi-eye]="passwordInputElement.type === 'password'"
[class.bi-eye-slash]="passwordInputElement.type === 'text'"
></i>
</button>
</div>
<button
[disabled]="!loginPassword"
type="button"
class="sam-mt btn btn-primary"
(click)="loginVault()"
>
<div class="sam-flex-row gap-h">
<i class="bi bi-box-arrow-in-right"></i>
<span>Sign in</span>
</div>
</button>
<button
class="sam-mt"
(click)="
confirm.show(
'Do you really want to delete your vault? All existing data will be lost.',
onClickDeleteVault.bind(this)
)
"
type="button"
class="btn btn-link"
>
Delete Vault
</button>
</div>
</div>
<!----------->
<!-- ALERT -->
<!----------->
@if(showInvalidPasswordAlert) {
<div style="position: absolute; bottom: 0; align-self: center">
<div class="alert alert-danger sam-flex-row gap" role="alert">
<i class="bi bi-exclamation-triangle"></i>
<span>Invalid password</span>
</div>
</div>
}
<lib-confirm #confirm> </lib-confirm>

View File

@@ -0,0 +1,19 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
justify-items: center;
.logo-frame {
border: 2px solid #0d6efd;
border-radius: 100%;
}
.content-login-vault {
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
padding: 0 var(--size) var(--size) var(--size);
}
}

View File

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

View File

@@ -0,0 +1,55 @@
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ConfirmComponent, StorageService } from '@common';
import { StartupService } from '../../services/startup/startup.service';
@Component({
selector: 'app-vault-login',
templateUrl: './vault-login.component.html',
styleUrl: './vault-login.component.scss',
imports: [FormsModule, ConfirmComponent],
})
export class VaultLoginComponent {
loginPassword = '';
showInvalidPasswordAlert = false;
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
readonly #startup = inject(StartupService);
toggleType(element: HTMLInputElement) {
if (element.type === 'password') {
element.type = 'text';
} else {
element.type = 'password';
}
}
async loginVault() {
if (!this.loginPassword) {
return;
}
try {
await this.#storage.unlockVault(this.loginPassword);
this.#router.navigateByUrl('/home/identities');
} catch (error) {
this.showInvalidPasswordAlert = true;
console.log(error);
window.setTimeout(() => {
this.showInvalidPasswordAlert = false;
}, 2000);
}
}
async onClickDeleteVault() {
try {
await this.#storage.deleteVault();
this.#startup.startOver();
} catch (error) {
console.log(error);
// TODO
}
}
}

View File

@@ -0,0 +1,44 @@
<div class="sam-text-header sam-mb-2">
<span>Gooti Setup - Sync Preference</span>
</div>
<span class="sam-text-muted sam-text-md sam-text-align-center2">
Gooti always encrypts sensitive data like private keys and site permissions
independent of the chosen sync mode.
</span>
<span class="sam-mt sam-text-lg">Sync : Google Chrome</span>
<span class="sam-text-muted sam-text-md sam-text-align-center2">
Your encrypted data is synced between Google Chrome browser instances. You
need to be signed in with your Google account.
</span>
<button
type="button"
class="sam-mt btn btn-primary"
(click)="onClickSync(true)"
>
<span> Sync ON</span>
</button>
<span class="sam-mt-2 sam-text-lg">Offline</span>
<span class="sam-text-muted sam-text-md">
Your encrypted data is never uploaded to any servers. It remains in your local
browser instance.
</span>
<button
type="button"
class="sam-mt sam-mb-2 btn btn-secondary"
(click)="onClickSync(false)"
>
<span> Sync OFF</span>
</button>
<div class="sam-flex-grow"></div>
<span class="sam-text-muted sam-text-md sam-mb">
Your preference can later be changed at any time.
</span>

View File

@@ -0,0 +1,8 @@
:host {
height: 100%;
display: flex;
flex-direction: column;
padding-left: var(--size);
padding-right: var(--size);
}

View File

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

View File

@@ -0,0 +1,41 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { BrowserSyncFlow, StorageService } from '@common';
@Component({
selector: 'app-welcome',
imports: [],
templateUrl: './welcome.component.html',
styleUrl: './welcome.component.scss',
})
export class WelcomeComponent {
readonly router = inject(Router);
readonly #storage = inject(StorageService);
async onClickSync(enabled: boolean) {
const flow: BrowserSyncFlow = enabled
? BrowserSyncFlow.BROWSER_SYNC
: BrowserSyncFlow.NO_SYNC;
await this.#storage.enableBrowserSyncFlow(flow);
// In case the user has selected the BROWSER_SYNC flow,
// we have to check if there is sync data available (e.g. from
// another browser instance).
// If so, navigate to /vault-login, otherwise to /vault-create/home.
if (flow === BrowserSyncFlow.BROWSER_SYNC) {
const browserSyncData =
await this.#storage.loadAndMigrateBrowserSyncData();
if (
typeof browserSyncData !== 'undefined' &&
Object.keys(browserSyncData).length > 0
) {
await this.router.navigateByUrl('/vault-login');
return;
}
}
await this.router.navigateByUrl('/vault-create/home');
}
}

View File

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

View File

@@ -0,0 +1,88 @@
import { inject, Injectable } from '@angular/core';
import { LoggerService, StorageService, StorageServiceConfig } from '@common';
import { ChromeSessionHandler } from '../../common/data/chrome-session-handler';
import { ChromeSyncYesHandler } from '../../common/data/chrome-sync-yes-handler';
import { ChromeSyncNoHandler } from '../../common/data/chrome-sync-no-handler';
import { ChromeMetaHandler } from '../../common/data/chrome-meta-handler';
import { Router } from '@angular/router';
@Injectable({
providedIn: 'root',
})
export class StartupService {
readonly #logger = inject(LoggerService);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
async startOver() {
const storageConfig: StorageServiceConfig = {
browserSessionHandler: new ChromeSessionHandler(),
browserSyncYesHandler: new ChromeSyncYesHandler(),
browserSyncNoHandler: new ChromeSyncNoHandler(),
gootiMetaHandler: new ChromeMetaHandler(),
};
this.#storage.initialize(storageConfig);
// Step 0:
storageConfig.browserSyncNoHandler.setIgnoreProperties(
storageConfig.gootiMetaHandler.metaProperties
);
// Step 1: Load the gooti's user settings
const gootiMetaData = await this.#storage.loadGootiMetaData();
if (typeof gootiMetaData?.syncFlow === 'undefined') {
// Very first run. The user has not set up Gooti yet.
this.#router.navigateByUrl('/welcome');
return;
}
this.#storage.enableBrowserSyncFlow(gootiMetaData.syncFlow);
// Load the browser session data.
const browserSessionData = await this.#storage.loadBrowserSessionData();
if (!browserSessionData) {
await this.#initializeFlow_A();
} else {
await this.#initializeFlow_B();
}
}
async #initializeFlow_A() {
// Starting with NO browser session data available.
//
// This could be because the browser sync data was
// never loaded before OR it was attempted, but
// there is no browser sync data.
this.#logger.log('No browser session data available.');
// Check if there is NO browser sync data.
const browserSyncData = await this.#storage.loadAndMigrateBrowserSyncData();
if (browserSyncData) {
// There is browser sync data. Route to the VAULT LOGIN to enable the session.
this.#router.navigateByUrl('/vault-login');
} else {
// There is NO browser sync data. Route to the VAULT CREATION to enable the session.
this.#router.navigateByUrl('/vault-create/home');
}
}
async #initializeFlow_B() {
// Stating with browser session data available. The user has already unlocked the vault before.
// Route to VAULT HOME.
this.#logger.log('Browser session data is available.');
// Also load the browser sync data. This is needed, if the user adds or deletes anything.
await this.#storage.loadAndMigrateBrowserSyncData();
const selectedIdentityId =
this.#storage.getBrowserSessionHandler().browserSessionData
?.selectedIdentityId;
this.#router.navigateByUrl(
`/home/${selectedIdentityId ? 'identity' : 'identities'}`
);
}
}

View File

@@ -0,0 +1,283 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSessionData,
BrowserSyncData,
BrowserSyncFlow,
CryptoHelper,
GootiMetaData,
Identity_DECRYPTED,
Nip07Method,
Nip07MethodPolicy,
NostrHelper,
Permission_DECRYPTED,
Permission_ENCRYPTED,
} from '@common';
import { ChromeMetaHandler } from './app/common/data/chrome-meta-handler';
import { Event, EventTemplate, finalizeEvent, nip04 } from 'nostr-tools';
export const debug = function (message: any) {
const dateString = new Date().toISOString();
console.log(`[Gooti - ${dateString}]: ${JSON.stringify(message)}`);
};
export type PromptResponse =
| 'reject'
| 'reject-once'
| 'approve'
| 'approve-once';
export interface PromptResponseMessage {
id: string;
response: PromptResponse;
}
export interface BackgroundRequestMessage {
method: Nip07Method;
params: any;
host: string;
}
export const getBrowserSessionData = async function (): Promise<
BrowserSessionData | undefined
> {
const browserSessionData = await chrome.storage.session.get(null);
if (Object.keys(browserSessionData).length === 0) {
return undefined;
}
return browserSessionData as BrowserSessionData;
};
export const getBrowserSyncData = async function (): Promise<
BrowserSyncData | undefined
> {
const gootiMetaHandler = new ChromeMetaHandler();
const gootiMetaData =
(await gootiMetaHandler.loadFullData()) as GootiMetaData;
let browserSyncData: BrowserSyncData | undefined;
if (gootiMetaData.syncFlow === BrowserSyncFlow.NO_SYNC) {
browserSyncData = (await chrome.storage.local.get(null)) as BrowserSyncData;
} else if (gootiMetaData.syncFlow === BrowserSyncFlow.BROWSER_SYNC) {
browserSyncData = (await chrome.storage.sync.get(null)) as BrowserSyncData;
}
return browserSyncData;
};
export const savePermissionsToBrowserSyncStorage = async function (
permissions: Permission_ENCRYPTED[]
): Promise<void> {
const gootiMetaHandler = new ChromeMetaHandler();
const gootiMetaData =
(await gootiMetaHandler.loadFullData()) as GootiMetaData;
if (gootiMetaData.syncFlow === BrowserSyncFlow.NO_SYNC) {
await chrome.storage.local.set({ permissions });
} else if (gootiMetaData.syncFlow === BrowserSyncFlow.BROWSER_SYNC) {
await chrome.storage.sync.set({ permissions });
}
};
export const checkPermissions = function (
browserSessionData: BrowserSessionData,
identity: Identity_DECRYPTED,
host: string,
method: Nip07Method,
params: any
): boolean | undefined {
const permissions = browserSessionData.permissions.filter(
(x) =>
x.identityId === identity.id && x.host === host && x.method === method
);
if (permissions.length === 0) {
return undefined;
}
if (method === 'getPublicKey') {
// No evaluation of params required.
return permissions.every((x) => x.methodPolicy === 'allow');
}
if (method === 'getRelays') {
// No evaluation of params required.
return permissions.every((x) => x.methodPolicy === 'allow');
}
if (method === 'signEvent') {
// Evaluate params.
const eventTemplate = params as EventTemplate;
if (
permissions.find(
(x) => x.methodPolicy === 'allow' && typeof x.kind === 'undefined'
)
) {
return true;
}
if (
permissions.some(
(x) => x.methodPolicy === 'allow' && x.kind === eventTemplate.kind
)
) {
return true;
}
if (
permissions.some(
(x) => x.methodPolicy === 'deny' && x.kind === eventTemplate.kind
)
) {
return false;
}
return undefined;
}
if (method === 'nip04.encrypt') {
// No evaluation of params required.
return permissions.every((x) => x.methodPolicy === 'allow');
}
if (method === 'nip04.decrypt') {
// No evaluation of params required.
return permissions.every((x) => x.methodPolicy === 'allow');
}
return undefined;
};
export const storePermission = async function (
browserSessionData: BrowserSessionData,
identity: Identity_DECRYPTED,
host: string,
method: Nip07Method,
methodPolicy: Nip07MethodPolicy,
kind?: number
) {
const browserSyncData = await getBrowserSyncData();
if (!browserSyncData) {
throw new Error(`Could not retrieve sync data`);
}
const permission: Permission_DECRYPTED = {
id: crypto.randomUUID(),
identityId: identity.id,
host,
method,
methodPolicy,
kind,
};
// Store session data
await chrome.storage.session.set({
permissions: [...browserSessionData.permissions, permission],
});
// Encrypt permission to store in sync storage (depending on sync flow).
const encryptedPermission = await encryptPermission(
permission,
browserSessionData.iv,
browserSessionData.vaultPassword as string
);
await savePermissionsToBrowserSyncStorage([
...browserSyncData.permissions,
encryptedPermission,
]);
};
export const getPosition = async function (width: number, height: number) {
let left = 0;
let top = 0;
try {
const lastFocused = await chrome.windows.getLastFocused();
if (
lastFocused &&
lastFocused.top !== undefined &&
lastFocused.left !== undefined &&
lastFocused.width !== undefined &&
lastFocused.height !== undefined
) {
// Position window in the center of the lastFocused window
top = Math.round(lastFocused.top + (lastFocused.height - height) / 2);
left = Math.round(lastFocused.left + (lastFocused.width - width) / 2);
} else {
console.error('Last focused window properties are undefined.');
}
} catch (error) {
console.error('Error getting window position:', error);
}
return {
top,
left,
};
};
export const signEvent = function (
eventTemplate: EventTemplate,
privkey: string
): Event {
return finalizeEvent(eventTemplate, NostrHelper.hex2bytes(privkey));
};
export const nip04Encrypt = async function (
privkey: string,
peerPubkey: string,
plaintext: string
): Promise<string> {
return await nip04.encrypt(
NostrHelper.hex2bytes(privkey),
peerPubkey,
plaintext
);
};
export const nip04Decrypt = async function (
privkey: string,
peerPubkey: string,
ciphertext: string
): Promise<string> {
return await nip04.decrypt(
NostrHelper.hex2bytes(privkey),
peerPubkey,
ciphertext
);
};
const encryptPermission = async function (
permission: Permission_DECRYPTED,
iv: string,
password: string
): Promise<Permission_ENCRYPTED> {
const encryptedPermission: Permission_ENCRYPTED = {
id: await encrypt(permission.id, iv, password),
identityId: await encrypt(permission.identityId, iv, password),
host: await encrypt(permission.host, iv, password),
method: await encrypt(permission.method, iv, password),
methodPolicy: await encrypt(permission.methodPolicy, iv, password),
};
if (typeof permission.kind !== 'undefined') {
encryptedPermission.kind = await encrypt(
permission.kind.toString(),
iv,
password
);
}
return encryptedPermission;
};
const encrypt = async function (
value: string,
iv: string,
password: string
): Promise<string> {
return await CryptoHelper.encrypt(value, iv, password);
};

View File

@@ -0,0 +1,148 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NostrHelper } from '@common';
import {
BackgroundRequestMessage,
checkPermissions,
debug,
getBrowserSessionData,
getPosition,
nip04Decrypt,
nip04Encrypt,
PromptResponse,
PromptResponseMessage,
signEvent,
storePermission,
} from './background-common';
import browser from 'webextension-polyfill';
import { Buffer } from 'buffer';
type Relays = Record<string, { read: boolean; write: boolean }>;
const openPrompts = new Map<
string,
{
resolve: (response: PromptResponse) => void;
reject: (reason?: any) => void;
}
>();
browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
debug('Message received');
const request = message as BackgroundRequestMessage | PromptResponseMessage;
debug(request);
if ((request as PromptResponseMessage)?.id) {
// Handle prompt response
const promptResponse = request as PromptResponseMessage;
const openPrompt = openPrompts.get(promptResponse.id);
if (!openPrompt) {
throw new Error(
'Prompt response could not be matched to any previous request.'
);
}
openPrompt.resolve(promptResponse.response);
openPrompts.delete(promptResponse.id);
return;
}
const browserSessionData = await getBrowserSessionData();
if (!browserSessionData) {
throw new Error('Gooti vault not unlocked by the user.');
}
const currentIdentity = browserSessionData.identities.find(
(x) => x.id === browserSessionData.selectedIdentityId
);
if (!currentIdentity) {
throw new Error('No Nostr identity available at endpoint.');
}
const req = request as BackgroundRequestMessage;
const permissionState = checkPermissions(
browserSessionData,
currentIdentity,
req.host,
req.method,
req.params
);
if (permissionState === false) {
throw new Error('Permission denied');
}
if (permissionState === undefined) {
// Ask user for permission.
const width = 375;
const height = 600;
const { top, left } = await getPosition(width, height);
const base64Event = Buffer.from(
JSON.stringify(req.params ?? {}, undefined, 2)
).toString('base64');
const response = await new Promise<PromptResponse>((resolve, reject) => {
const id = crypto.randomUUID();
openPrompts.set(id, { resolve, reject });
browser.windows.create({
type: 'popup',
url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
height,
width,
top,
left,
});
});
debug(response);
if (response === 'approve' || response === 'reject') {
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
response === 'approve' ? 'allow' : 'deny',
req.params?.kind
);
}
if (['reject', 'reject-once'].includes(response)) {
throw new Error('Permission denied');
}
} else {
debug('Request allowed (via saved permission).');
}
const relays: Relays = {};
switch (req.method) {
case 'getPublicKey':
return NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
case 'signEvent':
return signEvent(req.params, currentIdentity.privkey);
case 'getRelays':
browserSessionData.relays.forEach((x) => {
relays[x.url] = { read: x.read, write: x.write };
});
return relays;
case 'nip04.encrypt':
return await nip04Encrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.plaintext
);
case 'nip04.decrypt':
return await nip04Decrypt(
currentIdentity.privkey,
req.params.peerPubkey,
req.params.ciphertext
);
default:
throw new Error(`Not supported request method '${req.method}'.`);
}
});

View File

@@ -0,0 +1,42 @@
import browser from 'webextension-polyfill';
import { BackgroundRequestMessage } from './background-common';
// Inject the script that will provide window.nostr
// The script needs to run before any other scripts from the real
// page run (and maybe check for window.nostr).
const script = document.createElement('script');
script.setAttribute('async', 'false');
script.setAttribute('type', 'text/javascript');
script.setAttribute('src', browser.runtime.getURL('gooti-extension.js'));
(document.head || document.documentElement).appendChild(script);
// listen for messages from that script
window.addEventListener('message', async (message) => {
// We will also receive our own messages, that we sent.
// We have to ignore them (they will not have a params field).
if (message.source !== window) return;
if (!message.data) return;
if (!message.data.params) return;
if (message.data.ext !== 'gooti') return;
// pass on to background
let response;
try {
const request: BackgroundRequestMessage = {
method: message.data.method,
params: message.data.params,
host: location.host,
};
response = await browser.runtime.sendMessage(request);
} catch (error) {
response = { error };
}
// return response
window.postMessage(
{ id: message.data.id, ext: 'gooti', response },
message.origin
);
});

View File

@@ -0,0 +1,128 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Event, EventTemplate } from 'nostr-tools';
import { Nip07Method } from '@common';
type Relays = Record<string, { read: boolean; write: boolean }>;
class Messenger {
#requests = new Map<
string,
{
resolve: (value: unknown) => void;
reject: (reason: any) => void;
}
>();
constructor() {
window.addEventListener('message', this.#handleCallResponse.bind(this));
}
async request(method: Nip07Method, params: any): Promise<any> {
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
this.#requests.set(id, { resolve, reject });
window.postMessage(
{
id,
ext: 'gooti',
method,
params,
},
'*'
);
});
}
#handleCallResponse(message: MessageEvent) {
// We also will receive our own messages, that we sent.
// We have to ignore them (they will not have a response field).
if (
!message.data ||
message.data.response === null ||
message.data.response === undefined ||
message.data.ext !== 'gooti' ||
!this.#requests.has(message.data.id)
) {
return;
}
if (message.data.response.error) {
this.#requests.get(message.data.id)?.reject(message.data.response.error);
} else {
this.#requests.get(message.data.id)?.resolve(message.data.response);
}
this.#requests.delete(message.data.id);
}
}
const nostr = {
messenger: new Messenger(),
async getPublicKey(): Promise<string> {
debug('getPublicKey received');
const pubkey = await this.messenger.request('getPublicKey', {});
debug(`getPublicKey response:`);
debug(pubkey);
return pubkey;
},
async signEvent(event: EventTemplate): Promise<Event> {
debug('signEvent received');
const signedEvent = await this.messenger.request('signEvent', event);
debug('signEvent response:');
debug(signedEvent);
return signedEvent;
},
async getRelays(): Promise<Relays> {
debug('getRelays received');
const relays = (await this.messenger.request('getRelays', {})) as Relays;
debug('getRelays response:');
debug(relays);
return relays;
},
nip04: {
that: this,
async encrypt(peerPubkey: string, plaintext: string): Promise<string> {
debug('nip04.encrypt received');
const ciphertext = (await nostr.messenger.request('nip04.encrypt', {
peerPubkey,
plaintext,
})) as string;
debug('nip04.encrypt response:');
debug(ciphertext);
return ciphertext;
},
async decrypt(peerPubkey: string, ciphertext: string): Promise<string> {
debug('nip04.decrypt received');
const plaintext = (await nostr.messenger.request('nip04.decrypt', {
peerPubkey,
ciphertext,
})) as string;
debug('nip04.decrypt response:');
debug(plaintext);
return plaintext;
},
},
// nip44: {
// async encrypt(peer, plaintext) {
// return window.nostr._call('nip44.encrypt', { peer, plaintext });
// },
// async decrypt(peer, ciphertext) {
// return window.nostr._call('nip44.decrypt', { peer, ciphertext });
// },
// },
};
window.nostr = nostr as any;
const debug = function (value: any) {
console.log(JSON.stringify(value));
};

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<title>Gooti</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- <link rel="icon" type="image/x-icon" href="favicon.ico"> -->
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,14 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
import './app/common/extensions/array';
bootstrapApplication(AppComponent, appConfig).catch((err) =>
console.error(err)
);
// declare global {
// interface Window {
// nostr: any;
// }
// }

View File

@@ -0,0 +1,167 @@
import browser from 'webextension-polyfill';
import { Buffer } from 'buffer';
import { Nip07Method } from '@common';
import { PromptResponse, PromptResponseMessage } from './background-common';
const params = new URLSearchParams(location.search);
const id = params.get('id') as string;
const method = params.get('method') as Nip07Method;
const host = params.get('host') as string;
const nick = params.get('nick') as string;
const event = Buffer.from(params.get('event') as string, 'base64').toString();
let title = '';
switch (method) {
case 'getPublicKey':
title = 'Get Public Key';
break;
case 'signEvent':
title = 'Sign Event';
break;
case 'nip04.encrypt':
title = 'Encrypt';
break;
case 'nip04.decrypt':
title = 'Decrypt';
break;
case 'getRelays':
title = 'Get Relays';
break;
default:
break;
}
const titleSpanElement = document.getElementById('titleSpan');
if (titleSpanElement) {
titleSpanElement.innerText = title;
}
Array.from(document.getElementsByClassName('nick-INSERT')).forEach(
(element) => {
(element as HTMLElement).innerText = nick;
}
);
Array.from(document.getElementsByClassName('host-INSERT')).forEach(
(element) => {
(element as HTMLElement).innerText = host;
}
);
const kindSpanElement = document.getElementById('kindSpan');
if (kindSpanElement) {
kindSpanElement.innerText = JSON.parse(event).kind;
}
const cardGetPublicKeyElement = document.getElementById('cardGetPublicKey');
if (cardGetPublicKeyElement) {
if (method === 'getPublicKey') {
// Do nothing.
} else {
cardGetPublicKeyElement.style.display = 'none';
}
}
const cardGetRelaysElement = document.getElementById('cardGetRelays');
if (cardGetRelaysElement) {
if (method === 'getRelays') {
// Do nothing.
} else {
cardGetRelaysElement.style.display = 'none';
}
}
const cardSignEventElement = document.getElementById('cardSignEvent');
const card2SignEventElement = document.getElementById('card2SignEvent');
if (cardSignEventElement && card2SignEventElement) {
if (method === 'signEvent') {
const card2SignEvent_jsonElement = document.getElementById(
'card2SignEvent_json'
);
if (card2SignEvent_jsonElement) {
card2SignEvent_jsonElement.innerText = event;
}
} else {
cardSignEventElement.style.display = 'none';
card2SignEventElement.style.display = 'none';
}
}
const cardNip04EncryptElement = document.getElementById('cardNip04Encrypt');
const card2Nip04EncryptElement = document.getElementById('card2Nip04Encrypt');
if (cardNip04EncryptElement && card2Nip04EncryptElement) {
if (method === 'nip04.encrypt') {
const card2Nip04Encrypt_textElement = document.getElementById(
'card2Nip04Encrypt_text'
);
if (card2Nip04Encrypt_textElement) {
const eventObject: { peerPubkey: string; plaintext: string } =
JSON.parse(event);
card2Nip04Encrypt_textElement.innerText = eventObject.plaintext;
}
} else {
cardNip04EncryptElement.style.display = 'none';
card2Nip04EncryptElement.style.display = 'none';
}
}
const cardNip04DecryptElement = document.getElementById('cardNip04Decrypt');
const card2Nip04DecryptElement = document.getElementById('card2Nip04Decrypt');
if (cardNip04DecryptElement && card2Nip04DecryptElement) {
if (method === 'nip04.decrypt') {
const card2Nip04Decrypt_textElement = document.getElementById(
'card2Nip04Decrypt_text'
);
if (card2Nip04Decrypt_textElement) {
const eventObject: { peerPubkey: string; ciphertext: string } =
JSON.parse(event);
card2Nip04Decrypt_textElement.innerText = eventObject.ciphertext;
}
} else {
cardNip04DecryptElement.style.display = 'none';
card2Nip04DecryptElement.style.display = 'none';
}
}
//
// Functions
//
function deliver(response: PromptResponse) {
const message: PromptResponseMessage = {
id,
response,
};
browser.runtime.sendMessage(message);
window.close();
}
document.addEventListener('DOMContentLoaded', function () {
const rejectJustOnceButton = document.getElementById('rejectJustOnceButton');
rejectJustOnceButton?.addEventListener('click', () => {
deliver('reject-once');
});
const rejectButton = document.getElementById('rejectButton');
rejectButton?.addEventListener('click', () => {
deliver('reject');
});
const approveJustOnceButton = document.getElementById(
'approveJustOnceButton'
);
approveJustOnceButton?.addEventListener('click', () => {
deliver('approve-once');
});
const approveButton = document.getElementById('approveButton');
approveButton?.addEventListener('click', () => {
deliver('approve');
});
});

View File

@@ -0,0 +1,20 @@
@use "sass:meta";
@include meta.load-css("../../../node_modules/bootstrap/scss/bootstrap");
@include meta.load-css(
"../../../node_modules/bootstrap-icons/font/bootstrap-icons.min.css"
);
// Load the common styles
@include meta.load-css("../../common/src/lib/styles/styles.scss");
body {
height: 600px;
width: 375px;
color: #ffffff;
font-size: 16px;
background: var(--background);
margin: 0;
}

Some files were not shown because too many files have changed in this diff Show More