Migrate Angular UI from portmaster-ui to desktop/angular. Update Earthfile to build libs, UI and tauri-builtin
This commit is contained in:
3
desktop/angular/.gitignore
vendored
3
desktop/angular/.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-extension
|
||||
dist-lib
|
||||
dist-lib
|
||||
.angular
|
||||
104
desktop/angular/README.md
Normal file
104
desktop/angular/README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Portmaster
|
||||
|
||||
Welcome to the new Portmaster User-Interface. It's based on Angular and is built, unit and e2e tested using `@angular/cli`.
|
||||
|
||||
## Running locally
|
||||
|
||||
This section explains how to prepare your Ubuntu machine to build and test the new Portmaster User-Interface. It's recommended to use
|
||||
a virtual machine but running it on bare metal will work as well. You can use the new Portmaster UI as well as the old one in parallel so
|
||||
you can simply switch back when something is still missing or buggy.
|
||||
|
||||
1. **Prepare your tooling**
|
||||
|
||||
There's a simple dockerized way to build and test the new UI. Just make sure to have docker installed:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y docker.io git
|
||||
sudo systemctl enable --now docker
|
||||
sudo gpasswd -a $USER docker
|
||||
```
|
||||
|
||||
2. **Portmaster installation**
|
||||
|
||||
Next, make sure to install the Portmaster using the official .deb installer from [here](https://updates.safing.io/latest/linux_amd64/packages/portmaster-installer.deb). See the [Wiki](https://github.com/safing/portmaster/wiki/Linux) for more information.
|
||||
|
||||
Once the Portmaster is installed we need to add two new configuration flags. Execute the following:
|
||||
|
||||
```bash
|
||||
echo 'PORTMASTER_ARGS="--experimental-nfqueue --devmode"' | sudo tee /etc/default/portmaster
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart portmaster
|
||||
```
|
||||
|
||||
3. **Build and run the new UI**
|
||||
|
||||
Now, clone this repository and execute the `docker.sh` script:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/safing/portmaster-ui
|
||||
|
||||
# Enter the repo and checkout the correct branch
|
||||
cd portmaster-ui
|
||||
git checkout feature/new-ui
|
||||
|
||||
# Enter the directory and run docker.sh
|
||||
cd modules/portmaster
|
||||
sudo bash ./docker.sh
|
||||
```
|
||||
|
||||
Finally open your browser and point it to http://localhost:8080.
|
||||
|
||||
## Hacking Quick Start
|
||||
|
||||
Although everything should work in the docker container as well, for the best development experience it's recommended to install `@angular/cli` locally.
|
||||
|
||||
It's highly recommended to:
|
||||
- Use [VSCode](https://code.visualstudio.com/) (or it's oss or server-side variant) with
|
||||
- the official [Angular Language Service](https://marketplace.visualstudio.com/items?itemName=Angular.ng-template) extension
|
||||
- the [Tailwind CSS Extension Pack](https://marketplace.visualstudio.com/items?itemName=andrewmcodes.tailwindcss-extension-pack) extension
|
||||
- the [formate: CSS/LESS/SCSS formatter](https://github.com/mblander/formate) extension
|
||||
|
||||
### Folder Structure
|
||||
|
||||
From the project root (the folder containing this [README.md](./)) there are only two folders with the following content and structure:
|
||||
|
||||
- **`src/`** contains the actual application sources:
|
||||
- **`app/`** contains the actual application sources (components, services, uni tests ...)
|
||||
- **`layout/`** contains components that form the overall application layout. For example the navigation bar and the side dash are located there.
|
||||
- **`pages/`** contains the different pages of the application. A page is something that is associated with a dedicated application route and is rendered at the applications main content.
|
||||
- **`services/`** contains shared services (like PortAPI and friends)
|
||||
- **`shared/`** contains shared components that are likely used accross other components or pages.
|
||||
- **`widgets/`** contains widgets and their settings components for the application side dash.
|
||||
- **`debug/`** contains a debug sidebar component
|
||||
- **`assets/`** contains static assets that must be shipped seperately.
|
||||
- **`environments/`** contains build and production related environment settings (those are handled by `@angular/cli` automatically, see [angular.json](angular.json))
|
||||
- **`e2e/`** contains end-to-end testing sources.
|
||||
|
||||
|
||||
### Development server
|
||||
|
||||
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
|
||||
|
||||
In development mode (that is, you don't pass `--prod`) the UI expects portmaster running at `ws://127.0.0.1:817/api/database/v1`. See [environment](./src/app/environments/environment.ts).
|
||||
|
||||
### Code scaffolding
|
||||
|
||||
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
|
||||
|
||||
### Build
|
||||
|
||||
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build.
|
||||
|
||||
### Running unit tests
|
||||
|
||||
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||
|
||||
### Running end-to-end tests
|
||||
|
||||
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
|
||||
|
||||
### Further help
|
||||
|
||||
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).
|
||||
457
desktop/angular/angular.json
Normal file
457
desktop/angular/angular.json
Normal file
@@ -0,0 +1,457 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"portmaster": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
},
|
||||
"@schematics/angular:application": {
|
||||
"strict": true
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist",
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"aot": true,
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"src/theme.less",
|
||||
"src/styles.scss",
|
||||
"node_modules/prismjs/themes/prism-okaidia.css",
|
||||
"node_modules/prismjs/plugins/line-numbers/prism-line-numbers.css"
|
||||
],
|
||||
"stylePreprocessorOptions": {
|
||||
"includePaths": [
|
||||
"dist-lib/"
|
||||
]
|
||||
},
|
||||
"scripts": [
|
||||
"node_modules/marked/marked.min.js",
|
||||
"node_modules/emoji-toolkit/lib/js/joypixels.min.js",
|
||||
"node_modules/prismjs/prism.js",
|
||||
"node_modules/prismjs/components/prism-yaml.min.js",
|
||||
"node_modules/prismjs/components/prism-json.min.js",
|
||||
"node_modules/prismjs/plugins/line-numbers/prism-line-numbers.js"
|
||||
],
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"buildOptimizer": false,
|
||||
"sourceMap": true,
|
||||
"optimization": false,
|
||||
"namedChunks": true
|
||||
},
|
||||
"configurations": {
|
||||
"development": {},
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"optimization": {
|
||||
"scripts": true,
|
||||
"styles": {
|
||||
"minify": true,
|
||||
"inlineCritical": false
|
||||
}
|
||||
},
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"extractLicenses": true,
|
||||
"vendorChunk": true,
|
||||
"buildOptimizer": true,
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "4mb",
|
||||
"maximumError": "16mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4mb",
|
||||
"maximumError": "16mb"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"browserTarget": "portmaster:build"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"browserTarget": "portmaster:build:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"browserTarget": "portmaster:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"main": "src/test.ts",
|
||||
"polyfills": "src/polyfills.ts",
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"karmaConfig": "karma.conf.js",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
},
|
||||
"e2e": {
|
||||
"builder": "@angular-devkit/build-angular:protractor",
|
||||
"options": {
|
||||
"protractorConfig": "e2e/protractor.conf.js",
|
||||
"devServerTarget": "portmaster:serve"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"devServerTarget": "portmaster:serve:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-eslint/builder:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.html"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@safing/ui": {
|
||||
"projectType": "library",
|
||||
"root": "projects/safing/ui",
|
||||
"sourceRoot": "projects/safing/ui/src",
|
||||
"prefix": "lib",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:ng-packagr",
|
||||
"options": {
|
||||
"project": "projects/safing/ui/ng-package.json"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"tsConfig": "projects/safing/ui/tsconfig.lib.prod.json"
|
||||
},
|
||||
"development": {
|
||||
"tsConfig": "projects/safing/ui/tsconfig.lib.json"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"main": "projects/safing/ui/src/test.ts",
|
||||
"tsConfig": "projects/safing/ui/tsconfig.spec.json",
|
||||
"karmaConfig": "projects/safing/ui/karma.conf.js"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-eslint/builder:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"projects/safing/ui/**/*.ts",
|
||||
"projects/safing/ui/**/*.html"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"portmaster-chrome-extension": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "projects/portmaster-chrome-extension",
|
||||
"sourceRoot": "projects/portmaster-chrome-extension/src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-builders/custom-webpack:browser",
|
||||
"options": {
|
||||
"customWebpackConfig": {
|
||||
"path": "./browser-extension.config.ts"
|
||||
},
|
||||
"outputPath": "dist-extension",
|
||||
"index": "projects/portmaster-chrome-extension/src/index.html",
|
||||
"main": "projects/portmaster-chrome-extension/src/main.ts",
|
||||
"polyfills": "projects/portmaster-chrome-extension/src/polyfills.ts",
|
||||
"tsConfig": "projects/portmaster-chrome-extension/tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
"projects/portmaster-chrome-extension/src/favicon.ico",
|
||||
"projects/portmaster-chrome-extension/src/assets",
|
||||
"projects/portmaster-chrome-extension/src/manifest.json"
|
||||
],
|
||||
"styles": [
|
||||
"projects/portmaster-chrome-extension/src/styles.scss"
|
||||
],
|
||||
"scripts": [],
|
||||
"optimization": {
|
||||
"styles": {
|
||||
"inlineCritical": false
|
||||
}
|
||||
},
|
||||
"outputHashing": "none"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kb",
|
||||
"maximumError": "1mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "2kb",
|
||||
"maximumError": "4kb"
|
||||
}
|
||||
],
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "projects/portmaster-chrome-extension/src/environments/environment.ts",
|
||||
"with": "projects/portmaster-chrome-extension/src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"outputHashing": "none"
|
||||
},
|
||||
"development": {
|
||||
"customWebpackConfig": {
|
||||
"path": "./browser-extension-dev.config.ts"
|
||||
},
|
||||
"buildOptimizer": false,
|
||||
"optimization": false,
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true,
|
||||
"namedChunks": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"browserTarget": "portmaster-chrome-extension:build:production"
|
||||
},
|
||||
"development": {
|
||||
"browserTarget": "portmaster-chrome-extension:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"browserTarget": "portmaster-chrome-extension:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"main": "projects/portmaster-chrome-extension/src/test.ts",
|
||||
"polyfills": "projects/portmaster-chrome-extension/src/polyfills.ts",
|
||||
"tsConfig": "projects/portmaster-chrome-extension/tsconfig.spec.json",
|
||||
"karmaConfig": "projects/portmaster-chrome-extension/karma.conf.js",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
"projects/portmaster-chrome-extension/src/favicon.ico",
|
||||
"projects/portmaster-chrome-extension/src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"projects/portmaster-chrome-extension/src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@safing/portmaster-api": {
|
||||
"projectType": "library",
|
||||
"root": "projects/safing/portmaster-api",
|
||||
"sourceRoot": "projects/safing/portmaster-api/src",
|
||||
"prefix": "lib",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:ng-packagr",
|
||||
"options": {
|
||||
"project": "projects/safing/portmaster-api/ng-package.json"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"tsConfig": "projects/safing/portmaster-api/tsconfig.lib.prod.json"
|
||||
},
|
||||
"development": {
|
||||
"tsConfig": "projects/safing/portmaster-api/tsconfig.lib.json"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"main": "projects/safing/portmaster-api/src/test.ts",
|
||||
"tsConfig": "projects/safing/portmaster-api/tsconfig.spec.json",
|
||||
"karmaConfig": "projects/safing/portmaster-api/karma.conf.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tauri-builtin": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"skipTests": true,
|
||||
"style": "scss",
|
||||
"standalone": true
|
||||
},
|
||||
"@schematics/angular:class": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"skipTests": true,
|
||||
"standalone": true
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"skipTests": true,
|
||||
"standalone": true
|
||||
},
|
||||
"@schematics/angular:resolver": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"skipTests": true
|
||||
}
|
||||
},
|
||||
"root": "projects/tauri-builtin",
|
||||
"sourceRoot": "projects/tauri-builtin/src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"options": {
|
||||
"outputPath": "dist/tauri-builtin",
|
||||
"index": "projects/tauri-builtin/src/index.html",
|
||||
"main": "projects/tauri-builtin/src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
"tsConfig": "projects/tauri-builtin/tsconfig.app.json",
|
||||
"assets": [
|
||||
"projects/tauri-builtin/src/favicon.ico",
|
||||
"projects/tauri-builtin/src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"projects/tauri-builtin/src/styles.scss"
|
||||
],
|
||||
"inlineStyleLanguage": "scss",
|
||||
"stylePreprocessorOptions": {
|
||||
"includePaths": [
|
||||
"dist-lib/"
|
||||
]
|
||||
},
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kb",
|
||||
"maximumError": "1mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "2kb",
|
||||
"maximumError": "4kb"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"buildOptimizer": false,
|
||||
"optimization": false,
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true,
|
||||
"namedChunks": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"browserTarget": "tauri-builtin:build:production"
|
||||
},
|
||||
"development": {
|
||||
"browserTarget": "tauri-builtin:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"browserTarget": "tauri-builtin:build"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"analytics": false
|
||||
},
|
||||
"schematics": {
|
||||
"@angular-eslint/schematics:application": {
|
||||
"setParserOptionsProject": true
|
||||
},
|
||||
"@angular-eslint/schematics:library": {
|
||||
"setParserOptionsProject": true
|
||||
}
|
||||
}
|
||||
}
|
||||
1
desktop/angular/assets
Symbolic link
1
desktop/angular/assets
Symbolic link
@@ -0,0 +1 @@
|
||||
../../assets
|
||||
16
desktop/angular/browser-extension-dev.config.ts
Normal file
16
desktop/angular/browser-extension-dev.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Configuration } from 'webpack';
|
||||
const ExtensionReloader = require('webpack-ext-reloader');
|
||||
const config = require('./browser-extension.config');
|
||||
|
||||
module.exports = {
|
||||
...config,
|
||||
mode: 'development',
|
||||
plugins: [
|
||||
new ExtensionReloader({
|
||||
reloadPage: true, // Force the reload of the page also
|
||||
entries: { // The entries used for the content/background scripts or extension pages
|
||||
background: 'background',
|
||||
}
|
||||
})
|
||||
]
|
||||
} as Configuration;
|
||||
5
desktop/angular/browser-extension.config.ts
Normal file
5
desktop/angular/browser-extension.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { Configuration } from 'webpack';
|
||||
|
||||
module.exports = {
|
||||
entry: { background: { import: 'projects/portmaster-chrome-extension/src/background.ts', runtime: false } },
|
||||
} as Configuration;
|
||||
18
desktop/angular/docker.sh
Executable file
18
desktop/angular/docker.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
# cd to script dir
|
||||
baseDir="$( cd "$(dirname "$0")" && pwd )"
|
||||
cd "$baseDir"
|
||||
|
||||
# get base dir for mounting
|
||||
mnt="$( cd ../.. && pwd )"
|
||||
|
||||
# run container and start dev server
|
||||
docker run \
|
||||
-ti \
|
||||
--rm \
|
||||
-v $mnt:/portmaster-ui \
|
||||
-w /portmaster-ui/modules/portmaster \
|
||||
-p 8081:8080 \
|
||||
node:latest \
|
||||
npm start -- --host 0.0.0.0 --port 8080
|
||||
36
desktop/angular/e2e/protractor.conf.js
Normal file
36
desktop/angular/e2e/protractor.conf.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// @ts-check
|
||||
// Protractor configuration file, see link for more information
|
||||
// https://github.com/angular/protractor/blob/master/lib/config.ts
|
||||
|
||||
const { SpecReporter, StacktraceOption } = require('jasmine-spec-reporter');
|
||||
|
||||
/**
|
||||
* @type { import("protractor").Config }
|
||||
*/
|
||||
exports.config = {
|
||||
allScriptsTimeout: 11000,
|
||||
specs: [
|
||||
'./src/**/*.e2e-spec.ts'
|
||||
],
|
||||
capabilities: {
|
||||
browserName: 'chrome'
|
||||
},
|
||||
directConnect: true,
|
||||
baseUrl: 'http://localhost:4200/',
|
||||
framework: 'jasmine',
|
||||
jasmineNodeOpts: {
|
||||
showColors: true,
|
||||
defaultTimeoutInterval: 30000,
|
||||
print: function() {}
|
||||
},
|
||||
onPrepare() {
|
||||
require('ts-node').register({
|
||||
project: require('path').join(__dirname, './tsconfig.json')
|
||||
});
|
||||
jasmine.getEnv().addReporter(new SpecReporter({
|
||||
spec: {
|
||||
displayStacktrace: StacktraceOption.PRETTY
|
||||
}
|
||||
}));
|
||||
}
|
||||
};
|
||||
23
desktop/angular/e2e/src/app.e2e-spec.ts
Normal file
23
desktop/angular/e2e/src/app.e2e-spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { AppPage } from './app.po';
|
||||
import { browser, logging } from 'protractor';
|
||||
|
||||
describe('workspace-project App', () => {
|
||||
let page: AppPage;
|
||||
|
||||
beforeEach(() => {
|
||||
page = new AppPage();
|
||||
});
|
||||
|
||||
it('should display welcome message', () => {
|
||||
page.navigateTo();
|
||||
expect(page.getTitleText()).toEqual('portmaster app is running!');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Assert that there are no errors emitted from the browser
|
||||
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
|
||||
expect(logs).not.toContain(jasmine.objectContaining({
|
||||
level: logging.Level.SEVERE,
|
||||
} as logging.Entry));
|
||||
});
|
||||
});
|
||||
11
desktop/angular/e2e/src/app.po.ts
Normal file
11
desktop/angular/e2e/src/app.po.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { browser, by, element } from 'protractor';
|
||||
|
||||
export class AppPage {
|
||||
navigateTo(): Promise<unknown> {
|
||||
return browser.get(browser.baseUrl) as Promise<unknown>;
|
||||
}
|
||||
|
||||
getTitleText(): Promise<string> {
|
||||
return element(by.css('app-root .content span')).getText() as Promise<string>;
|
||||
}
|
||||
}
|
||||
14
desktop/angular/e2e/tsconfig.json
Normal file
14
desktop/angular/e2e/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../out-tsc/e2e",
|
||||
"module": "commonjs",
|
||||
"target": "es2018",
|
||||
"types": [
|
||||
"jasmine",
|
||||
"jasminewd2",
|
||||
"node"
|
||||
]
|
||||
}
|
||||
}
|
||||
32
desktop/angular/karma.conf.js
Normal file
32
desktop/angular/karma.conf.js
Normal file
@@ -0,0 +1,32 @@
|
||||
// Karma configuration file, see link for more information
|
||||
// https://karma-runner.github.io/1.0/config/configuration-file.html
|
||||
|
||||
module.exports = function (config) {
|
||||
config.set({
|
||||
basePath: '',
|
||||
frameworks: ['jasmine', '@angular-devkit/build-angular'],
|
||||
plugins: [
|
||||
require('karma-jasmine'),
|
||||
require('karma-chrome-launcher'),
|
||||
require('karma-jasmine-html-reporter'),
|
||||
require('karma-coverage-istanbul-reporter'),
|
||||
require('@angular-devkit/build-angular/plugins/karma')
|
||||
],
|
||||
client: {
|
||||
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
||||
},
|
||||
coverageIstanbulReporter: {
|
||||
dir: require('path').join(__dirname, './coverage/portmaster'),
|
||||
reports: ['html', 'lcovonly', 'text-summary'],
|
||||
fixWebpackSourcePaths: true
|
||||
},
|
||||
reporters: ['progress', 'kjhtml'],
|
||||
port: 9876,
|
||||
colors: true,
|
||||
logLevel: config.LOG_INFO,
|
||||
autoWatch: true,
|
||||
browsers: ['Chrome'],
|
||||
singleRun: false,
|
||||
restartOnFileChange: true
|
||||
});
|
||||
};
|
||||
34959
desktop/angular/package-lock.json
generated
Normal file
34959
desktop/angular/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
105
desktop/angular/package.json
Normal file
105
desktop/angular/package.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"name": "portmaster",
|
||||
"version": "0.8.3",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "npm install && npm run build-libs:dev && ng serve --proxy-config ./proxy.json",
|
||||
"build-libs": "NODE_ENV=production ng build --configuration production @safing/ui && NODE_ENV=production ng build --configuration production @safing/portmaster-api",
|
||||
"build-libs:dev": "ng build --configuration development @safing/ui && ng build --configuration development @safing/portmaster-api",
|
||||
"serve": "npm run build-libs:dev && ng serve --proxy-config ./proxy.json",
|
||||
"build:dev": "npm run build-libs:dev && ng build",
|
||||
"test": "ng test",
|
||||
"lint": "ng lint",
|
||||
"e2e": "ng e2e",
|
||||
"zip-dist": "node pack.js",
|
||||
"chrome-extension": "NODE_ENV=production ng build --configuration production portmaster-chrome-extension",
|
||||
"chrome-extension:dev": "ng build --configuration development portmaster-chrome-extension --watch",
|
||||
"build": "npm run build-libs && NODE_ENV=production ng build --configuration production --base-href /ui/modules/portmaster/",
|
||||
"build-tauri": "npm run build-libs && NODE_ENV=production ng build --configuration production",
|
||||
"serve-tauri-builtin": "ng serve tauri-builtin --port 4100",
|
||||
"serve-app": "ng serve --port 4200 --proxy-config ./proxy.json",
|
||||
"tauri-dev": "npm install && run-s build-libs:dev && run-p serve-app serve-tauri-builtin"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^16.0.1",
|
||||
"@angular/cdk": "^16.0.1",
|
||||
"@angular/common": "^16.0.1",
|
||||
"@angular/compiler": "^16.0.1",
|
||||
"@angular/core": "^16.0.1",
|
||||
"@angular/forms": "^16.0.1",
|
||||
"@angular/localize": "^16.0.1",
|
||||
"@angular/platform-browser": "^16.0.1",
|
||||
"@angular/platform-browser-dynamic": "^16.0.1",
|
||||
"@angular/router": "^16.0.1",
|
||||
"@fortawesome/angular-fontawesome": "^0.13.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.4.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.4.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
||||
"@tauri-apps/api": "^2.0.0-beta.3",
|
||||
"@tauri-apps/plugin-cli": "^2.0.0-beta.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.0.0-alpha.4",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0-alpha.4",
|
||||
"@tauri-apps/plugin-notification": "^2.0.0-alpha.4",
|
||||
"@tauri-apps/plugin-os": "^2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0-alpha.4",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"d3": "^7.8.4",
|
||||
"data-urls": "^5.0.0",
|
||||
"emoji-toolkit": "^7.0.1",
|
||||
"fuse.js": "^6.6.2",
|
||||
"ng-zorro-antd": "^16.1.0",
|
||||
"ngx-markdown": "^16.0.0",
|
||||
"postcss": "^8.4.23",
|
||||
"prismjs": "^1.29.0",
|
||||
"psl": "^1.9.0",
|
||||
"rxjs": "~7.8.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"topojson-simplify": "^3.0.3",
|
||||
"tslib": "^2.5.0",
|
||||
"whatwg-encoding": "^3.1.1",
|
||||
"zone.js": "^0.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-builders/custom-webpack": "^16.0.0-beta.1",
|
||||
"@angular-devkit/build-angular": "^16.0.1",
|
||||
"@angular-eslint/builder": "16.0.1",
|
||||
"@angular-eslint/eslint-plugin": "16.0.1",
|
||||
"@angular-eslint/eslint-plugin-template": "16.0.1",
|
||||
"@angular-eslint/schematics": "16.0.1",
|
||||
"@angular-eslint/template-parser": "16.0.1",
|
||||
"@angular/cli": "^16.0.1",
|
||||
"@angular/compiler-cli": "^16.0.1",
|
||||
"@fullhuman/postcss-purgecss": "^5.0.0",
|
||||
"@types/chrome": "^0.0.236",
|
||||
"@types/d3": "^7.4.0",
|
||||
"@types/data-urls": "^3.0.4",
|
||||
"@types/jasmine": "^4.3.1",
|
||||
"@types/jasminewd2": "~2.0.10",
|
||||
"@types/node": "^20.1.5",
|
||||
"@types/psl": "^1.1.0",
|
||||
"@types/topojson-client": "^3.1.1",
|
||||
"@types/topojson-simplify": "^3.0.1",
|
||||
"@types/whatwg-encoding": "^2.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.6",
|
||||
"@typescript-eslint/parser": "^5.59.6",
|
||||
"eslint": "^8.40.0",
|
||||
"jasmine-core": "^5.0.0",
|
||||
"jasmine-spec-reporter": "^7.0.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"ng-packagr": "^16.0.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss-import": "^15.1.0",
|
||||
"postcss-loader": "^7.3.0",
|
||||
"postcss-scss": "^4.0.6",
|
||||
"protractor": "~7.0.0",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"ts-node": "^10.9.1",
|
||||
"tslint": "~6.1.0",
|
||||
"typescript": "4.9",
|
||||
"webpack-bundle-analyzer": "^4.8.0",
|
||||
"webpack-ext-reloader": "^1.1.9",
|
||||
"zip-a-folder": "^1.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Karma configuration file, see link for more information
|
||||
// https://karma-runner.github.io/1.0/config/configuration-file.html
|
||||
|
||||
module.exports = function (config) {
|
||||
config.set({
|
||||
basePath: '',
|
||||
frameworks: ['jasmine', '@angular-devkit/build-angular'],
|
||||
plugins: [
|
||||
require('karma-jasmine'),
|
||||
require('karma-chrome-launcher'),
|
||||
require('karma-jasmine-html-reporter'),
|
||||
require('karma-coverage'),
|
||||
require('@angular-devkit/build-angular/plugins/karma')
|
||||
],
|
||||
client: {
|
||||
jasmine: {
|
||||
// you can add configuration options for Jasmine here
|
||||
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
|
||||
// for example, you can disable the random execution with `random: false`
|
||||
// or set a specific seed with `seed: 4321`
|
||||
},
|
||||
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
||||
},
|
||||
jasmineHtmlReporter: {
|
||||
suppressAll: true // removes the duplicated traces
|
||||
},
|
||||
coverageReporter: {
|
||||
dir: require('path').join(__dirname, '../../coverage/portmaster-chrome-extension'),
|
||||
subdir: '.',
|
||||
reporters: [
|
||||
{ type: 'html' },
|
||||
{ type: 'text-summary' }
|
||||
]
|
||||
},
|
||||
reporters: ['progress', 'kjhtml'],
|
||||
port: 9876,
|
||||
colors: true,
|
||||
logLevel: config.LOG_INFO,
|
||||
autoWatch: true,
|
||||
browsers: ['Chrome'],
|
||||
singleRun: false,
|
||||
restartOnFileChange: true
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { ExtDomainListComponent } from './domain-list';
|
||||
import { IntroComponent } from './welcome/intro.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: '', pathMatch: 'full', component: ExtDomainListComponent },
|
||||
{ path: 'authorize', pathMatch: 'prefix', component: IntroComponent }
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forRoot(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AppRoutingModule { }
|
||||
@@ -0,0 +1,3 @@
|
||||
<ext-header *ngIf="!isAuthorizeView"></ext-header>
|
||||
|
||||
<router-outlet></router-outlet>
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
@apply bg-background text-white flex flex-col w-96 h-96;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { MetaAPI, MyProfileResponse, retryPipeline } from '@safing/portmaster-api';
|
||||
import { catchError, filter, throwError } from 'rxjs';
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss'],
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
isAuthorizeView = false;
|
||||
|
||||
constructor(
|
||||
private metaapi: MetaAPI,
|
||||
private router: Router,
|
||||
) { }
|
||||
|
||||
profile: MyProfileResponse | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter(event => event instanceof NavigationEnd)
|
||||
)
|
||||
.subscribe(event => {
|
||||
if (event instanceof NavigationEnd) {
|
||||
this.isAuthorizeView = event.url.includes("/authorize")
|
||||
}
|
||||
})
|
||||
|
||||
this.metaapi.myProfile()
|
||||
.pipe(
|
||||
catchError(err => {
|
||||
if (err instanceof HttpErrorResponse && err.status === 403) {
|
||||
this.router.navigate(['/authorize'])
|
||||
}
|
||||
|
||||
return throwError(() => err)
|
||||
}),
|
||||
retryPipeline()
|
||||
)
|
||||
.subscribe({
|
||||
next: profile => {
|
||||
this.profile = profile;
|
||||
|
||||
console.log(this.profile);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { OverlayModule } from '@angular/cdk/overlay';
|
||||
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { PortmasterAPIModule } from '@safing/portmaster-api';
|
||||
import { TabModule } from '@safing/ui';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
import { AppComponent } from './app.component';
|
||||
import { ExtDomainListComponent } from './domain-list';
|
||||
import { ExtHeaderComponent } from './header';
|
||||
import { AuthIntercepter as AuthInterceptor } from './interceptor';
|
||||
import { WelcomeModule } from './welcome';
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
ExtDomainListComponent,
|
||||
ExtHeaderComponent,
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
AppRoutingModule,
|
||||
HttpClientModule,
|
||||
PortmasterAPIModule.forRoot(),
|
||||
TabModule,
|
||||
WelcomeModule,
|
||||
OverlayModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
multi: true,
|
||||
useClass: AuthInterceptor,
|
||||
}
|
||||
],
|
||||
bootstrap: [AppComponent]
|
||||
})
|
||||
export class AppModule { }
|
||||
@@ -0,0 +1,27 @@
|
||||
<ul>
|
||||
<li class="flex flex-col gap-1 px-2 py-1 hover:bg-gray-300" *ngFor="let req of requests">
|
||||
<div class="flex flex-row items-center justify-start gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-green-300" viewBox="0 0 20 20" fill="currentColor"
|
||||
*ngIf="!req.latestIsBlocked">
|
||||
<path fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-red-300" viewBox="0 0 20 20" fill="currentColor"
|
||||
*ngIf="req.latestIsBlocked">
|
||||
<path fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
||||
<span>
|
||||
{{ req.domain }}
|
||||
</span>
|
||||
</div>
|
||||
<span *ngIf="req.latestIsBlocked && !!req.lastConn" class="flex flex-row gap-2 text-xs text-secondary">
|
||||
<span class="w-4"></span>
|
||||
{{ req.lastConn.extra_data?.reason?.Msg }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from "@angular/core";
|
||||
import { Netquery, NetqueryConnection } from "@safing/portmaster-api";
|
||||
import { ListRequests, NotifyRequests } from "../../background/commands";
|
||||
import { Request } from '../../background/tab-tracker';
|
||||
|
||||
interface DomainRequests {
|
||||
domain: string;
|
||||
requests: Request[];
|
||||
latestIsBlocked: boolean;
|
||||
lastConn?: NetqueryConnection;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'ext-domain-list',
|
||||
templateUrl: './domain-list.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
@apply flex flex-grow flex-col overflow-auto;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class ExtDomainListComponent implements OnInit {
|
||||
requests: DomainRequests[] = [];
|
||||
|
||||
constructor(
|
||||
private netquery: Netquery,
|
||||
private cdr: ChangeDetectorRef
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
// setup listening for requests sent from our background script
|
||||
const self = this;
|
||||
chrome.runtime.onMessage.addListener((msg: NotifyRequests) => {
|
||||
if (typeof msg !== 'object') {
|
||||
console.error('Received invalid message from background script')
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`DEBUG: received command ${msg.type} from background script`)
|
||||
|
||||
switch (msg.type) {
|
||||
case 'notifyRequests':
|
||||
self.updateRequests(msg.requests);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error('Received unknown command from background script')
|
||||
}
|
||||
})
|
||||
|
||||
this.loadRequests();
|
||||
}
|
||||
|
||||
updateRequests(req: Request[]) {
|
||||
let m = new Map<string, DomainRequests>();
|
||||
|
||||
this.requests.forEach(obj => {
|
||||
obj.requests = [];
|
||||
m.set(obj.domain, obj);
|
||||
});
|
||||
|
||||
req.forEach(r => {
|
||||
let obj = m.get(r.domain);
|
||||
if (!obj) {
|
||||
obj = {
|
||||
domain: r.domain,
|
||||
requests: [],
|
||||
latestIsBlocked: false
|
||||
}
|
||||
m.set(r.domain, obj)
|
||||
}
|
||||
|
||||
obj.requests.push(r);
|
||||
})
|
||||
|
||||
this.requests = [];
|
||||
Array.from(m.keys()).sort()
|
||||
.map(key => m.get(key)!)
|
||||
.forEach(obj => {
|
||||
this.requests.push(obj)
|
||||
|
||||
this.netquery.query({
|
||||
query: {
|
||||
domain: obj.domain,
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
field: 'started',
|
||||
desc: true,
|
||||
}
|
||||
],
|
||||
page: 0,
|
||||
pageSize: 1,
|
||||
})
|
||||
.subscribe(result => {
|
||||
if (!result[0]) {
|
||||
return;
|
||||
}
|
||||
|
||||
obj.latestIsBlocked = !result[0].allowed;
|
||||
obj.lastConn = result[0] as NetqueryConnection;
|
||||
})
|
||||
})
|
||||
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
private loadRequests() {
|
||||
const cmd: ListRequests = {
|
||||
type: 'listRequests',
|
||||
tabId: 'current'
|
||||
}
|
||||
|
||||
const self = this;
|
||||
chrome.runtime.sendMessage(cmd, (response: any) => {
|
||||
if (Array.isArray(response)) {
|
||||
self.updateRequests(response)
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(response);
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './domain-list.component';
|
||||
@@ -0,0 +1,22 @@
|
||||
<div class="flex flex-row items-center w-full p-4 text-xl bg-gray-200 h-28">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-full ">
|
||||
<path fill="currentColor" class="text-green-100 shield-three" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="1"
|
||||
d="M20 11.242c0 4.368-3.157 8.462-7.48 9.686-.338.096-.702.096-1.04 0C7.157 19.705 4 15.61 4 11.242V7.214c0-.812.491-1.544 1.243-1.851l4.864-1.99c1.214-.497 2.574-.497 3.787 0l4.864 1.99C19.509 5.67 20 6.402 20 7.214v4.028z" />
|
||||
<path fill="currentColor" class="text-green-200 shield-two" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="1"
|
||||
d="M20 11.242c0 4.368-3.157 8.462-7.48 9.686-.338.096-.702.096-1.04 0C7.157 19.705 4 15.61 4 11.242V7.214c0-.812.491-1.544 1.243-1.851l4.864-1.99c1.214-.497 2.574-.497 3.787 0l4.864 1.99C19.509 5.67 20 6.402 20 7.214v4.028z" />
|
||||
<path fill="currentColor" class="text-green-300 shield-one" stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="1.4"
|
||||
d="M20 11.242c0 4.368-3.157 8.462-7.48 9.686-.338.096-.702.096-1.04 0C7.157 19.705 4 15.61 4 11.242V7.214c0-.812.491-1.544 1.243-1.851l4.864-1.99c1.214-.497 2.574-.497 3.787 0l4.864 1.99C19.509 5.67 20 6.402 20 7.214v4.028z" />
|
||||
|
||||
<path stroke="currentColor" fill="transparent" class="text-background shield-ok" stroke-linecap="round"
|
||||
stroke-linejoin="round" stroke-width="1" d="M8.712 12.566l2.193 2.193 4.787-4.788" />
|
||||
|
||||
|
||||
</svg>
|
||||
|
||||
<span class="text-2xl font-thin text-white">
|
||||
Secure
|
||||
</span>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
svg {
|
||||
transform: scale(0.95);
|
||||
|
||||
path {
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.shield-one {
|
||||
transform: scale(.62);
|
||||
}
|
||||
|
||||
.shield-two {
|
||||
animation-delay: -1.2s;
|
||||
opacity: .6;
|
||||
transform: scale(.8);
|
||||
}
|
||||
|
||||
.shield-three {
|
||||
animation-delay: -2.5s;
|
||||
opacity: .4;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.shield-ok {
|
||||
transform: scale(.62);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: 'ext-header',
|
||||
templateUrl: './header.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styleUrls: ['./header.component.scss']
|
||||
})
|
||||
export class ExtHeaderComponent { }
|
||||
@@ -0,0 +1 @@
|
||||
export * from './header.component';
|
||||
@@ -0,0 +1,45 @@
|
||||
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from "@angular/common/http";
|
||||
import { Injectable } from "@angular/core";
|
||||
import { BehaviorSubject, filter, Observable, switchMap } from "rxjs";
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class AuthIntercepter implements HttpInterceptor {
|
||||
/** Used to delay requests until we loaded the access token from the extension storage. */
|
||||
private loaded$ = new BehaviorSubject<boolean>(false);
|
||||
|
||||
/** Holds the access token required to talk to the Portmaster API. */
|
||||
private token: string | null = null;
|
||||
|
||||
constructor() {
|
||||
// make sure we use the new access token once we get one.
|
||||
chrome.storage.onChanged.addListener(changes => {
|
||||
this.token = changes['key'].newValue || null;
|
||||
})
|
||||
|
||||
// try to read the current access token from the extension storage.
|
||||
chrome.storage.local.get('key', obj => {
|
||||
this.token = obj.key || null;
|
||||
console.log("got token", this.token)
|
||||
this.loaded$.next(true);
|
||||
})
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'listRequests', tabId: 'current' }, (response: any) => {
|
||||
console.log(response);
|
||||
})
|
||||
}
|
||||
|
||||
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
return this.loaded$.pipe(
|
||||
filter(loaded => loaded),
|
||||
switchMap(() => {
|
||||
if (!!this.token) {
|
||||
req = req.clone({
|
||||
headers: req.headers.set("Authorization", "Bearer " + this.token)
|
||||
})
|
||||
}
|
||||
return next.handle(req)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class RequestInterceptorService {
|
||||
/** Used to emit when a new URL was requested */
|
||||
private onUrlRequested$ = new Subject<chrome.webRequest.WebRequestBodyDetails>();
|
||||
|
||||
/** Used to emit when a URL has likely been blocked by the portmaster */
|
||||
private onUrlBlocked$ = new Subject<chrome.webRequest.WebResponseErrorDetails>();
|
||||
|
||||
/** Emits when a new URL was requested */
|
||||
get onUrlRequested() {
|
||||
return this.onUrlRequested$.asObservable();
|
||||
}
|
||||
|
||||
/** Emits when a new URL was likely blocked by the portmaster */
|
||||
get onUrlBlocked() {
|
||||
return this.onUrlBlocked$.asObservable();
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.registerCallbacks()
|
||||
}
|
||||
|
||||
private registerCallbacks() {
|
||||
const filter = {
|
||||
urls: [
|
||||
"http://*/*",
|
||||
"https://*/*",
|
||||
]
|
||||
};
|
||||
|
||||
chrome.webRequest.onBeforeRequest.addListener(details => this.onUrlRequested$.next(details), filter)
|
||||
chrome.webRequest.onErrorOccurred.addListener(details => {
|
||||
if (details.error !== "net::ERR_ADDRESS_UNREACHABLE") {
|
||||
// we don't care about errors other than UNREACHABLE because that's error caused
|
||||
// by the portmaster.
|
||||
return;
|
||||
}
|
||||
|
||||
this.onUrlBlocked$.next(details);
|
||||
}, filter)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './welcome.module';
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<div class="flex flex-col items-center">
|
||||
|
||||
<h1 class="flex flex-row items-center gap-4 p-4 bg-gray-200 text-md">
|
||||
<svg class="w-auto h-16 mr-4" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 128 128">
|
||||
<g data-name="Main" fill-rule="evenodd">
|
||||
<path fill="#fff" d="M176.11 36.73l-5-8.61a41.53 41.53 0 00-14.73 57.22l8.55-5.12a31.58 31.58 0 0111.19-43.49z"
|
||||
transform="translate(-127.99 .01)" style="isolation:isolate" opacity=".8"></path>
|
||||
<path fill="#fff" d="M222.36 72.63a31.55 31.55 0 01-45 19.35l-4.62 8.84a41.54 41.54 0 0059.17-25.46z"
|
||||
transform="translate(-127.99 .01)" style="isolation:isolate" opacity=".8"></path>
|
||||
<path fill="#fff" d="M197 83a19.66 19.66 0 01-19.25-32.57l-4.5-4.27A25.87 25.87 0 00198.59 89z"
|
||||
transform="translate(-127.99 .01)" style="isolation:isolate" opacity=".6"></path>
|
||||
<path fill="#fff"
|
||||
d="M192 112.64A48.64 48.64 0 11240.64 64 48.64 48.64 0 01192 112.64zM256 64a64 64 0 10-64 64 64 64 0 0064-64z"
|
||||
transform="translate(-127.99 .1)"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<span class="inline-flex flex-col items-start">
|
||||
<span class="text-secondary">Welcome to the</span>
|
||||
<span class="text-lg font-semibold">
|
||||
Portmaster Browser Extension
|
||||
</span>
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex flex-col items-center flex-grow p-4 justify-evenly">
|
||||
<ng-container *ngIf="state === ''; else: authorizingTemplate">
|
||||
<span class="text-sm text-center text-secondary">
|
||||
This extension adds direct support for Portmaster to your Browser. For that, it needs to get access to the
|
||||
Portmaster on your system. For security reasons, you first need to authorize the Browser Extension to talk to the
|
||||
Portmaster.
|
||||
</span>
|
||||
|
||||
</ng-container>
|
||||
|
||||
<ng-template #authorizingTemplate>
|
||||
<h2 class="text-base text-primary">Waiting for Authorization</h2>
|
||||
<span class="text-sm text-center text-secondary">
|
||||
Please open the Portmaster and approve the authorization request.
|
||||
</span>
|
||||
</ng-template>
|
||||
|
||||
<button (click)="authorizeExtension()"
|
||||
class="px-3 py-1.5 text-center text-white rounded-md cursor-pointer hover:bg-blue hover:bg-opacity-70 bg-blue outline-none text-sm"
|
||||
type="button">{{ state === 'authorizing' ? 'Retry' : 'Authorize' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { MetaAPI } from "@safing/portmaster-api";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
|
||||
@Component({
|
||||
templateUrl: './intro.component.html',
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
@apply flex flex-col h-full;
|
||||
}
|
||||
`
|
||||
]
|
||||
})
|
||||
export class IntroComponent {
|
||||
private cancelRequest$ = new Subject<void>();
|
||||
|
||||
state: 'authorizing' | 'failed' | '' = '';
|
||||
|
||||
constructor(
|
||||
private meta: MetaAPI,
|
||||
private router: Router,
|
||||
) { }
|
||||
|
||||
authorizeExtension() {
|
||||
// cancel any pending request
|
||||
this.cancelRequest$.next();
|
||||
|
||||
this.state = 'authorizing';
|
||||
this.meta.requestApplicationAccess("Portmaster Browser Extension")
|
||||
.pipe(takeUntil(this.cancelRequest$))
|
||||
.subscribe({
|
||||
next: token => {
|
||||
chrome.storage.local.set(token);
|
||||
console.log(token);
|
||||
this.router.navigate(['/'])
|
||||
},
|
||||
error: err => {
|
||||
this.state = 'failed';
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
import { OverlayStepperModule } from "@safing/ui";
|
||||
import { IntroComponent } from "./intro.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
OverlayStepperModule,
|
||||
],
|
||||
declarations: [
|
||||
IntroComponent,
|
||||
],
|
||||
exports: [
|
||||
IntroComponent,
|
||||
]
|
||||
})
|
||||
export class WelcomeModule { }
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,133 @@
|
||||
import { debounceTime, Subject } from "rxjs";
|
||||
import { CallRequest, ListRequests, NotifyRequests } from "./background/commands";
|
||||
import { Request, TabTracker } from "./background/tab-tracker";
|
||||
import { getCurrentTab } from "./background/tab-utils";
|
||||
|
||||
export class BackgroundService {
|
||||
/** a lookup map for tab trackers by tab-id */
|
||||
private trackers = new Map<number, TabTracker>();
|
||||
|
||||
/** used to signal the pop-up that new requests arrived */
|
||||
private notifyRequests = new Subject<void>();
|
||||
|
||||
constructor() {
|
||||
// register a navigation-completed listener. This is fired when the user switches to a new website
|
||||
// by entering it in the browser address bar.
|
||||
chrome.webNavigation.onCompleted.addListener((details) => {
|
||||
console.log("event: webNavigation.onCompleted", details);
|
||||
})
|
||||
|
||||
// request event listeners for new requests and errors that occured for them.
|
||||
// We only care about http and https here.
|
||||
const filter = {
|
||||
urls: [
|
||||
'http://*/*',
|
||||
'https://*/*'
|
||||
]
|
||||
}
|
||||
chrome.webRequest.onBeforeRequest.addListener(details => this.handleOnBeforeRequest(details), filter)
|
||||
chrome.webRequest.onErrorOccurred.addListener(details => this.handleOnErrorOccured(details), filter)
|
||||
|
||||
// make sure we can communicate with the extension popup
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => this.handleMessage(msg, sender, sendResponse))
|
||||
|
||||
// set-up signalling of new requests to the pop-up
|
||||
this.notifyRequests
|
||||
.pipe(debounceTime(500))
|
||||
.subscribe(async () => {
|
||||
const currentTab = await getCurrentTab();
|
||||
if (!!currentTab && !!currentTab.id) {
|
||||
const msg: NotifyRequests = {
|
||||
type: 'notifyRequests',
|
||||
requests: this.mustGetTab({ tabId: currentTab.id }).allRequests()
|
||||
}
|
||||
|
||||
chrome.runtime.sendMessage(msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Callback for messages sent by the popup */
|
||||
private handleMessage(msg: CallRequest, sender: chrome.runtime.MessageSender, sendResponse: (msg: any) => void) {
|
||||
console.log(`DEBUG: got message from ${sender.origin} (tab=${sender.tab?.id})`)
|
||||
|
||||
if (typeof msg !== 'object') {
|
||||
console.error(`Received invalid message from popup`, msg)
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let response: Promise<any>;
|
||||
switch (msg.type) {
|
||||
case 'listRequests':
|
||||
response = this.handleListRequests(msg)
|
||||
break;
|
||||
|
||||
default:
|
||||
response = Promise.reject("unknown command")
|
||||
}
|
||||
|
||||
response
|
||||
.then(res => {
|
||||
console.log(`DEBUG: sending response for command ${msg.type}`, res)
|
||||
sendResponse(res);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(`Failed to handle command ${msg.type}`, err)
|
||||
sendResponse({
|
||||
type: 'error',
|
||||
details: err
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/** Returns a list of all observed requests based on the filter in msg. */
|
||||
private async handleListRequests(msg: ListRequests): Promise<Request[]> {
|
||||
if (msg.tabId === 'current') {
|
||||
const currentID = (await getCurrentTab()).id
|
||||
if (!currentID) {
|
||||
return [];
|
||||
}
|
||||
|
||||
msg.tabId = currentID;
|
||||
}
|
||||
|
||||
const tracker = this.mustGetTab({ tabId: msg.tabId as number })
|
||||
|
||||
if (!!msg.domain) {
|
||||
return tracker.forDomain(msg.domain)
|
||||
}
|
||||
|
||||
return tracker.allRequests()
|
||||
}
|
||||
|
||||
/** Callback for chrome.webRequest.onBeforeRequest */
|
||||
private handleOnBeforeRequest(details: chrome.webRequest.WebRequestDetails) {
|
||||
this.mustGetTab(details).trackRequest(details)
|
||||
|
||||
this.notifyRequests.next();
|
||||
}
|
||||
|
||||
/** Callback for chrome.webRequest.onErrorOccured */
|
||||
private handleOnErrorOccured(details: chrome.webRequest.WebResponseErrorDetails) {
|
||||
this.mustGetTab(details).trackError(details);
|
||||
|
||||
this.notifyRequests.next();
|
||||
}
|
||||
|
||||
/** Returns the tab-tracker for tabId. Creates a new tracker if none exists. */
|
||||
private mustGetTab({ tabId }: { tabId: number }): TabTracker {
|
||||
let tracker = this.trackers.get(tabId);
|
||||
if (!tracker) {
|
||||
tracker = new TabTracker(tabId)
|
||||
this.trackers.set(tabId, tracker)
|
||||
}
|
||||
|
||||
return tracker;
|
||||
}
|
||||
}
|
||||
|
||||
/** start the background service once we got successfully installed. */
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
new BackgroundService()
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Request } from "./tab-tracker";
|
||||
|
||||
export interface ListRequests {
|
||||
type: 'listRequests';
|
||||
domain?: string;
|
||||
tabId: number | 'current';
|
||||
}
|
||||
|
||||
export interface NotifyRequests {
|
||||
type: 'notifyRequests',
|
||||
requests: Request[];
|
||||
}
|
||||
|
||||
export type CallRequest = ListRequests;
|
||||
@@ -0,0 +1,126 @@
|
||||
import { deepClone } from "@safing/portmaster-api";
|
||||
|
||||
export interface Request {
|
||||
/** The ID assigned by the browser */
|
||||
id: string;
|
||||
|
||||
/** The domain this request was for */
|
||||
domain: string;
|
||||
|
||||
/** The timestamp in milliseconds since epoch at which the request was initiated */
|
||||
time: number;
|
||||
|
||||
/** Whether or not this request errored with net::ERR_ADDRESS_UNREACHABLE */
|
||||
isUnreachable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* TabTracker tracks requests to domains made by a single browser tab.
|
||||
*/
|
||||
export class TabTracker {
|
||||
/** A list of requests observed for this tab order by time they have been initiated */
|
||||
private requests: Request[] = [];
|
||||
|
||||
/** A lookup map for requests to specific domains */
|
||||
private byDomain = new Map<string, Request[]>();
|
||||
|
||||
/** A lookup map for requests by the chrome request ID */
|
||||
private byRequestId = new Map<string, Request>;
|
||||
|
||||
constructor(public readonly tabId: number) { }
|
||||
|
||||
/** Returns an array of all requests observed in this tab. */
|
||||
allRequests(): Request[] {
|
||||
return deepClone(this.requests)
|
||||
}
|
||||
|
||||
/** Returns a list of requests that have been observed for domain */
|
||||
forDomain(domain: string): Request[] {
|
||||
if (!domain.endsWith(".")) {
|
||||
domain += "."
|
||||
}
|
||||
|
||||
return this.byDomain.get(domain) || [];
|
||||
}
|
||||
|
||||
/** Call to add the details of a web-request to this tab-tracker */
|
||||
trackRequest(details: chrome.webRequest.WebRequestDetails) {
|
||||
// If this is the wrong tab ID ignore the request details
|
||||
if (details.tabId !== this.tabId) {
|
||||
console.error(`TabTracker.trackRequest: called with wrong tab ID. Expected ${this.tabId} but got ${details.tabId}`)
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// if the type of the request is for the main_frame the user switched to a new website.
|
||||
// In that case, we can wipe out all currently stored requests as the user will likely not
|
||||
// care anymore.
|
||||
if (details.type === "main_frame") {
|
||||
this.clearState();
|
||||
}
|
||||
|
||||
// get the domain of the request normalized to contain the trailing dot.
|
||||
let domain = new URL(details.url).host;
|
||||
if (!domain.endsWith(".")) {
|
||||
domain += "."
|
||||
}
|
||||
|
||||
const req: Request = {
|
||||
id: details.requestId,
|
||||
domain: domain,
|
||||
time: details.timeStamp,
|
||||
isUnreachable: false, // we don't actually know that yet
|
||||
}
|
||||
|
||||
this.requests.push(req);
|
||||
this.byRequestId.set(req.id, req)
|
||||
|
||||
// Add the request to the by-domain lookup map
|
||||
let byDomainRequests = this.byDomain.get(req.domain);
|
||||
if (!byDomainRequests) {
|
||||
byDomainRequests = [];
|
||||
this.byDomain.set(req.domain, byDomainRequests)
|
||||
}
|
||||
byDomainRequests.push(req)
|
||||
|
||||
console.log(`DEBUG: observed request ${req.id} to ${req.domain}`)
|
||||
}
|
||||
|
||||
/** Call to notify the tab-tracker of a request error */
|
||||
trackError(errorDetails: chrome.webRequest.WebResponseErrorDetails) {
|
||||
// we only care about net::ERR_ADDRESS_UNREACHABLE here because that's how the
|
||||
// Portmaster blocks the request.
|
||||
|
||||
// TODO(ppacher): docs say we must not rely on that value so we should figure out a better
|
||||
// way to detect if the error is caused by the Portmaster.
|
||||
if (errorDetails.error !== "net::ERR_ADDRESS_UNREACHABLE") {
|
||||
return;
|
||||
}
|
||||
|
||||
// the the previsouly observed request by the request ID.
|
||||
const req = this.byRequestId.get(errorDetails.requestId)
|
||||
if (!req) {
|
||||
console.error("TabTracker.trackError: request has not been observed before")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// make sure the error details actually happend for the observed tab.
|
||||
if (errorDetails.tabId !== this.tabId) {
|
||||
console.error(`TabTracker.trackRequest: called with wrong tab ID. Expected ${this.tabId} but got ${errorDetails.tabId}`)
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// mark the request as unreachable.
|
||||
req.isUnreachable = true;
|
||||
console.log(`DEBUG: marked request ${req.id} to ${req.domain} as unreachable`)
|
||||
}
|
||||
|
||||
/** Clears the current state of the tab tracker */
|
||||
private clearState() {
|
||||
this.requests = [];
|
||||
this.byDomain = new Map();
|
||||
this.byRequestId = new Map();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
|
||||
/** Queries and returns the currently active tab */
|
||||
export function getCurrentTab(): Promise<chrome.tabs.Tab> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.tabs.query({ active: true, lastFocusedWindow: true }, ([tab]) => {
|
||||
resolve(tab);
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const environment = {
|
||||
production: false
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
// This file can be replaced during build by using the `fileReplacements` array.
|
||||
// `ng build` replaces `environment.ts` with `environment.prod.ts`.
|
||||
// The list of file replacements can be found in `angular.json`.
|
||||
|
||||
export const environment = {
|
||||
production: false
|
||||
};
|
||||
|
||||
/*
|
||||
* For easier debugging in development mode, you can import the following file
|
||||
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
|
||||
*
|
||||
* This import should be commented out in production mode because it will have a negative impact
|
||||
* on performance if an error is thrown.
|
||||
*/
|
||||
// import 'zone.js/plugins/zone-error'; // Included with Angular CLI.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 948 B |
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>PortmasterChromeExtension</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>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { enableProdMode } from '@angular/core';
|
||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
||||
|
||||
import { AppModule } from './app/app.module';
|
||||
import { environment } from './environments/environment';
|
||||
|
||||
if (environment.production) {
|
||||
enableProdMode();
|
||||
}
|
||||
|
||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
||||
.catch(err => console.error(err));
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Portmaster Browser Extension",
|
||||
"version": "0.1",
|
||||
"description": "Browser Extension for even better Portmaster integration",
|
||||
"manifest_version": 2,
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"storage",
|
||||
"webRequest",
|
||||
"webNavigation",
|
||||
"*://*/*"
|
||||
],
|
||||
"browser_action": {
|
||||
"default_popup": "index.html",
|
||||
"default_icon": {
|
||||
"128": "assets/icon_128.png"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"scripts": ["background.js"],
|
||||
"persistent": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* This file includes polyfills needed by Angular and is loaded before the app.
|
||||
* You can add your own extra polyfills to this file.
|
||||
*
|
||||
* This file is divided into 2 sections:
|
||||
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
|
||||
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
|
||||
* file.
|
||||
*
|
||||
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
|
||||
* automatically update themselves. This includes recent versions of Safari, Chrome (including
|
||||
* Opera), Edge on the desktop, and iOS and Chrome on mobile.
|
||||
*
|
||||
* Learn more in https://angular.io/guide/browser-support
|
||||
*/
|
||||
|
||||
/***************************************************************************************************
|
||||
* BROWSER POLYFILLS
|
||||
*/
|
||||
|
||||
/**
|
||||
* By default, zone.js will patch all possible macroTask and DomEvents
|
||||
* user can disable parts of macroTask/DomEvents patch by setting following flags
|
||||
* because those flags need to be set before `zone.js` being loaded, and webpack
|
||||
* will put import in the top of bundle, so user need to create a separate file
|
||||
* in this directory (for example: zone-flags.ts), and put the following flags
|
||||
* into that file, and then add the following code before importing zone.js.
|
||||
* import './zone-flags';
|
||||
*
|
||||
* The flags allowed in zone-flags.ts are listed here.
|
||||
*
|
||||
* The following flags will work for all browsers.
|
||||
*
|
||||
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
|
||||
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
|
||||
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
|
||||
*
|
||||
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
|
||||
* with the following flag, it will bypass `zone.js` patch for IE/Edge
|
||||
*
|
||||
* (window as any).__Zone_enable_cross_context_check = true;
|
||||
*
|
||||
*/
|
||||
|
||||
/***************************************************************************************************
|
||||
* Zone JS is required by default for Angular itself.
|
||||
*/
|
||||
import 'zone.js'; // Included with Angular CLI.
|
||||
|
||||
|
||||
/***************************************************************************************************
|
||||
* APPLICATION IMPORTS
|
||||
*/
|
||||
@@ -0,0 +1,8 @@
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
|
||||
|
||||
@import '@angular/cdk/overlay-prebuilt';
|
||||
@@ -0,0 +1,14 @@
|
||||
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
|
||||
|
||||
import 'zone.js/testing';
|
||||
import { getTestBed } from '@angular/core/testing';
|
||||
import {
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting
|
||||
} from '@angular/platform-browser-dynamic/testing';
|
||||
|
||||
// First, initialize the Angular testing environment.
|
||||
getTestBed().initTestEnvironment(
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting(),
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../out-tsc/app",
|
||||
"types": [
|
||||
"chrome"
|
||||
]
|
||||
},
|
||||
"files": [
|
||||
"src/main.ts",
|
||||
"src/polyfills.ts",
|
||||
"src/background.ts"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"files": [
|
||||
"src/test.ts",
|
||||
"src/polyfills.ts"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
24
desktop/angular/projects/safing/portmaster-api/README.md
Normal file
24
desktop/angular/projects/safing/portmaster-api/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# PortmasterApi
|
||||
|
||||
This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 14.0.0.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Run `ng generate component component-name --project portmaster-api` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project portmaster-api`.
|
||||
> Note: Don't forget to add `--project portmaster-api` or else it will be added to the default project in your `angular.json` file.
|
||||
|
||||
## Build
|
||||
|
||||
Run `ng build portmaster-api` to build the project. The build artifacts will be stored in the `dist/` directory.
|
||||
|
||||
## Publishing
|
||||
|
||||
After building your library with `ng build portmaster-api`, go to the dist folder `cd dist/portmaster-api` and run `npm publish`.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `ng test portmaster-api` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||
|
||||
## Further help
|
||||
|
||||
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.
|
||||
44
desktop/angular/projects/safing/portmaster-api/karma.conf.js
Normal file
44
desktop/angular/projects/safing/portmaster-api/karma.conf.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// Karma configuration file, see link for more information
|
||||
// https://karma-runner.github.io/1.0/config/configuration-file.html
|
||||
|
||||
module.exports = function (config) {
|
||||
config.set({
|
||||
basePath: '',
|
||||
frameworks: ['jasmine', '@angular-devkit/build-angular'],
|
||||
plugins: [
|
||||
require('karma-jasmine'),
|
||||
require('karma-chrome-launcher'),
|
||||
require('karma-jasmine-html-reporter'),
|
||||
require('karma-coverage'),
|
||||
require('@angular-devkit/build-angular/plugins/karma')
|
||||
],
|
||||
client: {
|
||||
jasmine: {
|
||||
// you can add configuration options for Jasmine here
|
||||
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
|
||||
// for example, you can disable the random execution with `random: false`
|
||||
// or set a specific seed with `seed: 4321`
|
||||
},
|
||||
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
||||
},
|
||||
jasmineHtmlReporter: {
|
||||
suppressAll: true // removes the duplicated traces
|
||||
},
|
||||
coverageReporter: {
|
||||
dir: require('path').join(__dirname, '../../../coverage/safing/portmaster-api'),
|
||||
subdir: '.',
|
||||
reporters: [
|
||||
{ type: 'html' },
|
||||
{ type: 'text-summary' }
|
||||
]
|
||||
},
|
||||
reporters: ['progress', 'kjhtml'],
|
||||
port: 9876,
|
||||
colors: true,
|
||||
logLevel: config.LOG_INFO,
|
||||
autoWatch: true,
|
||||
browsers: ['Chrome'],
|
||||
singleRun: false,
|
||||
restartOnFileChange: true
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "../../../dist-lib/safing/portmaster-api",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
}
|
||||
}
|
||||
132
desktop/angular/projects/safing/portmaster-api/package-lock.json
generated
Normal file
132
desktop/angular/projects/safing/portmaster-api/package-lock.json
generated
Normal file
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"name": "@safing/portmaster-api",
|
||||
"version": "0.0.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@safing/portmaster-api",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jasmine": "^4.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^14.0.0",
|
||||
"@angular/core": "^14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/common": {
|
||||
"version": "14.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-14.0.5.tgz",
|
||||
"integrity": "sha512-YFRPxx3yRLjk0gPL7tm/97mi8+Pjt3q6zWCjrLkAlDjniDvgmKNWIQ1h6crZQR0Cw7yNqK0QoFXQgTw0GJIWLQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || >=16.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "14.0.5",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/core": {
|
||||
"version": "14.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-14.0.5.tgz",
|
||||
"integrity": "sha512-4MIfFM2nD+N0/Dk8xKfKvbdS/zYRhQgdnKT6ZIIV7Y/XCfn5QAIa4+vB5BEAZpuzSsZHLVdBQQ0TkaiONLfL2Q==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || >=16.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rxjs": "^6.5.3 || ^7.4.0",
|
||||
"zone.js": "~0.11.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jasmine": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-4.0.3.tgz",
|
||||
"integrity": "sha512-Opp1LvvEuZdk8fSSvchK2mZwhVrsNT0JgJE9Di6MjnaIpmEXM8TLCPPrVtNTYh8+5MPdY8j9bAHMu2SSfwpZJg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.5.6",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.6.tgz",
|
||||
"integrity": "sha512-dnyv2/YsXhnm461G+R/Pe5bWP41Nm6LBXEYWI6eiFP4fiwx6WRI/CD0zbdVAudd9xwLEF2IDcKXLHit0FYjUzw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
|
||||
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
|
||||
},
|
||||
"node_modules/zone.js": {
|
||||
"version": "0.11.6",
|
||||
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.11.6.tgz",
|
||||
"integrity": "sha512-umJqFtKyZlPli669gB1gOrRE9hxUUGkZr7mo878z+NEBJZZixJkKeVYfnoLa7g25SseUDc92OZrMKKHySyJrFg==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/common": {
|
||||
"version": "14.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-14.0.5.tgz",
|
||||
"integrity": "sha512-YFRPxx3yRLjk0gPL7tm/97mi8+Pjt3q6zWCjrLkAlDjniDvgmKNWIQ1h6crZQR0Cw7yNqK0QoFXQgTw0GJIWLQ==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"tslib": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"@angular/core": {
|
||||
"version": "14.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-14.0.5.tgz",
|
||||
"integrity": "sha512-4MIfFM2nD+N0/Dk8xKfKvbdS/zYRhQgdnKT6ZIIV7Y/XCfn5QAIa4+vB5BEAZpuzSsZHLVdBQQ0TkaiONLfL2Q==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"tslib": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"@types/jasmine": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-4.0.3.tgz",
|
||||
"integrity": "sha512-Opp1LvvEuZdk8fSSvchK2mZwhVrsNT0JgJE9Di6MjnaIpmEXM8TLCPPrVtNTYh8+5MPdY8j9bAHMu2SSfwpZJg==",
|
||||
"dev": true
|
||||
},
|
||||
"rxjs": {
|
||||
"version": "7.5.6",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.6.tgz",
|
||||
"integrity": "sha512-dnyv2/YsXhnm461G+R/Pe5bWP41Nm6LBXEYWI6eiFP4fiwx6WRI/CD0zbdVAudd9xwLEF2IDcKXLHit0FYjUzw==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"tslib": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
|
||||
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
|
||||
},
|
||||
"zone.js": {
|
||||
"version": "0.11.6",
|
||||
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.11.6.tgz",
|
||||
"integrity": "sha512-umJqFtKyZlPli669gB1gOrRE9hxUUGkZr7mo878z+NEBJZZixJkKeVYfnoLa7g25SseUDc92OZrMKKHySyJrFg==",
|
||||
"peer": true,
|
||||
"requires": {
|
||||
"tslib": "^2.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
desktop/angular/projects/safing/portmaster-api/package.json
Normal file
14
desktop/angular/projects/safing/portmaster-api/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@safing/portmaster-api",
|
||||
"version": "0.0.1",
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^14.0.0",
|
||||
"@angular/core": "^14.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jasmine": "^4.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Inject, Injectable } from '@angular/core';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { filter, finalize, map, mergeMap, share, take } from 'rxjs/operators';
|
||||
import {
|
||||
AppProfile,
|
||||
FlatConfigObject,
|
||||
LayeredProfile,
|
||||
TagDescription,
|
||||
flattenProfileConfig,
|
||||
} from './app-profile.types';
|
||||
import {
|
||||
PORTMASTER_HTTP_API_ENDPOINT,
|
||||
PortapiService,
|
||||
} from './portapi.service';
|
||||
import { Process } from './portapi.types';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AppProfileService {
|
||||
private watchedProfiles = new Map<string, Observable<AppProfile>>();
|
||||
|
||||
constructor(
|
||||
private portapi: PortapiService,
|
||||
private http: HttpClient,
|
||||
@Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Returns the database key of a profile.
|
||||
*
|
||||
* @param source The source of the profile.
|
||||
* @param id The profile ID.
|
||||
*/
|
||||
getKey(source: string, id: string): string;
|
||||
|
||||
/**
|
||||
* Returns the database key of a profile
|
||||
*
|
||||
* @param p The app-profile itself..
|
||||
*/
|
||||
getKey(p: AppProfile): string;
|
||||
|
||||
getKey(idOrSourceOrProfile: string | AppProfile, id?: string): string {
|
||||
if (typeof idOrSourceOrProfile === 'object') {
|
||||
return this.getKey(idOrSourceOrProfile.Source, idOrSourceOrProfile.ID);
|
||||
}
|
||||
|
||||
let key = idOrSourceOrProfile;
|
||||
|
||||
if (!!id) {
|
||||
key = `core:profiles/${idOrSourceOrProfile}/${id}`;
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an application profile.
|
||||
*
|
||||
* @param sourceAndId The full profile ID including source
|
||||
*/
|
||||
getAppProfile(sourceAndId: string): Observable<AppProfile>;
|
||||
|
||||
/**
|
||||
* Load an application profile.
|
||||
*
|
||||
* @param source The source of the profile
|
||||
* @param id The ID of the profile
|
||||
*/
|
||||
getAppProfile(source: string, id: string): Observable<AppProfile>;
|
||||
|
||||
getAppProfile(
|
||||
sourceOrSourceAndID: string,
|
||||
id?: string
|
||||
): Observable<AppProfile> {
|
||||
let source = sourceOrSourceAndID;
|
||||
if (id !== undefined) {
|
||||
source += '/' + id;
|
||||
}
|
||||
const key = `core:profiles/${source}`;
|
||||
|
||||
if (this.watchedProfiles.has(key)) {
|
||||
return this.watchedProfiles.get(key)!.pipe(take(1));
|
||||
}
|
||||
|
||||
return this.getAppProfileFromKey(key);
|
||||
}
|
||||
|
||||
setProfileIcon(
|
||||
content: string | ArrayBuffer,
|
||||
mimeType: string
|
||||
): Observable<{ filename: string }> {
|
||||
return this.http.post<{ filename: string }>(
|
||||
`${this.httpAPI}/v1/profile/icon`,
|
||||
content,
|
||||
{
|
||||
headers: new HttpHeaders({
|
||||
'Content-Type': mimeType,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an application profile by it's database key.
|
||||
*
|
||||
* @param key The key of the application profile.
|
||||
*/
|
||||
getAppProfileFromKey(key: string): Observable<AppProfile> {
|
||||
return this.portapi.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the global-configuration profile.
|
||||
*/
|
||||
globalConfig(): Observable<FlatConfigObject> {
|
||||
return this.getAppProfile('special', 'global-config').pipe(
|
||||
map((profile) => flattenProfileConfig(profile.Config))
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns all possible process tags. */
|
||||
tagDescriptions(): Observable<TagDescription[]> {
|
||||
return this.http
|
||||
.get<{ Tags: TagDescription[] }>(`${this.httpAPI}/v1/process/tags`)
|
||||
.pipe(map((result) => result.Tags));
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches an application profile for changes.
|
||||
*
|
||||
* @param source The source of the profile
|
||||
* @param id The ID of the profile
|
||||
*/
|
||||
watchAppProfile(sourceAndId: string): Observable<AppProfile>;
|
||||
/**
|
||||
* Watches an application profile for changes.
|
||||
*
|
||||
* @param source The source of the profile
|
||||
* @param id The ID of the profile
|
||||
*/
|
||||
watchAppProfile(source: string, id: string): Observable<AppProfile>;
|
||||
|
||||
watchAppProfile(sourceAndId: string, id?: string): Observable<AppProfile> {
|
||||
let key = '';
|
||||
|
||||
if (id === undefined) {
|
||||
key = sourceAndId;
|
||||
if (!key.startsWith('core:profiles/')) {
|
||||
key = `core:profiles/${key}`;
|
||||
}
|
||||
} else {
|
||||
key = `core:profiles/${sourceAndId}/${id}`;
|
||||
}
|
||||
|
||||
if (this.watchedProfiles.has(key)) {
|
||||
return this.watchedProfiles.get(key)!;
|
||||
}
|
||||
|
||||
const stream = this.portapi.get<AppProfile>(key).pipe(
|
||||
mergeMap(() => this.portapi.watch<AppProfile>(key)),
|
||||
finalize(() => {
|
||||
console.log(
|
||||
'watchAppProfile: removing cached profile stream for ' + key
|
||||
);
|
||||
this.watchedProfiles.delete(key);
|
||||
}),
|
||||
share({
|
||||
connector: () => new BehaviorSubject<AppProfile | null>(null),
|
||||
resetOnRefCountZero: true,
|
||||
}),
|
||||
filter((profile) => profile !== null)
|
||||
) as Observable<AppProfile>;
|
||||
|
||||
this.watchedProfiles.set(key, stream);
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
/** @deprecated use saveProfile instead */
|
||||
saveLocalProfile(profile: AppProfile): Observable<void> {
|
||||
return this.saveProfile(profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an application profile.
|
||||
*
|
||||
* @param profile The profile to save
|
||||
*/
|
||||
saveProfile(profile: AppProfile): Observable<void> {
|
||||
profile.LastEdited = Math.floor(new Date().getTime() / 1000);
|
||||
return this.portapi.update(
|
||||
`core:profiles/${profile.Source}/${profile.ID}`,
|
||||
profile
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch all application profiles
|
||||
*/
|
||||
watchProfiles(): Observable<AppProfile[]> {
|
||||
return this.portapi.watchAll<AppProfile>('core:profiles/');
|
||||
}
|
||||
|
||||
watchLayeredProfile(source: string, id: string): Observable<LayeredProfile>;
|
||||
|
||||
/**
|
||||
* Watches the layered runtime profile for a given application
|
||||
* profile.
|
||||
*
|
||||
* @param profile The app profile
|
||||
*/
|
||||
watchLayeredProfile(profile: AppProfile): Observable<LayeredProfile>;
|
||||
|
||||
watchLayeredProfile(
|
||||
profileOrSource: string | AppProfile,
|
||||
id?: string
|
||||
): Observable<LayeredProfile> {
|
||||
if (typeof profileOrSource == 'object') {
|
||||
id = profileOrSource.ID;
|
||||
profileOrSource = profileOrSource.Source;
|
||||
}
|
||||
|
||||
const key = `runtime:layeredProfile/${profileOrSource}/${id}`;
|
||||
return this.portapi.watch<LayeredProfile>(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the layered runtime profile for a given application
|
||||
* profile.
|
||||
*
|
||||
* @param profile The app profile
|
||||
*/
|
||||
getLayeredProfile(profile: AppProfile): Observable<LayeredProfile> {
|
||||
const key = `runtime:layeredProfile/${profile.Source}/${profile.ID}`;
|
||||
return this.portapi.get<LayeredProfile>(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an application profile.
|
||||
*
|
||||
* @param profile The profile to delete
|
||||
*/
|
||||
deleteProfile(profile: AppProfile): Observable<void> {
|
||||
return this.portapi.delete(`core:profiles/${profile.Source}/${profile.ID}`);
|
||||
}
|
||||
|
||||
getProcessesByProfile(profileOrId: AppProfile | string): Observable<Process[]> {
|
||||
if (typeof profileOrId === 'object') {
|
||||
profileOrId = profileOrId.Source + "/" + profileOrId.ID
|
||||
}
|
||||
|
||||
return this.http.get<Process[]>(`${this.httpAPI}/v1/process/list/by-profile/${profileOrId}`)
|
||||
}
|
||||
|
||||
getProcessByPid(pid: number): Observable<Process> {
|
||||
return this.http.get<Process>(`${this.httpAPI}/v1/process/group-leader/${pid}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { BaseSetting, OptionValueType, SettingValueType } from './config.types';
|
||||
import { SecurityLevel } from './core.types';
|
||||
import { Record } from './portapi.types';
|
||||
|
||||
export interface ConfigMap {
|
||||
[key: string]: ConfigObject;
|
||||
}
|
||||
|
||||
export type ConfigObject = OptionValueType | ConfigMap;
|
||||
|
||||
export interface FlatConfigObject {
|
||||
[key: string]: OptionValueType;
|
||||
}
|
||||
|
||||
|
||||
export interface LayeredProfile extends Record {
|
||||
// LayerIDs is a list of all profiles that are used
|
||||
// by this layered profile. Profiles are evaluated in
|
||||
// order.
|
||||
LayerIDs: string[];
|
||||
|
||||
// The current revision counter of the layered profile.
|
||||
RevisionCounter: number;
|
||||
}
|
||||
|
||||
export enum FingerprintType {
|
||||
Tag = 'tag',
|
||||
Cmdline = 'cmdline',
|
||||
Env = 'env',
|
||||
Path = 'path',
|
||||
}
|
||||
|
||||
export enum FingerpringOperation {
|
||||
Equal = 'equals',
|
||||
Prefix = 'prefix',
|
||||
Regex = 'regex',
|
||||
}
|
||||
|
||||
export interface Fingerprint {
|
||||
Type: FingerprintType;
|
||||
Key: string;
|
||||
Operation: FingerpringOperation;
|
||||
Value: string;
|
||||
}
|
||||
|
||||
export interface TagDescription {
|
||||
ID: string;
|
||||
Name: string;
|
||||
Description: string;
|
||||
}
|
||||
|
||||
export interface Icon {
|
||||
Type: 'database' | 'path' | 'api';
|
||||
Source: '' | 'user' | 'import' | 'core' | 'ui';
|
||||
Value: string;
|
||||
}
|
||||
|
||||
export interface AppProfile extends Record {
|
||||
ID: string;
|
||||
LinkedPath: string; // deprecated
|
||||
PresentationPath: string;
|
||||
Fingerprints: Fingerprint[];
|
||||
Created: number;
|
||||
LastEdited: number;
|
||||
Config?: ConfigMap;
|
||||
Description: string;
|
||||
Warning: string;
|
||||
WarningLastUpdated: string;
|
||||
Homepage: string;
|
||||
Icons: Icon[];
|
||||
Name: string;
|
||||
Internal: boolean;
|
||||
SecurityLevel: SecurityLevel;
|
||||
Source: 'local';
|
||||
}
|
||||
|
||||
// flattenProfileConfig returns a flat version of a nested ConfigMap where each property
|
||||
// can be used as the database key for the associated setting.
|
||||
export function flattenProfileConfig(
|
||||
p?: ConfigMap,
|
||||
prefix = ''
|
||||
): FlatConfigObject {
|
||||
if (p === null || p === undefined) {
|
||||
return {}
|
||||
}
|
||||
|
||||
let result: FlatConfigObject = {};
|
||||
|
||||
Object.keys(p).forEach((key) => {
|
||||
const childPrefix = prefix === '' ? key : `${prefix}/${key}`;
|
||||
|
||||
const prop = p[key];
|
||||
|
||||
if (isConfigMap(prop)) {
|
||||
const flattened = flattenProfileConfig(prop, childPrefix);
|
||||
result = mergeObjects(result, flattened);
|
||||
return;
|
||||
}
|
||||
|
||||
result[childPrefix] = prop;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current value (or null) of a setting stored in a config
|
||||
* map by path.
|
||||
*
|
||||
* @param obj The ConfigMap object
|
||||
* @param path The path of the setting separated by foward slashes.
|
||||
*/
|
||||
export function getAppSetting<T extends OptionValueType>(
|
||||
obj: ConfigMap | null | undefined,
|
||||
path: string
|
||||
): T | null {
|
||||
if (obj === null || obj === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parts = path.split('/');
|
||||
|
||||
let iter = obj;
|
||||
for (let idx = 0; idx < parts.length; idx++) {
|
||||
const propName = parts[idx];
|
||||
|
||||
if (iter[propName] === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = iter[propName];
|
||||
if (idx === parts.length - 1) {
|
||||
return value as T;
|
||||
}
|
||||
|
||||
if (!isConfigMap(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
iter = value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getActualValue<S extends BaseSetting<any, any>>(
|
||||
s: S
|
||||
): SettingValueType<S> {
|
||||
if (s.Value !== undefined) {
|
||||
return s.Value;
|
||||
}
|
||||
if (s.GlobalDefault !== undefined) {
|
||||
return s.GlobalDefault;
|
||||
}
|
||||
return s.DefaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of a settings inside the nested config object.
|
||||
*
|
||||
* @param obj THe config object
|
||||
* @param path The path of the setting
|
||||
* @param value The new value to set.
|
||||
*/
|
||||
export function setAppSetting(obj: ConfigObject, path: string, value: any) {
|
||||
const parts = path.split('/');
|
||||
if (typeof obj !== 'object' || Array.isArray(obj)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let iter = obj;
|
||||
for (let idx = 0; idx < parts.length; idx++) {
|
||||
const propName = parts[idx];
|
||||
|
||||
if (idx === parts.length - 1) {
|
||||
if (value === undefined) {
|
||||
delete iter[propName];
|
||||
} else {
|
||||
iter[propName] = value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (iter[propName] === undefined) {
|
||||
iter[propName] = {};
|
||||
}
|
||||
|
||||
iter = iter[propName] as ConfigMap;
|
||||
}
|
||||
}
|
||||
|
||||
/** Typeguard to ensure v is a ConfigMap */
|
||||
function isConfigMap(v: any): v is ConfigMap {
|
||||
return typeof v === 'object' && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new flat-config object that contains values from both
|
||||
* parameters.
|
||||
*
|
||||
* @param a The first config object
|
||||
* @param b The second config object
|
||||
*/
|
||||
function mergeObjects(
|
||||
a: FlatConfigObject,
|
||||
b: FlatConfigObject
|
||||
): FlatConfigObject {
|
||||
var res: FlatConfigObject = {};
|
||||
Object.keys(a).forEach((key) => {
|
||||
res[key] = a[key];
|
||||
});
|
||||
Object.keys(b).forEach((key) => {
|
||||
res[key] = b[key];
|
||||
});
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Injectable, TrackByFunction } from '@angular/core';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { distinctUntilChanged, filter, map, share, toArray } from 'rxjs/operators';
|
||||
import { BaseSetting, BoolSetting, OptionType, Setting, SettingValueType } from './config.types';
|
||||
import { PortapiService } from './portapi.service';
|
||||
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ConfigService {
|
||||
networkRatingEnabled$: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* A {@link TrackByFunction} for tracking settings.
|
||||
*/
|
||||
static trackBy: TrackByFunction<Setting> = (_: number, obj: Setting) => obj.Name;
|
||||
readonly trackBy = ConfigService.trackBy;
|
||||
|
||||
/** configPrefix is the database key prefix for the config db */
|
||||
readonly configPrefix = "config:";
|
||||
|
||||
constructor(private portapi: PortapiService) {
|
||||
this.networkRatingEnabled$ = this.watch<BoolSetting>("core/enableNetworkRating")
|
||||
.pipe(
|
||||
share({ connector: () => new BehaviorSubject(false) }),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a configuration setting from the database.
|
||||
*
|
||||
* @param key The key of the configuration setting.
|
||||
*/
|
||||
get(key: string): Observable<Setting> {
|
||||
return this.portapi.get<Setting>(this.configPrefix + key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all configuration settings that match query. Note that in
|
||||
* contrast to {@link PortAPI} settings values are collected into
|
||||
* an array before being emitted. This allows simple usage in *ngFor
|
||||
* and friends.
|
||||
*
|
||||
* @param query The query used to search for configuration settings.
|
||||
*/
|
||||
query(query: string): Observable<Setting[]> {
|
||||
return this.portapi.query<Setting>(this.configPrefix + query)
|
||||
.pipe(
|
||||
map(setting => setting.data),
|
||||
toArray()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a setting.
|
||||
*
|
||||
* @param s The setting to save. Note that the new value should already be set to {@property Value}.
|
||||
*/
|
||||
save(s: Setting): Observable<void>;
|
||||
|
||||
/**
|
||||
* Save a setting.
|
||||
*
|
||||
* @param key The key of the configuration setting
|
||||
* @param value The new value of the setting.
|
||||
*/
|
||||
save(key: string, value: any): Observable<void>;
|
||||
|
||||
// save is overloaded, see above.
|
||||
save(s: Setting | string, v?: any): Observable<void> {
|
||||
if (typeof s === 'string') {
|
||||
return this.portapi.update(this.configPrefix + s, {
|
||||
Key: s,
|
||||
Value: v,
|
||||
});
|
||||
}
|
||||
return this.portapi.update(this.configPrefix + s.Key, s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch a configuration setting.
|
||||
*
|
||||
* @param key The key of the setting to watch.
|
||||
*/
|
||||
watch<T extends Setting>(key: string): Observable<SettingValueType<T>> {
|
||||
return this.portapi.qsub<BaseSetting<SettingValueType<T>, any>>(this.configPrefix + key)
|
||||
.pipe(
|
||||
filter(value => value.key === this.configPrefix + key), // qsub does a query so filter for our key.
|
||||
map(value => value.data),
|
||||
map(value => value.Value !== undefined ? value.Value : value.DefaultValue),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if a value is valid for a given option.
|
||||
*
|
||||
* @param spec The option specification (as returned by get()).
|
||||
* @param value The value that should be tested.
|
||||
*/
|
||||
validate<S extends Setting>(spec: S, value: SettingValueType<S>) {
|
||||
if (!spec.ValidationRegex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const re = new RegExp(spec.ValidationRegex);
|
||||
|
||||
switch (spec.OptType) {
|
||||
case OptionType.Int:
|
||||
case OptionType.Bool:
|
||||
// todo(ppacher): do we validate that?
|
||||
return
|
||||
case OptionType.String:
|
||||
if (!re.test(value as string)) {
|
||||
throw new Error(`${value} does not match ${spec.ValidationRegex}`)
|
||||
}
|
||||
return;
|
||||
case OptionType.StringArray:
|
||||
(value as string[]).forEach(v => {
|
||||
if (!re.test(v as string)) {
|
||||
throw new Error(`${value} does not match ${spec.ValidationRegex}`)
|
||||
}
|
||||
});
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { FeatureID } from './features';
|
||||
import { Record } from './portapi.types';
|
||||
import { deepClone } from './utils';
|
||||
|
||||
/**
|
||||
* ExpertiseLevel defines all available expertise levels.
|
||||
*/
|
||||
export enum ExpertiseLevel {
|
||||
User = 'user',
|
||||
Expert = 'expert',
|
||||
Developer = 'developer',
|
||||
}
|
||||
|
||||
export enum ExpertiseLevelNumber {
|
||||
user = 0,
|
||||
expert = 1,
|
||||
developer = 2
|
||||
}
|
||||
|
||||
export function getExpertiseLevelNumber(lvl: ExpertiseLevel): ExpertiseLevelNumber {
|
||||
switch (lvl) {
|
||||
case ExpertiseLevel.User:
|
||||
return ExpertiseLevelNumber.user;
|
||||
case ExpertiseLevel.Expert:
|
||||
return ExpertiseLevelNumber.expert;
|
||||
case ExpertiseLevel.Developer:
|
||||
return ExpertiseLevelNumber.developer
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OptionType defines the type of an option as stored in
|
||||
* the backend. Note that ExternalOptionHint may be used
|
||||
* to request a different visual representation and edit
|
||||
* menu on a per-option basis.
|
||||
*/
|
||||
export enum OptionType {
|
||||
String = 1,
|
||||
StringArray = 2,
|
||||
Int = 3,
|
||||
Bool = 4,
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an option type to it's string representation.
|
||||
*
|
||||
* @param opt The option type to convert
|
||||
*/
|
||||
export function optionTypeName(opt: OptionType): string {
|
||||
switch (opt) {
|
||||
case OptionType.String:
|
||||
return 'string';
|
||||
case OptionType.StringArray:
|
||||
return '[]string';
|
||||
case OptionType.Int:
|
||||
return 'int'
|
||||
case OptionType.Bool:
|
||||
return 'bool'
|
||||
}
|
||||
}
|
||||
|
||||
/** The actual type an option value can be */
|
||||
export type OptionValueType = string | string[] | number | boolean;
|
||||
|
||||
/** Type-guard for string option types */
|
||||
export function isStringType(opt: OptionType, vt: OptionValueType): vt is string {
|
||||
return opt === OptionType.String;
|
||||
}
|
||||
|
||||
/** Type-guard for string-array option types */
|
||||
export function isStringArrayType(opt: OptionType, vt: OptionValueType): vt is string[] {
|
||||
return opt === OptionType.StringArray;
|
||||
}
|
||||
|
||||
/** Type-guard for number option types */
|
||||
export function isNumberType(opt: OptionType, vt: OptionValueType): vt is number {
|
||||
return opt === OptionType.Int;
|
||||
}
|
||||
|
||||
/** Type-guard for boolean option types */
|
||||
export function isBooleanType(opt: OptionType, vt: OptionValueType): vt is boolean {
|
||||
return opt === OptionType.Bool;
|
||||
}
|
||||
|
||||
/**
|
||||
* ReleaseLevel defines the available release and maturity
|
||||
* levels.
|
||||
*/
|
||||
export enum ReleaseLevel {
|
||||
Stable = 0,
|
||||
Beta = 1,
|
||||
Experimental = 2,
|
||||
}
|
||||
|
||||
export function releaseLevelFromName(name: 'stable' | 'beta' | 'experimental'): ReleaseLevel {
|
||||
switch (name) {
|
||||
case 'stable':
|
||||
return ReleaseLevel.Stable;
|
||||
case 'beta':
|
||||
return ReleaseLevel.Beta;
|
||||
case 'experimental':
|
||||
return ReleaseLevel.Experimental;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* releaseLevelName returns a string representation of the
|
||||
* release level.
|
||||
*
|
||||
* @args level The release level to convert.
|
||||
*/
|
||||
export function releaseLevelName(level: ReleaseLevel): string {
|
||||
switch (level) {
|
||||
case ReleaseLevel.Stable:
|
||||
return 'stable'
|
||||
case ReleaseLevel.Beta:
|
||||
return 'beta'
|
||||
case ReleaseLevel.Experimental:
|
||||
return 'experimental'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ExternalOptionHint tells the UI to use a different visual
|
||||
* representation and edit menu that the options value would
|
||||
* imply.
|
||||
*/
|
||||
export enum ExternalOptionHint {
|
||||
SecurityLevel = 'security level',
|
||||
EndpointList = 'endpoint list',
|
||||
FilterList = 'filter list',
|
||||
OneOf = 'one-of',
|
||||
OrderedList = 'ordered'
|
||||
}
|
||||
|
||||
/** A list of well-known option annotation keys. */
|
||||
export enum WellKnown {
|
||||
DisplayHint = "safing/portbase:ui:display-hint",
|
||||
Order = "safing/portbase:ui:order",
|
||||
Unit = "safing/portbase:ui:unit",
|
||||
Category = "safing/portbase:ui:category",
|
||||
Subsystem = "safing/portbase:module:subsystem",
|
||||
Stackable = "safing/portbase:options:stackable",
|
||||
QuickSetting = "safing/portbase:ui:quick-setting",
|
||||
Requires = "safing/portbase:config:requires",
|
||||
RestartPending = "safing/portbase:options:restart-pending",
|
||||
EndpointListVerdictNames = "safing/portmaster:ui:endpoint-list:verdict-names",
|
||||
RequiresFeatureID = "safing/portmaster:ui:config:requires-feature",
|
||||
RequiresUIReload = "safing/portmaster:ui:requires-reload",
|
||||
}
|
||||
|
||||
/**
|
||||
* Annotations describes the annoations object of a configuration
|
||||
* setting. Well-known annotations are stricktly typed.
|
||||
*/
|
||||
export interface Annotations<T extends OptionValueType> {
|
||||
// Well known option annoations and their
|
||||
// types.
|
||||
[WellKnown.DisplayHint]?: ExternalOptionHint;
|
||||
[WellKnown.Order]?: number;
|
||||
[WellKnown.Unit]?: string;
|
||||
[WellKnown.Category]?: string;
|
||||
[WellKnown.Subsystem]?: string;
|
||||
[WellKnown.Stackable]?: true;
|
||||
[WellKnown.QuickSetting]?: QuickSetting<T> | QuickSetting<T>[] | CountrySelectionQuickSetting<T> | CountrySelectionQuickSetting<T>[];
|
||||
[WellKnown.Requires]?: ValueRequirement | ValueRequirement[];
|
||||
[WellKnown.RequiresFeatureID]?: FeatureID | FeatureID[];
|
||||
[WellKnown.RequiresUIReload]?: unknown,
|
||||
// Any thing else...
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface PossilbeValue<T = any> {
|
||||
/** Name is the name of the value and should be displayed */
|
||||
Name: string;
|
||||
/** Description may hold an additional description of the value */
|
||||
Description: string;
|
||||
/** Value is the actual value expected by the portmaster */
|
||||
Value: T;
|
||||
}
|
||||
|
||||
export interface QuickSetting<T extends OptionValueType> {
|
||||
// Name is the name of the quick setting.
|
||||
Name: string;
|
||||
// Value is the value that the quick-setting configures. It must match
|
||||
// the expected value type of the annotated option.
|
||||
Value: T;
|
||||
// Action defines the action of the quick setting.
|
||||
Action: 'replace' | 'merge-top' | 'merge-bottom';
|
||||
}
|
||||
|
||||
export interface CountrySelectionQuickSetting<T extends OptionValueType> extends QuickSetting<T> {
|
||||
// Filename of the flag to be used.
|
||||
// In most cases this will be the 2-letter country code, but there are also special flags.
|
||||
FlagID: string;
|
||||
}
|
||||
|
||||
export interface ValueRequirement {
|
||||
// Key is the configuration key of the required setting.
|
||||
Key: string;
|
||||
// Value is the required value of the linked setting.
|
||||
Value: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* BaseSetting describes the general shape of a portbase config setting.
|
||||
*/
|
||||
export interface BaseSetting<T extends OptionValueType, O extends OptionType> extends Record {
|
||||
// Value is the value of a setting.
|
||||
Value?: T;
|
||||
// DefaultValue is the default value of a setting.
|
||||
DefaultValue: T;
|
||||
// Description is a short description.
|
||||
Description?: string;
|
||||
// ExpertiseLevel defines the required expertise level for
|
||||
// this setting to show up.
|
||||
ExpertiseLevel: ExpertiseLevelNumber;
|
||||
// Help may contain a longer help text for this option.
|
||||
Help?: string;
|
||||
// Key is the database key.
|
||||
Key: string;
|
||||
// Name is the name of the option.
|
||||
Name: string;
|
||||
// OptType is the option's basic type.
|
||||
OptType: O;
|
||||
// Annotations holds option specific annotations.
|
||||
Annotations: Annotations<T>;
|
||||
// ReleaseLevel defines the release level of the feature
|
||||
// or settings changed by this option.
|
||||
ReleaseLevel: ReleaseLevel;
|
||||
// RequiresRestart may be set to true if the service requires
|
||||
// a restart after this option has been changed.
|
||||
RequiresRestart?: boolean;
|
||||
// ValidateRegex defines the regex used to validate this option.
|
||||
// The regex is used in Golang but is expected to be valid in
|
||||
// JavaScript as well.
|
||||
ValidationRegex?: string;
|
||||
PossibleValues?: PossilbeValue[];
|
||||
|
||||
// GlobalDefault holds the global default value and is used in the app settings
|
||||
// This property is NOT defined inside the portmaster!
|
||||
GlobalDefault?: T;
|
||||
}
|
||||
|
||||
export type IntSetting = BaseSetting<number, OptionType.Int>;
|
||||
export type StringSetting = BaseSetting<string, OptionType.String>;
|
||||
export type StringArraySetting = BaseSetting<string[], OptionType.StringArray>;
|
||||
export type BoolSetting = BaseSetting<boolean, OptionType.Bool>;
|
||||
|
||||
/**
|
||||
* Apply a quick setting to a value.
|
||||
*
|
||||
* @param current The current value of the setting.
|
||||
* @param qs The quick setting to apply.
|
||||
*/
|
||||
export function applyQuickSetting<V extends OptionValueType>(current: V | null, qs: QuickSetting<V>): V | null {
|
||||
if (qs.Action === 'replace' || !qs.Action) {
|
||||
return deepClone(qs.Value);
|
||||
}
|
||||
|
||||
if ((!Array.isArray(current) && current !== null) || !Array.isArray(qs.Value)) {
|
||||
console.warn(`Tried to ${qs.Action} quick-setting on non-array type`);
|
||||
return current;
|
||||
}
|
||||
|
||||
const clone = deepClone(current);
|
||||
let missing: any[] = [];
|
||||
|
||||
qs.Value.forEach(val => {
|
||||
if (clone.includes(val)) {
|
||||
return
|
||||
}
|
||||
missing.push(val);
|
||||
});
|
||||
|
||||
if (qs.Action === 'merge-bottom') {
|
||||
return clone.concat(missing) as V;
|
||||
}
|
||||
|
||||
return missing.concat(clone) as V;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the ValidationRegex of a setting and returns a list
|
||||
* of supported values.
|
||||
*
|
||||
* @param s The setting to extract support values from.
|
||||
*/
|
||||
export function parseSupportedValues<S extends Setting>(s: S): SettingValueType<S>[] {
|
||||
if (!s.ValidationRegex) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const values = s.ValidationRegex.match(/\w+/gmi);
|
||||
const result: SettingValueType<S>[] = [];
|
||||
|
||||
let converter: (s: string) => any;
|
||||
|
||||
switch (s.OptType) {
|
||||
case OptionType.Bool:
|
||||
converter = s => s === 'true';
|
||||
break;
|
||||
case OptionType.Int:
|
||||
converter = s => +s;
|
||||
break;
|
||||
case OptionType.String:
|
||||
case OptionType.StringArray:
|
||||
converter = s => s
|
||||
break
|
||||
}
|
||||
|
||||
values?.forEach(val => {
|
||||
result.push(converter(val))
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* isDefaultValue checks if value is the settings default value.
|
||||
* It supports all available settings type and fallsback to use
|
||||
* JSON encoded string comparision (JS JSON.stringify is stable).
|
||||
*/
|
||||
export function isDefaultValue<T extends OptionValueType>(value: T | undefined | null, defaultValue: T): boolean {
|
||||
if (value === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isObject = typeof value === 'object';
|
||||
const isDefault = isObject
|
||||
? JSON.stringify(value) === JSON.stringify(defaultValue)
|
||||
: value === defaultValue;
|
||||
|
||||
return isDefault;
|
||||
}
|
||||
|
||||
/**
|
||||
* SettingValueType is used to infer the type of a settings from it's default value.
|
||||
* Use like this:
|
||||
*
|
||||
* validate<S extends Setting>(spec: S, value SettingValueType<S>) { ... }
|
||||
*/
|
||||
export type SettingValueType<S extends Setting> = S extends { DefaultValue: infer T } ? T : any;
|
||||
|
||||
export type Setting = IntSetting
|
||||
| StringSetting
|
||||
| StringArraySetting
|
||||
| BoolSetting;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { TrackByFunction } from '@angular/core';
|
||||
|
||||
export enum SecurityLevel {
|
||||
Off = 0,
|
||||
Normal = 1,
|
||||
High = 2,
|
||||
Extreme = 4,
|
||||
}
|
||||
|
||||
export enum RiskLevel {
|
||||
Off = 'off',
|
||||
Auto = 'auto',
|
||||
Low = 'low',
|
||||
Medium = 'medium',
|
||||
High = 'high'
|
||||
}
|
||||
|
||||
/** Interface capturing any object that has an ID member. */
|
||||
export interface Identifyable {
|
||||
ID: string | number;
|
||||
}
|
||||
|
||||
/** A TrackByFunction for all Identifyable objects. */
|
||||
export const trackById: TrackByFunction<Identifyable> = (_: number, obj: Identifyable) => {
|
||||
return obj.ID;
|
||||
}
|
||||
|
||||
export function getEnumKey(enumLike: any, value: string | number): string {
|
||||
if (typeof value === 'string') {
|
||||
return value.toLowerCase()
|
||||
}
|
||||
|
||||
return (enumLike[value] as string).toLowerCase()
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Inject, Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { PORTMASTER_HTTP_API_ENDPOINT } from './portapi.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DebugAPI {
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
@Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string,
|
||||
) { }
|
||||
|
||||
ping(): Observable<string> {
|
||||
return this.http.get(`${this.httpAPI}/v1/ping`, {
|
||||
responseType: 'text'
|
||||
})
|
||||
}
|
||||
|
||||
getStack(): Observable<string> {
|
||||
return this.http.get(`${this.httpAPI}/v1/debug/stack`, {
|
||||
responseType: 'text'
|
||||
})
|
||||
}
|
||||
|
||||
getDebugInfo(style = 'github'): Observable<string> {
|
||||
return this.http.get(`${this.httpAPI}/v1/debug/info`, {
|
||||
params: {
|
||||
style,
|
||||
},
|
||||
responseType: 'text',
|
||||
})
|
||||
}
|
||||
|
||||
getCoreDebugInfo(style = 'github'): Observable<string> {
|
||||
return this.http.get(`${this.httpAPI}/v1/debug/core`, {
|
||||
params: {
|
||||
style,
|
||||
},
|
||||
responseType: 'text',
|
||||
})
|
||||
}
|
||||
|
||||
getProfileDebugInfo(source: string, id: string, style = 'github'): Observable<string> {
|
||||
return this.http.get(`${this.httpAPI}/v1/debug/network`, {
|
||||
params: {
|
||||
profile: `${source}/${id}`,
|
||||
style,
|
||||
},
|
||||
responseType: 'text',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export enum FeatureID {
|
||||
None = "",
|
||||
SPN = "spn",
|
||||
PrioritySupport = "support",
|
||||
History = "history",
|
||||
Bandwidth = "bw-vis",
|
||||
VPNCompat = "vpn-compat",
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
|
||||
import { Inject, Injectable, Optional } from '@angular/core';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { PORTMASTER_HTTP_API_ENDPOINT } from './portapi.service';
|
||||
|
||||
export interface MetaEndpointParameter {
|
||||
Method: string;
|
||||
Field: string;
|
||||
Value: string;
|
||||
Description: string;
|
||||
}
|
||||
|
||||
export interface MetaEndpoint {
|
||||
Path: string;
|
||||
MimeType: string;
|
||||
Read: number;
|
||||
Write: number;
|
||||
Name: string;
|
||||
Description: string;
|
||||
Parameters: MetaEndpointParameter[];
|
||||
}
|
||||
|
||||
export interface AuthPermission {
|
||||
Read: number;
|
||||
Write: number;
|
||||
ReadRole: string;
|
||||
WriteRole: string;
|
||||
}
|
||||
|
||||
export interface MyProfileResponse {
|
||||
profile: string;
|
||||
source: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AuthKeyResponse {
|
||||
key: string;
|
||||
validUntil: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MetaAPI {
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
@Inject(PORTMASTER_HTTP_API_ENDPOINT) @Optional() private httpEndpoint: string = 'http://localhost:817/api',
|
||||
) { }
|
||||
|
||||
listEndpoints(): Observable<MetaEndpoint[]> {
|
||||
return this.http.get<MetaEndpoint[]>(`${this.httpEndpoint}/v1/endpoints`)
|
||||
}
|
||||
|
||||
permissions(): Observable<AuthPermission> {
|
||||
return this.http.get<AuthPermission>(`${this.httpEndpoint}/v1/auth/permissions`)
|
||||
}
|
||||
|
||||
myProfile(): Observable<MyProfileResponse> {
|
||||
return this.http.get<MyProfileResponse>(`${this.httpEndpoint}/v1/app/profile`)
|
||||
}
|
||||
|
||||
requestApplicationAccess(appName: string, read: 'user' | 'admin' = 'user', write: 'user' | 'admin' = 'user'): Observable<AuthKeyResponse> {
|
||||
let params = new HttpParams()
|
||||
.set("app-name", appName)
|
||||
.set("read", read)
|
||||
.set("write", write)
|
||||
|
||||
return this.http.get<AuthKeyResponse>(`${this.httpEndpoint}/v1/app/auth`, { params })
|
||||
}
|
||||
|
||||
login(bearer: string): Observable<boolean>;
|
||||
login(username: string, password: string): Observable<boolean>;
|
||||
login(usernameOrBearer: string, password?: string): Observable<boolean> {
|
||||
let login: Observable<void>;
|
||||
|
||||
if (!!password) {
|
||||
login = this.http.get<void>(`${this.httpEndpoint}/v1/auth/basic`, {
|
||||
headers: {
|
||||
'Authorization': `Basic ${btoa(usernameOrBearer + ":" + password)}`
|
||||
}
|
||||
})
|
||||
} else {
|
||||
login = this.http.get<void>(`${this.httpEndpoint}/v1/auth/bearer`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${usernameOrBearer}`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return login.pipe(
|
||||
map(() => true),
|
||||
catchError(err => {
|
||||
if (err instanceof HttpErrorResponse) {
|
||||
if (err.status === 401) {
|
||||
return of(false);
|
||||
}
|
||||
}
|
||||
|
||||
return throwError(() => err)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
logout(): Observable<void> {
|
||||
return this.http.get<void>(`${this.httpEndpoint}/v1/auth/reset`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ModuleWithProviders, NgModule } from "@angular/core";
|
||||
import { AppProfileService } from "./app-profile.service";
|
||||
import { ConfigService } from "./config.service";
|
||||
import { DebugAPI } from "./debug-api.service";
|
||||
import { MetaAPI } from "./meta-api.service";
|
||||
import { Netquery } from "./netquery.service";
|
||||
import { PortapiService, PORTMASTER_HTTP_API_ENDPOINT, PORTMASTER_WS_API_ENDPOINT } from "./portapi.service";
|
||||
import { SPNService } from "./spn.service";
|
||||
import { WebsocketService } from "./websocket.service";
|
||||
|
||||
export interface ModuleConfig {
|
||||
httpAPI?: string;
|
||||
websocketAPI?: string;
|
||||
}
|
||||
|
||||
@NgModule({})
|
||||
export class PortmasterAPIModule {
|
||||
|
||||
/**
|
||||
* Configures a module with additional providers.
|
||||
*
|
||||
* @param cfg The module configuration defining the Portmaster HTTP and Websocket API endpoints.
|
||||
*/
|
||||
static forRoot(cfg: ModuleConfig = {}): ModuleWithProviders<PortmasterAPIModule> {
|
||||
if (cfg.httpAPI === undefined) {
|
||||
cfg.httpAPI = `http://${window.location.host}/api`;
|
||||
}
|
||||
if (cfg.websocketAPI === undefined) {
|
||||
cfg.websocketAPI = `ws://${window.location.host}/api/database/v1`;
|
||||
}
|
||||
|
||||
return {
|
||||
ngModule: PortmasterAPIModule,
|
||||
providers: [
|
||||
PortapiService,
|
||||
WebsocketService,
|
||||
MetaAPI,
|
||||
ConfigService,
|
||||
AppProfileService,
|
||||
DebugAPI,
|
||||
Netquery,
|
||||
SPNService,
|
||||
{
|
||||
provide: PORTMASTER_HTTP_API_ENDPOINT,
|
||||
useValue: cfg.httpAPI,
|
||||
},
|
||||
{
|
||||
provide: PORTMASTER_WS_API_ENDPOINT,
|
||||
useValue: cfg.websocketAPI
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
import { HttpClient, HttpParams, HttpResponse } from "@angular/common/http";
|
||||
import { Inject, Injectable } from "@angular/core";
|
||||
import { Observable, forkJoin, of } from "rxjs";
|
||||
import { catchError, map, mergeMap } from "rxjs/operators";
|
||||
import { AppProfileService } from "./app-profile.service";
|
||||
import { AppProfile } from "./app-profile.types";
|
||||
import { DNSContext, IPScope, Reason, TLSContext, TunnelContext, Verdict } from "./network.types";
|
||||
import { PORTMASTER_HTTP_API_ENDPOINT, PortapiService } from "./portapi.service";
|
||||
import { Container } from "postcss";
|
||||
|
||||
export interface FieldSelect {
|
||||
field: string;
|
||||
}
|
||||
|
||||
export interface FieldAsSelect {
|
||||
$field: {
|
||||
field: string;
|
||||
as: string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Count {
|
||||
$count: {
|
||||
field: string;
|
||||
distinct?: boolean;
|
||||
as?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Sum {
|
||||
$sum: {
|
||||
condition: Condition;
|
||||
as: string;
|
||||
distinct?: boolean;
|
||||
} | {
|
||||
field: string;
|
||||
as: string;
|
||||
distinct?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Min {
|
||||
$min: {
|
||||
condition: Condition;
|
||||
as: string;
|
||||
distinct?: boolean;
|
||||
} | {
|
||||
field: string;
|
||||
as: string;
|
||||
distinct?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Distinct {
|
||||
$distinct: string;
|
||||
}
|
||||
|
||||
export type Select = FieldSelect | FieldAsSelect | Count | Distinct | Sum | Min;
|
||||
|
||||
export interface Equal {
|
||||
$eq: any;
|
||||
}
|
||||
|
||||
export interface NotEqual {
|
||||
$ne: any;
|
||||
}
|
||||
|
||||
export interface Like {
|
||||
$like: string;
|
||||
}
|
||||
|
||||
export interface In {
|
||||
$in: any[];
|
||||
}
|
||||
|
||||
export interface NotIn {
|
||||
$notin: string[];
|
||||
}
|
||||
|
||||
export interface Greater {
|
||||
$gt: number;
|
||||
}
|
||||
|
||||
export interface GreaterOrEqual {
|
||||
$ge: number;
|
||||
}
|
||||
|
||||
export interface Less {
|
||||
$lt: number;
|
||||
}
|
||||
|
||||
export interface LessOrEqual {
|
||||
$le: number;
|
||||
}
|
||||
|
||||
export type Matcher = Equal | NotEqual | Like | In | NotIn | Greater | GreaterOrEqual | Less | LessOrEqual;
|
||||
|
||||
export interface OrderBy {
|
||||
field: string;
|
||||
desc?: boolean;
|
||||
}
|
||||
|
||||
export interface Condition {
|
||||
[key: string]: string | Matcher | (string | Matcher)[];
|
||||
}
|
||||
|
||||
export interface TextSearch {
|
||||
fields: string[];
|
||||
value: string;
|
||||
}
|
||||
|
||||
export enum Database {
|
||||
Live = "main",
|
||||
History = "history"
|
||||
}
|
||||
|
||||
export interface Query {
|
||||
select?: string | Select | (Select | string)[];
|
||||
query?: Condition;
|
||||
orderBy?: string | OrderBy | (OrderBy | string)[];
|
||||
textSearch?: TextSearch;
|
||||
groupBy?: string[];
|
||||
pageSize?: number;
|
||||
page?: number;
|
||||
databases?: Database[];
|
||||
}
|
||||
|
||||
export interface NetqueryConnection {
|
||||
id: string;
|
||||
allowed: boolean | null;
|
||||
profile: string;
|
||||
path: string;
|
||||
type: 'dns' | 'ip';
|
||||
external: boolean;
|
||||
ip_version: number;
|
||||
ip_protocol: number;
|
||||
local_ip: string;
|
||||
local_port: number;
|
||||
remote_ip: string;
|
||||
remote_port: number;
|
||||
domain: string;
|
||||
country: string;
|
||||
asn: number;
|
||||
as_owner: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
scope: IPScope;
|
||||
verdict: Verdict;
|
||||
started: string;
|
||||
ended: string;
|
||||
tunneled: boolean;
|
||||
encrypted: boolean;
|
||||
internal: boolean;
|
||||
direction: 'inbound' | 'outbound';
|
||||
profile_revision: number;
|
||||
exit_node?: string;
|
||||
extra_data?: {
|
||||
pid?: number;
|
||||
processCreatedAt?: number;
|
||||
cname?: string[];
|
||||
blockedByLists?: string[];
|
||||
blockedEntities?: string[];
|
||||
reason?: Reason;
|
||||
tunnel?: TunnelContext;
|
||||
dns?: DNSContext;
|
||||
tls?: TLSContext;
|
||||
};
|
||||
|
||||
profile_name: string;
|
||||
active: boolean;
|
||||
bytes_received: number;
|
||||
bytes_sent: number;
|
||||
}
|
||||
|
||||
export interface ChartResult {
|
||||
timestamp: number;
|
||||
value: number;
|
||||
countBlocked: number;
|
||||
}
|
||||
|
||||
export interface QueryResult extends Partial<NetqueryConnection> {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface Identities {
|
||||
exit_node: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface IProfileStats {
|
||||
ID: string;
|
||||
Name: string;
|
||||
|
||||
size: number;
|
||||
empty: boolean;
|
||||
identities: Identities[];
|
||||
countAllowed: number;
|
||||
countUnpermitted: number;
|
||||
countAliveConnections: number;
|
||||
bytes_sent: number;
|
||||
bytes_received: number;
|
||||
}
|
||||
|
||||
type BatchResponse<T> = {
|
||||
[key in keyof T]: QueryResult[]
|
||||
}
|
||||
|
||||
interface BatchRequest {
|
||||
[key: string]: Query
|
||||
}
|
||||
|
||||
interface BandwidthBaseResult {
|
||||
timestamp: number;
|
||||
incoming: number;
|
||||
outgoing: number;
|
||||
}
|
||||
|
||||
export type ConnKeys = keyof NetqueryConnection
|
||||
|
||||
export type BandwidthChartResult<K extends ConnKeys> = {
|
||||
[key in K]: NetqueryConnection[K];
|
||||
} & BandwidthBaseResult
|
||||
|
||||
export type ProfileBandwidthChartResult = BandwidthChartResult<'profile'>;
|
||||
|
||||
export type ConnectionBandwidthChartResult = BandwidthChartResult<'id'>;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class Netquery {
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
private profileService: AppProfileService,
|
||||
private portapi: PortapiService,
|
||||
@Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string,
|
||||
) { }
|
||||
|
||||
query(query: Query, origin: string): Observable<QueryResult[]> {
|
||||
return this.http.post<{ results: QueryResult[] }>(`${this.httpAPI}/v1/netquery/query`, query, {
|
||||
params: new HttpParams().set("origin", origin)
|
||||
})
|
||||
.pipe(map(res => res.results || []));
|
||||
}
|
||||
|
||||
batch<T extends BatchRequest>(queries: T): Observable<BatchResponse<T>> {
|
||||
return this.http.post<BatchResponse<T>>(`${this.httpAPI}/v1/netquery/query/batch`, queries)
|
||||
}
|
||||
|
||||
cleanProfileHistory(profileIDs: string | string[]): Observable<HttpResponse<any>> {
|
||||
return this.http.post(`${this.httpAPI}/v1/netquery/history/clear`,
|
||||
{
|
||||
profileIDs: Array.isArray(profileIDs) ? profileIDs : [profileIDs]
|
||||
},
|
||||
{
|
||||
observe: 'response',
|
||||
responseType: 'text',
|
||||
reportProgress: false,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
profileBandwidthChart(profile?: string[], interval?: number): Observable<{ [profile: string]: ProfileBandwidthChartResult[] }> {
|
||||
const cond: Condition = {}
|
||||
if (!!profile) {
|
||||
cond['profile'] = profile
|
||||
}
|
||||
|
||||
return this.bandwidthChart(cond, ['profile'], interval)
|
||||
.pipe(
|
||||
map(results => {
|
||||
const obj: {
|
||||
[connId: string]: ProfileBandwidthChartResult[]
|
||||
} = {};
|
||||
|
||||
results?.forEach(row => {
|
||||
const arr = obj[row.profile] || []
|
||||
arr.push(row)
|
||||
obj[row.profile] = arr
|
||||
})
|
||||
|
||||
return obj
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
bandwidthChart<K extends ConnKeys>(query: Condition, groupBy?: K[], interval?: number): Observable<BandwidthChartResult<K>[]> {
|
||||
return this.http.post<{ results: BandwidthChartResult<K>[] }>(`${this.httpAPI}/v1/netquery/charts/bandwidth`, {
|
||||
interval,
|
||||
groupBy,
|
||||
query,
|
||||
})
|
||||
.pipe(
|
||||
map(response => response.results),
|
||||
)
|
||||
}
|
||||
|
||||
connectionBandwidthChart(connIds: string[], interval?: number): Observable<{ [connId: string]: ConnectionBandwidthChartResult[] }> {
|
||||
const cond: Condition = {}
|
||||
if (!!connIds) {
|
||||
cond['id'] = connIds
|
||||
}
|
||||
|
||||
return this.bandwidthChart(cond, ['id'], interval)
|
||||
.pipe(
|
||||
map(results => {
|
||||
const obj: {
|
||||
[connId: string]: ConnectionBandwidthChartResult[]
|
||||
} = {};
|
||||
|
||||
results?.forEach(row => {
|
||||
const arr = obj[row.id] || []
|
||||
arr.push(row)
|
||||
obj[row.id] = arr
|
||||
})
|
||||
|
||||
return obj
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
activeConnectionChart(cond: Condition, textSearch?: TextSearch): Observable<ChartResult[]> {
|
||||
return this.http.post<{ results: ChartResult[] }>(`${this.httpAPI}/v1/netquery/charts/connection-active`, {
|
||||
query: cond,
|
||||
textSearch,
|
||||
})
|
||||
.pipe(map(res => {
|
||||
const now = new Date();
|
||||
|
||||
let data: ChartResult[] = [];
|
||||
|
||||
let lastPoint: ChartResult | null = {
|
||||
timestamp: Math.floor(now.getTime() / 1000 - 600),
|
||||
value: 0,
|
||||
countBlocked: 0,
|
||||
};
|
||||
res.results?.forEach(point => {
|
||||
if (!!lastPoint && lastPoint.timestamp < (point.timestamp - 10)) {
|
||||
for (let i = lastPoint.timestamp; i < point.timestamp; i += 10) {
|
||||
data.push({
|
||||
timestamp: i,
|
||||
value: 0,
|
||||
countBlocked: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
data.push(point);
|
||||
lastPoint = point;
|
||||
})
|
||||
|
||||
const lastPointTs = Math.round(now.getTime() / 1000);
|
||||
if (!!lastPoint && lastPoint.timestamp < (lastPointTs - 20)) {
|
||||
for (let i = lastPoint.timestamp; i < lastPointTs; i += 20) {
|
||||
data.push({
|
||||
timestamp: i,
|
||||
value: 0,
|
||||
countBlocked: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}));
|
||||
}
|
||||
|
||||
getActiveProfileIDs(): Observable<string[]> {
|
||||
return this.query({
|
||||
select: [
|
||||
'profile',
|
||||
],
|
||||
groupBy: [
|
||||
'profile',
|
||||
],
|
||||
}, 'get-active-profile-ids').pipe(
|
||||
map(result => {
|
||||
return result.map(res => res.profile!);
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
getActiveProfiles(): Observable<AppProfile[]> {
|
||||
return this.getActiveProfileIDs()
|
||||
.pipe(
|
||||
mergeMap(profiles => forkJoin(profiles.map(pid => this.profileService.getAppProfile(pid))))
|
||||
)
|
||||
}
|
||||
|
||||
getProfileStats(query?: Condition): Observable<IProfileStats[]> {
|
||||
let profileCache = new Map<string, AppProfile>();
|
||||
|
||||
return this.batch({
|
||||
verdicts: {
|
||||
select: [
|
||||
'profile',
|
||||
'verdict',
|
||||
{ $count: { field: '*', as: 'totalCount' } },
|
||||
],
|
||||
groupBy: [
|
||||
'profile',
|
||||
'verdict',
|
||||
],
|
||||
query: query,
|
||||
},
|
||||
|
||||
conns: {
|
||||
select: [
|
||||
'profile',
|
||||
{ $count: { field: '*', as: 'totalCount' } },
|
||||
{ $count: { field: 'ended', as: 'countEnded' } },
|
||||
{ $sum: { field: 'bytes_sent', as: 'bytes_sent' } },
|
||||
{ $sum: { field: 'bytes_received', as: 'bytes_received' } },
|
||||
],
|
||||
groupBy: [
|
||||
'profile',
|
||||
],
|
||||
query: query,
|
||||
},
|
||||
|
||||
identities: {
|
||||
select: [
|
||||
'profile',
|
||||
'exit_node',
|
||||
{ $count: { field: '*', as: 'totalCount' } }
|
||||
],
|
||||
groupBy: [
|
||||
'profile',
|
||||
'exit_node',
|
||||
],
|
||||
query: {
|
||||
...query,
|
||||
exit_node: {
|
||||
$ne: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
}).pipe(
|
||||
map(result => {
|
||||
let statsMap = new Map<string, IProfileStats>();
|
||||
|
||||
const getOrCreate = (id: string) => {
|
||||
let stats = statsMap.get(id) || {
|
||||
ID: id,
|
||||
Name: 'Deleted',
|
||||
countAliveConnections: 0,
|
||||
countAllowed: 0,
|
||||
countUnpermitted: 0,
|
||||
empty: true,
|
||||
identities: [],
|
||||
size: 0,
|
||||
bytes_received: 0,
|
||||
bytes_sent: 0
|
||||
};
|
||||
|
||||
statsMap.set(id, stats);
|
||||
return stats;
|
||||
}
|
||||
result.verdicts?.forEach(res => {
|
||||
const stats = getOrCreate(res.profile!);
|
||||
|
||||
switch (res.verdict) {
|
||||
case Verdict.Accept:
|
||||
case Verdict.RerouteToNs:
|
||||
case Verdict.RerouteToTunnel:
|
||||
case Verdict.Undeterminable:
|
||||
stats.size += res.totalCount
|
||||
stats.countAllowed += res.totalCount;
|
||||
break;
|
||||
|
||||
case Verdict.Block:
|
||||
case Verdict.Drop:
|
||||
case Verdict.Failed:
|
||||
case Verdict.Undecided:
|
||||
stats.size += res.totalCount
|
||||
stats.countUnpermitted += res.totalCount;
|
||||
break;
|
||||
}
|
||||
|
||||
stats.empty = stats.size == 0;
|
||||
})
|
||||
|
||||
result.conns?.forEach(res => {
|
||||
const stats = getOrCreate(res.profile!);
|
||||
|
||||
stats.countAliveConnections = res.totalCount - res.countEnded;
|
||||
stats.bytes_received += res.bytes_received!;
|
||||
stats.bytes_sent += res.bytes_sent!;
|
||||
})
|
||||
|
||||
result.identities?.forEach(res => {
|
||||
const stats = getOrCreate(res.profile!);
|
||||
|
||||
let ident = stats.identities.find(value => value.exit_node === res.exit_node)
|
||||
if (!ident) {
|
||||
ident = {
|
||||
count: 0,
|
||||
exit_node: res.exit_node!,
|
||||
}
|
||||
stats.identities.push(ident);
|
||||
}
|
||||
|
||||
ident.count += res.totalCount;
|
||||
})
|
||||
|
||||
return Array.from(statsMap.values())
|
||||
}),
|
||||
mergeMap(stats => {
|
||||
return forkJoin(stats.map(p => {
|
||||
if (profileCache.has(p.ID)) {
|
||||
return of(profileCache.get(p.ID)!);
|
||||
}
|
||||
return this.profileService.getAppProfile(p.ID)
|
||||
.pipe(catchError(err => {
|
||||
return of(null)
|
||||
}))
|
||||
}))
|
||||
.pipe(
|
||||
map((profiles: (AppProfile | null)[]) => {
|
||||
profileCache = new Map();
|
||||
|
||||
let lm = new Map<string, IProfileStats>();
|
||||
stats.forEach(stat => lm.set(stat.ID, stat));
|
||||
|
||||
profiles
|
||||
.forEach(p => {
|
||||
if (!p) {
|
||||
return
|
||||
}
|
||||
|
||||
profileCache.set(`${p.Source}/${p.ID}`, p)
|
||||
|
||||
let stat = lm.get(`${p.Source}/${p.ID}`)
|
||||
if (!stat) {
|
||||
return;
|
||||
}
|
||||
|
||||
stat.Name = p.Name
|
||||
})
|
||||
|
||||
return Array.from(lm.values())
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { Record } from './portapi.types';
|
||||
|
||||
export enum Verdict {
|
||||
Undecided = 0,
|
||||
Undeterminable = 1,
|
||||
Accept = 2,
|
||||
Block = 3,
|
||||
Drop = 4,
|
||||
RerouteToNs = 5,
|
||||
RerouteToTunnel = 6,
|
||||
Failed = 7
|
||||
}
|
||||
|
||||
export enum IPProtocol {
|
||||
ICMP = 1,
|
||||
IGMP = 2,
|
||||
TCP = 6,
|
||||
UDP = 17,
|
||||
ICMPv6 = 58,
|
||||
UDPLite = 136,
|
||||
RAW = 255, // TODO(ppacher): what is RAW used for?
|
||||
}
|
||||
|
||||
export enum IPVersion {
|
||||
V4 = 4,
|
||||
V6 = 6,
|
||||
}
|
||||
|
||||
export enum IPScope {
|
||||
Invalid = -1,
|
||||
Undefined = 0,
|
||||
HostLocal = 1,
|
||||
LinkLocal = 2,
|
||||
SiteLocal = 3,
|
||||
Global = 4,
|
||||
LocalMulticast = 5,
|
||||
GlobalMulitcast = 6
|
||||
}
|
||||
|
||||
let globalScopes = new Set([IPScope.GlobalMulitcast, IPScope.Global])
|
||||
let localScopes = new Set([IPScope.SiteLocal, IPScope.LinkLocal, IPScope.LocalMulticast])
|
||||
|
||||
// IsGlobalScope returns true if scope represents a globally
|
||||
// routed destination.
|
||||
export function IsGlobalScope(scope: IPScope): scope is IPScope.GlobalMulitcast | IPScope.Global {
|
||||
return globalScopes.has(scope);
|
||||
}
|
||||
|
||||
// IsLocalScope returns true if scope represents a locally
|
||||
// routed destination.
|
||||
export function IsLANScope(scope: IPScope): scope is IPScope.SiteLocal | IPScope.LinkLocal | IPScope.LocalMulticast {
|
||||
return localScopes.has(scope);
|
||||
}
|
||||
|
||||
// IsLocalhost returns true if scope represents localhost.
|
||||
export function IsLocalhost(scope: IPScope): scope is IPScope.HostLocal {
|
||||
return scope === IPScope.HostLocal;
|
||||
}
|
||||
|
||||
const deniedVerdicts = new Set([
|
||||
Verdict.Drop,
|
||||
Verdict.Block,
|
||||
])
|
||||
// IsDenied returns true if the verdict v represents a
|
||||
// deny or block decision.
|
||||
export function IsDenied(v: Verdict): boolean {
|
||||
return deniedVerdicts.has(v);
|
||||
}
|
||||
|
||||
export interface CountryInfo {
|
||||
Code: string;
|
||||
Name: string;
|
||||
Center: GeoCoordinates;
|
||||
Continent: ContinentInfo;
|
||||
}
|
||||
|
||||
export interface ContinentInfo {
|
||||
Code: string;
|
||||
Region: string;
|
||||
Name: string;
|
||||
}
|
||||
|
||||
export interface GeoCoordinates {
|
||||
AccuracyRadius: number;
|
||||
Latitude: number;
|
||||
Longitude: number;
|
||||
}
|
||||
|
||||
export const UnknownLocation: GeoCoordinates = {
|
||||
AccuracyRadius: 0,
|
||||
Latitude: 0,
|
||||
Longitude: 0
|
||||
}
|
||||
|
||||
export interface IntelEntity {
|
||||
// Protocol is the IP protocol used to connect/communicate
|
||||
// the the described entity.
|
||||
Protocol: IPProtocol;
|
||||
// Port is the remote port number used.
|
||||
Port: number;
|
||||
// Domain is the domain name of the entity. This may either
|
||||
// be the domain name used in the DNS request or the
|
||||
// named returned from reverse PTR lookup.
|
||||
Domain: string;
|
||||
// CNAME is a list of CNAMEs that have been used
|
||||
// to resolve this entity.
|
||||
CNAME: string[] | null;
|
||||
// IP is the IP address of the entity.
|
||||
IP: string;
|
||||
// IPScope holds the classification of the IP address.
|
||||
IPScope: IPScope;
|
||||
// Country holds the country of residence of the IP address.
|
||||
Country: string;
|
||||
// ASN holds the number of the autonoumous system that operates
|
||||
// the IP.
|
||||
ASN: number;
|
||||
// ASOrg holds the AS owner name.
|
||||
ASOrg: string;
|
||||
// Coordinates contains the geographic coordinates of the entity.
|
||||
Coordinates: GeoCoordinates | null;
|
||||
// BlockedByLists holds a list of filter list IDs that
|
||||
// would have blocked the entity.
|
||||
BlockedByLists: string[] | null;
|
||||
// BlockedEntities holds a list of entities that have been
|
||||
// blocked by filter lists. Those entities can be ASNs, domains,
|
||||
// CNAMEs, IPs or Countries.
|
||||
BlockedEntities: string[] | null;
|
||||
// ListOccurences maps the blocked entity (see BlockedEntities)
|
||||
// to a list of filter-list IDs that contains it.
|
||||
ListOccurences: { [key: string]: string[] } | null;
|
||||
}
|
||||
|
||||
export enum ScopeIdentifier {
|
||||
IncomingHost = "IH",
|
||||
IncomingLAN = "IL",
|
||||
IncomingInternet = "II",
|
||||
IncomingInvalid = "IX",
|
||||
PeerHost = "PH",
|
||||
PeerLAN = "PL",
|
||||
PeerInternet = "PI",
|
||||
PeerInvalid = "PX"
|
||||
}
|
||||
|
||||
export const ScopeTranslation: { [key: string]: string } = {
|
||||
[ScopeIdentifier.IncomingHost]: "Device-Local Incoming",
|
||||
[ScopeIdentifier.IncomingLAN]: "LAN Incoming",
|
||||
[ScopeIdentifier.IncomingInternet]: "Internet Incoming",
|
||||
[ScopeIdentifier.PeerHost]: "Device-Local Outgoing",
|
||||
[ScopeIdentifier.PeerLAN]: "LAN Peer-to-Peer",
|
||||
[ScopeIdentifier.PeerInternet]: "Internet Peer-to-Peer",
|
||||
[ScopeIdentifier.IncomingInvalid]: "N/A",
|
||||
[ScopeIdentifier.PeerInvalid]: "N/A",
|
||||
}
|
||||
|
||||
export interface ProcessContext {
|
||||
BinaryPath: string;
|
||||
ProcessName: string;
|
||||
ProfileName: string;
|
||||
PID: number;
|
||||
Profile: string;
|
||||
Source: string
|
||||
}
|
||||
|
||||
// Reason justifies the decision on a connection
|
||||
// verdict.
|
||||
export interface Reason {
|
||||
// Msg holds a human readable message of the reason.
|
||||
Msg: string;
|
||||
// OptionKey, if available, holds the key of the
|
||||
// configuration option that caused the verdict.
|
||||
OptionKey: string;
|
||||
// Profile holds the profile the option setting has
|
||||
// been configured in.
|
||||
Profile: string;
|
||||
// Context may holds additional data about the reason.
|
||||
Context: any;
|
||||
}
|
||||
|
||||
export enum ConnectionType {
|
||||
Undefined = 0,
|
||||
IPConnection = 1,
|
||||
DNSRequest = 2
|
||||
}
|
||||
|
||||
export function IsDNSRequest(t: ConnectionType): t is ConnectionType.DNSRequest {
|
||||
return t === ConnectionType.DNSRequest;
|
||||
}
|
||||
|
||||
export function IsIPConnection(t: ConnectionType): t is ConnectionType.IPConnection {
|
||||
return t === ConnectionType.IPConnection;
|
||||
}
|
||||
|
||||
export interface DNSContext {
|
||||
Domain: string;
|
||||
ServedFromCache: boolean;
|
||||
RequestingNew: boolean;
|
||||
IsBackup: boolean;
|
||||
Filtered: boolean;
|
||||
FilteredEntries: string[], // RR
|
||||
Question: 'A' | 'AAAA' | 'MX' | 'TXT' | 'SOA' | 'SRV' | 'PTR' | 'NS' | string;
|
||||
RCode: 'NOERROR' | 'SERVFAIL' | 'NXDOMAIN' | 'REFUSED' | string;
|
||||
Modified: string;
|
||||
Expires: string;
|
||||
}
|
||||
|
||||
export interface TunnelContext {
|
||||
Path: TunnelNode[];
|
||||
PathCost: number;
|
||||
RoutingAlg: 'default';
|
||||
}
|
||||
|
||||
export interface GeoIPInfo {
|
||||
IP: string;
|
||||
Country: string;
|
||||
ASN: number;
|
||||
ASOwner: string;
|
||||
}
|
||||
|
||||
export interface TunnelNode {
|
||||
ID: string;
|
||||
Name: string;
|
||||
IPv4?: GeoIPInfo;
|
||||
IPv6?: GeoIPInfo;
|
||||
|
||||
}
|
||||
|
||||
export interface CertInfo<dateType extends string | Date = string> {
|
||||
Subject: string;
|
||||
Issuer: string;
|
||||
AlternateNames: string[];
|
||||
NotBefore: dateType;
|
||||
NotAfter: dateType;
|
||||
}
|
||||
|
||||
export interface TLSContext {
|
||||
Version: string;
|
||||
VersionRaw: number;
|
||||
SNI: string;
|
||||
Chain: CertInfo[][];
|
||||
}
|
||||
|
||||
export interface Connection extends Record {
|
||||
// ID is a unique ID for the connection.
|
||||
ID: string;
|
||||
// Type defines the connection type.
|
||||
Type: ConnectionType;
|
||||
// TLS may holds additional data for the TLS
|
||||
// session.
|
||||
TLS: TLSContext | null;
|
||||
// DNSContext holds additional data about the DNS request for
|
||||
// this connection.
|
||||
DNSContext: DNSContext | null;
|
||||
// TunnelContext holds additional data about the SPN tunnel used for
|
||||
// the connection.
|
||||
TunnelContext: TunnelContext | null;
|
||||
// Scope defines the scope of the connection. It's an somewhat
|
||||
// weired field that may contain a ScopeIdentifier or a string.
|
||||
// In case of a string it may eventually be interpreted as a
|
||||
// domain name.
|
||||
Scope: ScopeIdentifier | string;
|
||||
// IPVersion is the version of the IP protocol used.
|
||||
IPVersion: IPVersion;
|
||||
// Inbound is true if the connection is incoming to
|
||||
// hte local system.
|
||||
Inbound: boolean;
|
||||
// IPProtocol is the protocol used by the connection.
|
||||
IPProtocol: IPProtocol;
|
||||
// LocalIP is the local IP address that is involved into
|
||||
// the connection.
|
||||
LocalIP: string;
|
||||
// LocalIPScope holds the classification of the local IP
|
||||
// address;
|
||||
LocalIPScope: IPScope;
|
||||
// LocalPort is the local port that is involved into the
|
||||
// connection.
|
||||
LocalPort: number;
|
||||
// Entity describes the remote entity that is part of the
|
||||
// connection.
|
||||
Entity: IntelEntity;
|
||||
// Verdict defines the final verdict.
|
||||
Verdict: Verdict;
|
||||
// Reason is the reason justifying the verdict of the connection.
|
||||
Reason: Reason;
|
||||
// Started holds the number of seconds in UNIX epoch time at which
|
||||
// the connection was initiated.
|
||||
Started: number;
|
||||
// End dholds the number of seconds in UNIX epoch time at which
|
||||
// the connection was considered terminated.
|
||||
Ended: number;
|
||||
// Tunneled is set to true if the connection was tunneled through the
|
||||
// SPN.
|
||||
Tunneled: boolean;
|
||||
// VerdictPermanent is set to true if the connection was marked and
|
||||
// handed back to the operating system.
|
||||
VerdictPermanent: boolean;
|
||||
// Inspecting is set to true if the connection is being inspected.
|
||||
Inspecting: boolean;
|
||||
// Encrypted is set to true if the connection is estimated as being
|
||||
// encrypted. Interpreting this field must be done with care!
|
||||
Encrypted: boolean;
|
||||
// Internal is set to true if this connection is done by the Portmaster
|
||||
// or any associated helper processes/binaries itself.
|
||||
Internal: boolean;
|
||||
// ProcessContext holds additional information about the process
|
||||
// that initated the connection.
|
||||
ProcessContext: ProcessContext;
|
||||
// ProfileRevisionCounter is used to track changes to the process
|
||||
// profile.
|
||||
ProfileRevisionCounter: number;
|
||||
}
|
||||
|
||||
export interface ReasonContext {
|
||||
[key: string]: any;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,453 @@
|
||||
import { iif, MonoTypeOperatorFunction, of, Subscriber, throwError } from 'rxjs';
|
||||
import { concatMap, delay, retryWhen } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
* ReplyType contains all possible message types of a reply.
|
||||
*/
|
||||
export type ReplyType = 'ok'
|
||||
| 'upd'
|
||||
| 'new'
|
||||
| 'del'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'warning'
|
||||
| 'done';
|
||||
|
||||
/**
|
||||
* RequestType contains all possible message types of a request.
|
||||
*/
|
||||
export type RequestType = 'get'
|
||||
| 'query'
|
||||
| 'sub'
|
||||
| 'qsub'
|
||||
| 'create'
|
||||
| 'update'
|
||||
| 'insert'
|
||||
| 'delete'
|
||||
| 'cancel';
|
||||
|
||||
// RecordMeta describes the meta-data object that is part of
|
||||
// every API resource.
|
||||
export interface RecordMeta {
|
||||
// Created hold a unix-epoch timestamp when the record has been
|
||||
// created.
|
||||
Created: number;
|
||||
// Deleted hold a unix-epoch timestamp when the record has been
|
||||
// deleted.
|
||||
Deleted: number;
|
||||
// Expires hold a unix-epoch timestamp when the record has been
|
||||
// expires.
|
||||
Expires: number;
|
||||
// Modified hold a unix-epoch timestamp when the record has been
|
||||
// modified last.
|
||||
Modified: number;
|
||||
// Key holds the database record key.
|
||||
Key: string;
|
||||
}
|
||||
|
||||
export interface Process extends Record {
|
||||
Name: string;
|
||||
UserID: number;
|
||||
UserName: string;
|
||||
UserHome: string;
|
||||
Pid: number;
|
||||
Pgid: number;
|
||||
CreatedAt: number;
|
||||
ParentPid: number;
|
||||
ParentCreatedAt: number;
|
||||
Path: string;
|
||||
ExecName: string;
|
||||
Cwd: string;
|
||||
CmdLine: string;
|
||||
FirstArg: string;
|
||||
Env: {
|
||||
[key: string]: string
|
||||
} | null;
|
||||
Tags: {
|
||||
Key: string;
|
||||
Value: string;
|
||||
}[] | null;
|
||||
MatchingPath: string;
|
||||
PrimaryProfileID: string;
|
||||
FirstSeen: number;
|
||||
LastSeen: number;
|
||||
Error: string;
|
||||
ExecHashes: {
|
||||
[key: string]: string
|
||||
} | null;
|
||||
}
|
||||
|
||||
// Record describes the base record structure of all API resources.
|
||||
export interface Record {
|
||||
_meta?: RecordMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* All possible MessageType that are available in PortAPI.
|
||||
*/
|
||||
export type MessageType = RequestType | ReplyType;
|
||||
|
||||
/**
|
||||
* BaseMessage describes the base message type that is exchanged
|
||||
* via PortAPI.
|
||||
*/
|
||||
export interface BaseMessage<M extends MessageType = MessageType> {
|
||||
// ID of the request. Used to correlated (multiplex) requests and
|
||||
// responses across a single websocket connection.
|
||||
id: string;
|
||||
// Type is the request/response message type.
|
||||
type: M;
|
||||
}
|
||||
|
||||
/**
|
||||
* DoneReply marks the end of a PortAPI stream.
|
||||
*/
|
||||
export interface DoneReply extends BaseMessage<'done'> { }
|
||||
|
||||
/**
|
||||
* DataReply is either sent once as a result on a `get` request or
|
||||
* is sent multiple times in the course of a PortAPI stream.
|
||||
*/
|
||||
export interface DataReply<T extends Record> extends BaseMessage<'ok' | 'upd' | 'new' | 'del'> {
|
||||
// Key is the database key including the database prefix.
|
||||
key: string;
|
||||
// Data is the actual data of the entry.
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if d is a DataReply message type.
|
||||
*
|
||||
* @param d The reply message to check
|
||||
*/
|
||||
export function isDataReply(d: ReplyMessage): d is DataReply<any> {
|
||||
return d.type === 'ok'
|
||||
|| d.type === 'upd'
|
||||
|| d.type === 'new'
|
||||
|| d.type === 'del';
|
||||
//|| d.type === 'done'; // done is actually not correct
|
||||
}
|
||||
|
||||
/**
|
||||
* SuccessReply is used to mark an operation as successfully. It does not carry any
|
||||
* data. Think of it as a "201 No Content" in HTTP.
|
||||
*/
|
||||
export interface SuccessReply extends BaseMessage<'success'> { }
|
||||
|
||||
/**
|
||||
* ErrorReply describes an error that happened while processing a
|
||||
* request. Note that an `error` type message may be sent for single
|
||||
* and response-stream requests. In case of a stream the `error` type
|
||||
* message marks the end of the stream. See WarningReply for a simple
|
||||
* warning message that can be transmitted via PortAPI.
|
||||
*/
|
||||
export interface ErrorReply extends BaseMessage<'error'> {
|
||||
// Message is the error message from the backend.
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WarningReply contains a warning message that describes an error
|
||||
* condition encountered when processing a single entitiy of a
|
||||
* response stream. In contrast to `error` type messages, a `warning`
|
||||
* can only occure during data streams and does not end the stream.
|
||||
*/
|
||||
export interface WarningReply extends BaseMessage<'warning'> {
|
||||
// Message describes the warning/error condition the backend
|
||||
// encountered.
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* QueryRequest defines the payload for `query`, `sub` and `qsub` message
|
||||
* types. The result of a query request is always a stream of responses.
|
||||
* See ErrorReply, WarningReply and DoneReply for more information.
|
||||
*/
|
||||
export interface QueryRequest extends BaseMessage<'query' | 'sub' | 'qsub'> {
|
||||
// Query is the query for the database.
|
||||
query: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* KeyRequests defines the payload for a `get` or `delete` request. Those
|
||||
* message type only carry the key of the database entry to delete. Note that
|
||||
* `delete` can only return a `success` or `error` type message while `get` will
|
||||
* receive a `ok` or `error` type message.
|
||||
*/
|
||||
export interface KeyRequest extends BaseMessage<'delete' | 'get'> {
|
||||
// Key is the database entry key.
|
||||
key: string;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* DataRequest is used during create, insert or update operations.
|
||||
* TODO(ppacher): check what's the difference between create and insert,
|
||||
* both seem to error when trying to create a new entry.
|
||||
*/
|
||||
export interface DataRequest<T> extends BaseMessage<'update' | 'create' | 'insert'> {
|
||||
// Key is the database entry key.
|
||||
key: string;
|
||||
// Data is the data to store.
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* CancelRequest can be sent on stream operations to early-abort the request.
|
||||
*/
|
||||
export interface CancelRequest extends BaseMessage<'cancel'> { }
|
||||
|
||||
/**
|
||||
* ReplyMessage is a union of all reply message types.
|
||||
*/
|
||||
export type ReplyMessage<T extends Record = any> = DataReply<T>
|
||||
| DoneReply
|
||||
| SuccessReply
|
||||
| WarningReply
|
||||
| ErrorReply;
|
||||
|
||||
/**
|
||||
* RequestMessage is a union of all request message types.
|
||||
*/
|
||||
export type RequestMessage<T = any> = QueryRequest
|
||||
| KeyRequest
|
||||
| DataRequest<T>
|
||||
| CancelRequest;
|
||||
|
||||
/**
|
||||
* Requestable can be used to accept only properties that match
|
||||
* the request message type M.
|
||||
*/
|
||||
export type Requestable<M extends RequestType> = RequestMessage & { type: M };
|
||||
|
||||
/**
|
||||
* Returns true if m is a cancellable message type.
|
||||
*
|
||||
* @param m The message type to check.
|
||||
*/
|
||||
export function isCancellable(m: MessageType): boolean {
|
||||
switch (m) {
|
||||
case 'qsub':
|
||||
case 'sub':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflects a currently in-flight PortAPI request. Used to
|
||||
* intercept and mangle with responses.
|
||||
*/
|
||||
export interface InspectedActiveRequest {
|
||||
// The type of request.
|
||||
type: RequestType;
|
||||
// The actual request payload.
|
||||
// @todo(ppacher): typings
|
||||
payload: any;
|
||||
// The request observer. Use to inject data
|
||||
// or complete/error the subscriber. Use with
|
||||
// care!
|
||||
observer: Subscriber<DataReply<any>>;
|
||||
// Counter for the number of messages received
|
||||
// for this request.
|
||||
messagesReceived: number;
|
||||
// The last data received on the request
|
||||
lastData: any;
|
||||
// The last key received on the request
|
||||
lastKey: string;
|
||||
}
|
||||
|
||||
export interface RetryableOpts {
|
||||
// A delay in milliseconds before retrying an operation.
|
||||
retryDelay?: number;
|
||||
// The maximum number of retries.
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
export interface ProfileImportResult extends ImportResult {
|
||||
replacesProfiles: string[];
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
restartRequired: boolean;
|
||||
replacesExisting: boolean;
|
||||
containsUnknown: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a RxJS operator function that implements a retry pipeline
|
||||
* with a configurable retry delay and an optional maximum retry count.
|
||||
* If maxRetries is reached the last error captured is thrown.
|
||||
*
|
||||
* @param opts Configuration options for the retryPipeline.
|
||||
* see {@type RetryableOpts} for more information.
|
||||
*/
|
||||
export function retryPipeline<T>({ retryDelay, maxRetries }: RetryableOpts = {}): MonoTypeOperatorFunction<T> {
|
||||
return retryWhen(errors => errors.pipe(
|
||||
// use concatMap to keep the errors in order and make sure
|
||||
// they don't execute in parallel.
|
||||
concatMap((e, i) =>
|
||||
iif(
|
||||
// conditional observable seletion, throwError if i > maxRetries
|
||||
// or a retryDelay otherwise
|
||||
() => i > (maxRetries || Infinity),
|
||||
throwError(() => e),
|
||||
of(e).pipe(delay(retryDelay || 1000))
|
||||
)
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
export interface WatchOpts extends RetryableOpts {
|
||||
// Whether or not `new` updates should be filtered
|
||||
// or let through. See {@method PortAPI.watch} for
|
||||
// more information.
|
||||
ingoreNew?: boolean;
|
||||
|
||||
ignoreDelete?: boolean;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Serializes a request or reply message into it's wire format.
|
||||
*
|
||||
* @param msg The request or reply messsage to serialize
|
||||
*/
|
||||
export function serializeMessage(msg: RequestMessage | ReplyMessage): any {
|
||||
if (msg === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let blob = `${msg.id}|${msg.type}`;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'done': // reply
|
||||
case 'success': // reply
|
||||
case 'cancel': // request
|
||||
break;
|
||||
|
||||
case 'error': // reply
|
||||
case 'warning': // reply
|
||||
blob += `|${msg.message}`
|
||||
break;
|
||||
|
||||
case 'ok': // reply
|
||||
case 'upd': // reply
|
||||
case 'new': // reply
|
||||
case 'insert': // request
|
||||
case 'update': // request
|
||||
case 'create': // request
|
||||
blob += `|${msg.key}|J${JSON.stringify(msg.data)}`
|
||||
break;
|
||||
|
||||
|
||||
case 'del': // reply
|
||||
case 'get': // request
|
||||
case 'delete': // request
|
||||
blob += `|${msg.key}`
|
||||
break;
|
||||
|
||||
case 'query': // request
|
||||
case 'sub': // request
|
||||
case 'qsub': // request
|
||||
blob += `|query ${msg.query}`
|
||||
break;
|
||||
|
||||
default:
|
||||
// We need (msg as any) here because typescript knows that we covered
|
||||
// all possible values above and that .type can never be something else.
|
||||
// Still, we want to guard against unexpected portmaster message
|
||||
// types.
|
||||
console.error(`Unknown message type ${(msg as any).type}`);
|
||||
}
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserializes (loads) a PortAPI message from a WebSocket message event.
|
||||
*
|
||||
* @param event The WebSocket MessageEvent to parse.
|
||||
*/
|
||||
export function deserializeMessage(event: MessageEvent): RequestMessage | ReplyMessage {
|
||||
let data: string;
|
||||
|
||||
if (typeof event.data !== 'string') {
|
||||
data = new TextDecoder("utf-8").decode(event.data)
|
||||
} else {
|
||||
data = event.data;
|
||||
}
|
||||
|
||||
const parts = data.split("|");
|
||||
|
||||
if (parts.length < 2) {
|
||||
throw new Error(`invalid number of message parts, expected 3-4 but got ${parts.length}`);
|
||||
}
|
||||
|
||||
const id = parts[0];
|
||||
const type = parts[1] as MessageType;
|
||||
|
||||
var msg: Partial<RequestMessage | ReplyMessage> = {
|
||||
id,
|
||||
type,
|
||||
}
|
||||
|
||||
if (parts.length > 4) {
|
||||
parts[3] = parts.slice(3).join('|')
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case 'done': // reply
|
||||
case 'success': // reply
|
||||
case 'cancel': // request
|
||||
break;
|
||||
|
||||
case 'error': // reply
|
||||
case 'warning': // reply
|
||||
msg.message = parts[2];
|
||||
break;
|
||||
|
||||
case 'ok': // reply
|
||||
case 'upd': // reply
|
||||
case 'new': // reply
|
||||
case 'insert': // request
|
||||
case 'update': // request
|
||||
case 'create': // request
|
||||
msg.key = parts[2];
|
||||
try {
|
||||
if (parts[3][0] === 'J') {
|
||||
msg.data = JSON.parse(parts[3].slice(1));
|
||||
} else {
|
||||
msg.data = parts[3];
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e, data)
|
||||
}
|
||||
break;
|
||||
|
||||
case 'del': // reply
|
||||
case 'get': // request
|
||||
case 'delete': // request
|
||||
msg.key = parts[2];
|
||||
break;
|
||||
|
||||
case 'query': // request
|
||||
case 'sub': // request
|
||||
case 'qsub': // request
|
||||
msg.query = parts[2];
|
||||
if (msg.query.startsWith("query ")) {
|
||||
msg.query = msg.query.slice(6);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// We need (msg as any) here because typescript knows that we covered
|
||||
// all possible values above and that .type can never be something else.
|
||||
// Still, we want to guard against unexpected portmaster message
|
||||
// types.
|
||||
console.error(`Unknown message type ${(msg as any).type}`);
|
||||
}
|
||||
|
||||
return msg as (ReplyMessage | RequestMessage); // it's not partitial anymore
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { HttpClient, HttpParams, HttpResponse } from "@angular/common/http";
|
||||
import { Inject, Injectable } from "@angular/core";
|
||||
import { BehaviorSubject, Observable, of } from "rxjs";
|
||||
import { filter, map, share, switchMap } from "rxjs/operators";
|
||||
import { FeatureID } from "./features";
|
||||
import { PORTMASTER_HTTP_API_ENDPOINT, PortapiService } from './portapi.service';
|
||||
import { Feature, Pin, SPNStatus, UserProfile } from "./spn.types";
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SPNService {
|
||||
|
||||
/** Emits the SPN status whenever it changes */
|
||||
status$: Observable<SPNStatus>;
|
||||
|
||||
profile$ = this.watchProfile()
|
||||
.pipe(
|
||||
share({ connector: () => new BehaviorSubject<UserProfile | null | undefined>(undefined) }),
|
||||
filter(val => val !== undefined)
|
||||
) as Observable<UserProfile | null>;
|
||||
|
||||
private pins$: Observable<Pin[]>;
|
||||
|
||||
constructor(
|
||||
private portapi: PortapiService,
|
||||
private http: HttpClient,
|
||||
@Inject(PORTMASTER_HTTP_API_ENDPOINT) private httpAPI: string,
|
||||
) {
|
||||
this.status$ = this.portapi.watch<SPNStatus>('runtime:spn/status', { ignoreDelete: true })
|
||||
.pipe(
|
||||
share({ connector: () => new BehaviorSubject<any | null>(null) }),
|
||||
filter(val => val !== null),
|
||||
)
|
||||
|
||||
this.pins$ = this.status$
|
||||
.pipe(
|
||||
switchMap(status => {
|
||||
if (status.Status !== "disabled") {
|
||||
return this.portapi.watchAll<Pin>("map:main/", { retryDelay: 50000 })
|
||||
}
|
||||
|
||||
return of([] as Pin[]);
|
||||
}),
|
||||
share({ connector: () => new BehaviorSubject<Pin[] | undefined>(undefined) }),
|
||||
filter(val => val !== undefined)
|
||||
) as Observable<Pin[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches all pins of the "main" SPN map.
|
||||
*/
|
||||
watchPins(): Observable<Pin[]> {
|
||||
return this.pins$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a unicode string to base64.
|
||||
* See https://developer.mozilla.org/en-US/docs/Web/API/btoa
|
||||
* and https://stackoverflow.com/questions/30106476/using-javascripts-atob-to-decode-base64-doesnt-properly-decode-utf-8-strings
|
||||
*/
|
||||
b64EncodeUnicode(str: string): string {
|
||||
return window.btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (match, p1) {
|
||||
return String.fromCharCode(parseInt(p1, 16))
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs into the SPN user account
|
||||
*/
|
||||
login({ username, password }: { username: string, password: string }): Observable<HttpResponse<string>> {
|
||||
return this.http.post(`${this.httpAPI}/v1/spn/account/login`, undefined, {
|
||||
headers: {
|
||||
Authorization: `Basic ${this.b64EncodeUnicode(username + ':' + password)}`
|
||||
},
|
||||
responseType: 'text',
|
||||
observe: 'response'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out of the SPN user account
|
||||
*
|
||||
* @param purge Whether or not the portmaster should keep user/device information for the next login
|
||||
*/
|
||||
logout(purge = false): Observable<HttpResponse<string>> {
|
||||
let params = new HttpParams();
|
||||
if (!!purge) {
|
||||
params = params.set("purge", "true")
|
||||
}
|
||||
return this.http.delete(`${this.httpAPI}/v1/spn/account/logout`, {
|
||||
params,
|
||||
responseType: 'text',
|
||||
observe: 'response'
|
||||
})
|
||||
}
|
||||
|
||||
watchEnabledFeatures(): Observable<(Feature & { enabled: boolean })[]> {
|
||||
return this.profile$
|
||||
.pipe(
|
||||
switchMap(profile => {
|
||||
return this.loadFeaturePackages()
|
||||
.pipe(
|
||||
map(features => {
|
||||
return features.map(feature => {
|
||||
// console.log(feature, profile?.current_plan?.feature_ids)
|
||||
return {
|
||||
...feature,
|
||||
enabled: feature.RequiredFeatureID === FeatureID.None || profile?.current_plan?.feature_ids?.includes(feature.RequiredFeatureID) || false,
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns a list of all feature packages */
|
||||
loadFeaturePackages(): Observable<Feature[]> {
|
||||
return this.http.get<{ Features: Feature[] }>(`${this.httpAPI}/v1/account/features`)
|
||||
.pipe(
|
||||
map(response => response.Features.map(feature => {
|
||||
return {
|
||||
...feature,
|
||||
IconURL: `${this.httpAPI}/v1/account/features/${feature.ID}/icon`,
|
||||
}
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current SPN user profile.
|
||||
*
|
||||
* @param refresh Whether or not the user profile should be refreshed from the ticket agent
|
||||
* @returns
|
||||
*/
|
||||
userProfile(refresh = false): Observable<UserProfile> {
|
||||
let params = new HttpParams();
|
||||
if (!!refresh) {
|
||||
params = params.set("refresh", true)
|
||||
}
|
||||
return this.http.get<UserProfile>(`${this.httpAPI}/v1/spn/account/user/profile`, {
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches the user profile. It will emit null if there is no profile available yet.
|
||||
*/
|
||||
watchProfile(): Observable<UserProfile | null> {
|
||||
let hasSent = false;
|
||||
return this.portapi.watch<UserProfile>('core:spn/account/user', { ignoreDelete: true }, { forwardDone: true })
|
||||
.pipe(
|
||||
filter(result => {
|
||||
if ('type' in result && result.type === 'done') {
|
||||
if (hasSent) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}),
|
||||
map(result => {
|
||||
hasSent = true;
|
||||
if ('type' in result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { FeatureID } from './features';
|
||||
import { CountryInfo, GeoCoordinates, IntelEntity } from './network.types';
|
||||
import { Record } from './portapi.types';
|
||||
|
||||
export interface SPNStatus extends Record {
|
||||
Status: 'failed' | 'disabled' | 'connecting' | 'connected';
|
||||
HomeHubID: string;
|
||||
HomeHubName: string;
|
||||
ConnectedIP: string;
|
||||
ConnectedTransport: string;
|
||||
ConnectedCountry: CountryInfo | null;
|
||||
ConnectedSince: string | null;
|
||||
}
|
||||
|
||||
export interface Pin extends Record {
|
||||
ID: string;
|
||||
Name: string;
|
||||
FirstSeen: string;
|
||||
EntityV4?: IntelEntity | null;
|
||||
EntityV6?: IntelEntity | null;
|
||||
States: string[];
|
||||
SessionActive: boolean;
|
||||
HopDistance: number;
|
||||
ConnectedTo: {
|
||||
[key: string]: Lane,
|
||||
};
|
||||
Route: string[] | null;
|
||||
VerifiedOwner: string;
|
||||
}
|
||||
|
||||
export interface Lane {
|
||||
HubID: string;
|
||||
Capacity: number;
|
||||
Latency: number;
|
||||
}
|
||||
|
||||
export function getPinCoords(p: Pin): GeoCoordinates | null {
|
||||
if (p.EntityV4 && p.EntityV4.Coordinates) {
|
||||
return p.EntityV4.Coordinates;
|
||||
}
|
||||
return p.EntityV6?.Coordinates || null;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
ends_at: string;
|
||||
state: 'manual' | 'active' | 'cancelled';
|
||||
next_billing_date: string;
|
||||
payment_provider: string;
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
name: string;
|
||||
amount: number;
|
||||
months: number;
|
||||
renewable: boolean;
|
||||
feature_ids: FeatureID[];
|
||||
}
|
||||
|
||||
export interface View {
|
||||
Message: string;
|
||||
ShowAccountData: boolean;
|
||||
ShowAccountButton: boolean;
|
||||
ShowLoginButton: boolean;
|
||||
ShowRefreshButton: boolean;
|
||||
ShowLogoutButton: boolean;
|
||||
}
|
||||
|
||||
export interface UserProfile extends Record {
|
||||
username: string;
|
||||
state: string;
|
||||
balance: number;
|
||||
device: Device | null;
|
||||
subscription: Subscription | null;
|
||||
current_plan: Plan | null;
|
||||
next_plan: Plan | null;
|
||||
view: View | null;
|
||||
LastNotifiedOfEnd?: string;
|
||||
LoggedInAt?: string;
|
||||
}
|
||||
|
||||
export interface Package {
|
||||
Name: string;
|
||||
HexColor: string;
|
||||
}
|
||||
|
||||
export interface Feature {
|
||||
ID: string;
|
||||
Name: string;
|
||||
ConfigKey: string;
|
||||
ConfigScope: string;
|
||||
RequiredFeatureID: FeatureID;
|
||||
InPackage: Package | null;
|
||||
Comment: string;
|
||||
Beta?: boolean;
|
||||
ComingSoon?: boolean;
|
||||
|
||||
// does not come from the PM API but is set by SPNService
|
||||
IconURL: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
export function deepClone<T = any>(o?: T | null): T {
|
||||
if (o === null || o === undefined) {
|
||||
return null as any as T;
|
||||
}
|
||||
|
||||
let _out: T = (Array.isArray(o) ? [] : {}) as T;
|
||||
for (let _key in (o as T)) {
|
||||
let v = o[_key];
|
||||
_out[_key] = (typeof v === "object") ? deepClone(v) : v;
|
||||
}
|
||||
return _out as T;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { webSocket, WebSocketSubject, WebSocketSubjectConfig } from 'rxjs/webSocket';
|
||||
|
||||
@Injectable()
|
||||
export class WebsocketService {
|
||||
constructor() { }
|
||||
|
||||
/**
|
||||
* createConnection creates a new websocket connection using opts.
|
||||
*
|
||||
* @param opts Options for the websocket connection.
|
||||
*/
|
||||
createConnection<T>(opts: WebSocketSubjectConfig<T>): WebSocketSubject<T> {
|
||||
return webSocket(opts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Public API Surface of portmaster-api
|
||||
*/
|
||||
|
||||
export * from './lib/app-profile.service';
|
||||
export * from './lib/app-profile.types';
|
||||
export * from './lib/config.service';
|
||||
export * from './lib/config.types';
|
||||
export * from './lib/core.types';
|
||||
export * from './lib/debug-api.service';
|
||||
export * from './lib/features';
|
||||
export * from './lib/meta-api.service';
|
||||
export * from './lib/module';
|
||||
export * from './lib/netquery.service';
|
||||
export * from './lib/network.types';
|
||||
export * from './lib/portapi.service';
|
||||
export * from './lib/portapi.types';
|
||||
export * from './lib/spn.service';
|
||||
export * from './lib/spn.types';
|
||||
export * from './lib/utils';
|
||||
export * from './lib/websocket.service';
|
||||
|
||||
15
desktop/angular/projects/safing/portmaster-api/src/test.ts
Normal file
15
desktop/angular/projects/safing/portmaster-api/src/test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
|
||||
|
||||
import 'zone.js';
|
||||
import 'zone.js/testing';
|
||||
import { getTestBed } from '@angular/core/testing';
|
||||
import {
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting
|
||||
} from '@angular/platform-browser-dynamic/testing';
|
||||
|
||||
// First, initialize the Angular testing environment.
|
||||
getTestBed().initTestEnvironment(
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting(),
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../out-tsc/lib",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"exclude": [
|
||||
"src/test.ts",
|
||||
"testing/**/*",
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "./tsconfig.lib.json",
|
||||
"compilerOptions": {
|
||||
"declarationMap": false
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"files": [
|
||||
"testing/**/*.ts"
|
||||
],
|
||||
"include": [
|
||||
"testing/**/*.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/*.d.ts"
|
||||
]
|
||||
}
|
||||
44
desktop/angular/projects/safing/ui/.eslintrc.json
Normal file
44
desktop/angular/projects/safing/ui/.eslintrc.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"extends": "../../../.eslintrc.json",
|
||||
"ignorePatterns": [
|
||||
"!**/*"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"*.ts"
|
||||
],
|
||||
"parserOptions": {
|
||||
"project": [
|
||||
"projects/safing/ui/tsconfig.lib.json",
|
||||
"projects/safing/ui/tsconfig.spec.json"
|
||||
],
|
||||
"createDefaultProgram": true
|
||||
},
|
||||
"rules": {
|
||||
"@angular-eslint/directive-selector": [
|
||||
"error",
|
||||
{
|
||||
"type": "attribute",
|
||||
"prefix": "sfng",
|
||||
"style": "camelCase"
|
||||
}
|
||||
],
|
||||
"@angular-eslint/component-selector": [
|
||||
"error",
|
||||
{
|
||||
"type": "element",
|
||||
"prefix": "sfng",
|
||||
"style": "kebab-case"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"*.html"
|
||||
],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
24
desktop/angular/projects/safing/ui/README.md
Normal file
24
desktop/angular/projects/safing/ui/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Ui
|
||||
|
||||
This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 12.2.0.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Run `ng generate component component-name --project ui` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project ui`.
|
||||
> Note: Don't forget to add `--project ui` or else it will be added to the default project in your `angular.json` file.
|
||||
|
||||
## Build
|
||||
|
||||
Run `ng build ui` to build the project. The build artifacts will be stored in the `dist/` directory.
|
||||
|
||||
## Publishing
|
||||
|
||||
After building your library with `ng build ui`, go to the dist folder `cd dist/ui` and run `npm publish`.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `ng test ui` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||
|
||||
## Further help
|
||||
|
||||
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.
|
||||
44
desktop/angular/projects/safing/ui/karma.conf.js
Normal file
44
desktop/angular/projects/safing/ui/karma.conf.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// Karma configuration file, see link for more information
|
||||
// https://karma-runner.github.io/1.0/config/configuration-file.html
|
||||
|
||||
module.exports = function (config) {
|
||||
config.set({
|
||||
basePath: '',
|
||||
frameworks: ['jasmine', '@angular-devkit/build-angular'],
|
||||
plugins: [
|
||||
require('karma-jasmine'),
|
||||
require('karma-chrome-launcher'),
|
||||
require('karma-jasmine-html-reporter'),
|
||||
require('karma-coverage'),
|
||||
require('@angular-devkit/build-angular/plugins/karma')
|
||||
],
|
||||
client: {
|
||||
jasmine: {
|
||||
// you can add configuration options for Jasmine here
|
||||
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
|
||||
// for example, you can disable the random execution with `random: false`
|
||||
// or set a specific seed with `seed: 4321`
|
||||
},
|
||||
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
||||
},
|
||||
jasmineHtmlReporter: {
|
||||
suppressAll: true // removes the duplicated traces
|
||||
},
|
||||
coverageReporter: {
|
||||
dir: require('path').join(__dirname, '../../../coverage/safing/ui'),
|
||||
subdir: '.',
|
||||
reporters: [
|
||||
{ type: 'html' },
|
||||
{ type: 'text-summary' }
|
||||
]
|
||||
},
|
||||
reporters: ['progress', 'kjhtml'],
|
||||
port: 9876,
|
||||
colors: true,
|
||||
logLevel: config.LOG_INFO,
|
||||
autoWatch: true,
|
||||
browsers: ['Chrome'],
|
||||
singleRun: false,
|
||||
restartOnFileChange: true
|
||||
});
|
||||
};
|
||||
11
desktop/angular/projects/safing/ui/ng-package.json
Normal file
11
desktop/angular/projects/safing/ui/ng-package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "../../../dist-lib/safing/ui",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
},
|
||||
"assets": [
|
||||
"theming.scss",
|
||||
"**/_*.scss"
|
||||
]
|
||||
}
|
||||
17
desktop/angular/projects/safing/ui/package.json
Normal file
17
desktop/angular/projects/safing/ui/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@safing/ui",
|
||||
"version": "0.0.1",
|
||||
"peerDependencies": {
|
||||
"@angular/common": "~12.2.0",
|
||||
"@angular/core": "~12.2.0",
|
||||
"@angular/cdk": "~12.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"exports": {
|
||||
"./theming": {
|
||||
"sass": "./theming.scss"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<ng-content></ng-content>
|
||||
@@ -0,0 +1,116 @@
|
||||
import { coerceBooleanProperty } from '@angular/cdk/coercion';
|
||||
import { ChangeDetectionStrategy, Component, Input, OnDestroy, TemplateRef } from '@angular/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { SfngAccordionComponent } from './accordion';
|
||||
|
||||
@Component({
|
||||
selector: 'sfng-accordion-group',
|
||||
templateUrl: './accordion-group.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SfngAccordionGroupComponent implements OnDestroy {
|
||||
/** @private Currently registered accordion components */
|
||||
accordions: SfngAccordionComponent[] = [];
|
||||
|
||||
/**
|
||||
* A template-ref to render as the header for each accordion-component.
|
||||
* Receives the accordion data as an $implicit context.
|
||||
*/
|
||||
@Input()
|
||||
set headerTemplate(v: TemplateRef<any> | null) {
|
||||
this._headerTemplate = v;
|
||||
|
||||
if (!!this.accordions.length) {
|
||||
this.accordions.forEach(a => {
|
||||
a.headerTemplate = v;
|
||||
a.cdr.markForCheck();
|
||||
})
|
||||
}
|
||||
}
|
||||
get headerTemplate() { return this._headerTemplate }
|
||||
private _headerTemplate: TemplateRef<any> | null = null;
|
||||
|
||||
/** Whether or not one or more components can be expanded. */
|
||||
@Input()
|
||||
set singleMode(v: any) {
|
||||
this._singleMode = coerceBooleanProperty(v);
|
||||
}
|
||||
get singleMode() { return this._singleMode }
|
||||
private _singleMode = false;
|
||||
|
||||
/** Whether or not the accordion is disabled and does not allow expanding */
|
||||
@Input()
|
||||
set disabled(v: any) {
|
||||
this._disabled = coerceBooleanProperty(v);
|
||||
if (this._disabled) {
|
||||
this.accordions.forEach(a => a.active = false);
|
||||
}
|
||||
}
|
||||
get disabled(): boolean { return this._disabled; }
|
||||
private _disabled = false;
|
||||
|
||||
/** A list of subscriptions to the activeChange output of the registered accordion-components */
|
||||
private subscriptions: Subscription[] = [];
|
||||
|
||||
/**
|
||||
* Registeres an accordion component to be handled together with this
|
||||
* accordion group.
|
||||
*
|
||||
* @param a The accordion component to register
|
||||
*/
|
||||
register(a: SfngAccordionComponent) {
|
||||
this.accordions.push(a);
|
||||
|
||||
// Tell the accordion-component about the default header-template.
|
||||
if (!a.headerTemplate) {
|
||||
a.headerTemplate = this.headerTemplate;
|
||||
}
|
||||
|
||||
// Subscribe to the activeChange output of the registered
|
||||
// accordion and call toggle() for each event emitted.
|
||||
this.subscriptions.push(a.activeChange.subscribe(() => {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.toggle(a);
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters a accordion component
|
||||
*
|
||||
* @param a The accordion component to unregister
|
||||
*/
|
||||
unregister(a: SfngAccordionComponent) {
|
||||
const index = this.accordions.indexOf(a);
|
||||
if (index === -1) return;
|
||||
|
||||
const subscription = this.subscriptions[index];
|
||||
|
||||
subscription.unsubscribe();
|
||||
this.accordions = this.accordions.splice(index, 1);
|
||||
this.subscriptions = this.subscriptions.splice(index, 1);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subscriptions.forEach(s => s.unsubscribe());
|
||||
this.subscriptions = [];
|
||||
this.accordions = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand an accordion component and collaps all others if
|
||||
* single-mode is selected.
|
||||
*
|
||||
* @param a The accordion component to toggle.
|
||||
*/
|
||||
private toggle(a: SfngAccordionComponent) {
|
||||
if (!a.active && this._singleMode) {
|
||||
this.accordions?.forEach(a => a.active = false);
|
||||
}
|
||||
|
||||
a.active = !a.active;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<div [class.active]="active" [class.cursor-pointer]="!group || !group.disabled" (click)="toggle($event)">
|
||||
<ng-container *ngTemplateOutlet="headerTemplate; context: {$implicit: data, active: active, accordion: component}">
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="h-auto overflow-visible opacity-100" *ngIf="active" [@fadeIn] [@fadeOut]>
|
||||
<ng-container>
|
||||
<ng-content></ng-content>
|
||||
</ng-container>
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
import { SfngAccordionComponent } from "./accordion";
|
||||
import { SfngAccordionGroupComponent } from "./accordion-group";
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
],
|
||||
declarations: [
|
||||
SfngAccordionGroupComponent,
|
||||
SfngAccordionComponent,
|
||||
],
|
||||
exports: [
|
||||
SfngAccordionGroupComponent,
|
||||
SfngAccordionComponent,
|
||||
]
|
||||
})
|
||||
export class SfngAccordionModule { }
|
||||
@@ -0,0 +1,88 @@
|
||||
import { coerceBooleanProperty } from '@angular/cdk/coercion';
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, HostBinding, Input, OnDestroy, OnInit, Optional, Output, TemplateRef, TrackByFunction } from '@angular/core';
|
||||
import { fadeInAnimation, fadeOutAnimation } from '../animations';
|
||||
import { SfngAccordionGroupComponent } from './accordion-group';
|
||||
|
||||
@Component({
|
||||
selector: 'sfng-accordion',
|
||||
templateUrl: './accordion.html',
|
||||
exportAs: 'sfngAccordion',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [
|
||||
fadeInAnimation,
|
||||
fadeOutAnimation
|
||||
]
|
||||
})
|
||||
export class SfngAccordionComponent<T = any> implements OnInit, OnDestroy {
|
||||
/** @deprecated in favor of [data] */
|
||||
@Input()
|
||||
title: string = '';
|
||||
|
||||
/** A reference to the component provided via the template context */
|
||||
component = this;
|
||||
|
||||
/**
|
||||
* The data the accordion component is used for. This is passed as an $implicit context
|
||||
* to the header template.
|
||||
*/
|
||||
@Input()
|
||||
data: T | undefined = undefined;
|
||||
|
||||
@Input()
|
||||
trackBy: TrackByFunction<T | null> = (_, c) => c
|
||||
|
||||
/** Whether or not the accordion component starts active. */
|
||||
@Input()
|
||||
set active(v: any) {
|
||||
this._active = coerceBooleanProperty(v);
|
||||
}
|
||||
get active() {
|
||||
return this._active;
|
||||
}
|
||||
private _active: boolean = false;
|
||||
|
||||
/** Emits whenever the active value changes. Supports two-way bindings. */
|
||||
@Output()
|
||||
activeChange = new EventEmitter<boolean>();
|
||||
|
||||
/**
|
||||
* The header-template to render for this component. If null, the default template from
|
||||
* the parent accordion-group will be used.
|
||||
*/
|
||||
@Input()
|
||||
headerTemplate: TemplateRef<any> | null = null;
|
||||
|
||||
@HostBinding('class.active')
|
||||
/** @private Whether or not the accordion should have the 'active' class */
|
||||
get activeClass(): string {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// register at our parent group-component (if any).
|
||||
this.group?.register(this);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.group?.unregister(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the active-state of the accordion-component.
|
||||
*
|
||||
* @param event The mouse event.
|
||||
*/
|
||||
toggle(event?: Event) {
|
||||
if (!!this.group && this.group.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
event?.preventDefault();
|
||||
this.activeChange.emit(!this.active);
|
||||
}
|
||||
|
||||
constructor(
|
||||
public cdr: ChangeDetectorRef,
|
||||
@Optional() public group: SfngAccordionGroupComponent,
|
||||
) { }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { SfngAccordionComponent } from './accordion';
|
||||
export { SfngAccordionGroupComponent } from './accordion-group';
|
||||
export { SfngAccordionModule } from './accordion.module';
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { animate, query, stagger, style, transition, trigger } from '@angular/animations';
|
||||
|
||||
export const fadeInAnimation = trigger(
|
||||
'fadeIn',
|
||||
[
|
||||
transition(
|
||||
':enter',
|
||||
[
|
||||
style({ opacity: 0, transform: 'translateY(-5px)' }),
|
||||
animate('120ms cubic-bezier(0, 0, 0.2, 1)',
|
||||
style({ opacity: 1, transform: 'translateY(0px)' }))
|
||||
]
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
export const fadeOutAnimation = trigger(
|
||||
'fadeOut',
|
||||
[
|
||||
transition(
|
||||
':leave',
|
||||
[
|
||||
style({ opacity: 1, transform: 'translateY(0px)' }),
|
||||
animate('120ms cubic-bezier(0, 0, 0.2, 1)',
|
||||
style({ opacity: 0, transform: 'translateY(-5px)' }))
|
||||
]
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
export const fadeInListAnimation = trigger(
|
||||
'fadeInList',
|
||||
[
|
||||
transition(':enter, * => 0, * => -1', []),
|
||||
transition(':increment', [
|
||||
query(':enter', [
|
||||
style({ opacity: 0 }),
|
||||
stagger(5, [
|
||||
animate('300ms ease-out', style({ opacity: 1 })),
|
||||
]),
|
||||
], { optional: true })
|
||||
]),
|
||||
]
|
||||
)
|
||||
|
||||
export const moveInOutAnimation = trigger(
|
||||
'moveInOut',
|
||||
[
|
||||
transition(
|
||||
':enter',
|
||||
[
|
||||
style({ opacity: 0, transform: 'translateX(100%)' }),
|
||||
animate('.2s ease-in',
|
||||
style({ opacity: 1, transform: 'translateX(0%)' }))
|
||||
]
|
||||
),
|
||||
transition(
|
||||
':leave',
|
||||
[
|
||||
style({ opacity: 1 }),
|
||||
animate('.2s ease-out',
|
||||
style({ opacity: 0, transform: 'translateX(100%)' }))
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
export const moveInOutListAnimation = trigger(
|
||||
'moveInOutList',
|
||||
[
|
||||
transition(':enter, * => 0, * => -1', []),
|
||||
transition(':increment', [
|
||||
query(':enter', [
|
||||
style({ opacity: 0, transform: 'translateX(100%)' }),
|
||||
stagger(50, [
|
||||
animate('200ms ease-out', style({ opacity: 1, transform: 'translateX(0%)' })),
|
||||
]),
|
||||
], { optional: true })
|
||||
]),
|
||||
transition(':decrement', [
|
||||
query(':leave', [
|
||||
stagger(-50, [
|
||||
animate('200ms ease-out', style({ opacity: 0, transform: 'translateX(100%)' })),
|
||||
]),
|
||||
], { optional: true })
|
||||
]),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
.sfng-confirm-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
caption {
|
||||
@apply text-sm;
|
||||
opacity: .6;
|
||||
font-size: .6rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.message,
|
||||
h1 {
|
||||
flex-shrink: 0;
|
||||
text-overflow: ellipsis;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 0.75rem;
|
||||
flex-grow: 1;
|
||||
opacity: .6;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.message~input {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 95%;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
opacity: .7;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
@apply text-primary;
|
||||
@apply bg-gray-500 border-gray-400 bg-opacity-75 border-opacity-75;
|
||||
|
||||
&::placeholder {
|
||||
@apply text-tertiary;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
|
||||
button.action-button {
|
||||
&:not(:last-child) {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
&:not(.outline) {
|
||||
@apply bg-blue;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
@apply bg-red-300;
|
||||
}
|
||||
|
||||
&.outline {
|
||||
@apply outline-none;
|
||||
@apply border;
|
||||
@apply border-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
&>span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
label {
|
||||
margin-left: .5rem;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
sfng-dialog-container {
|
||||
.container {
|
||||
display: block;
|
||||
box-shadow: 0px 0px 5px 2px rgba(0, 0, 0, 0.75);
|
||||
@apply p-6;
|
||||
@apply bg-gray-300;
|
||||
@apply rounded;
|
||||
min-width: 20rem;
|
||||
width: fit-content;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#drag-handle {
|
||||
display: block;
|
||||
height: 6px;
|
||||
background-color: white;
|
||||
opacity: .4;
|
||||
border-radius: 3px;
|
||||
position: absolute;
|
||||
bottom: calc(0.5rem - 2px);
|
||||
width: 30%;
|
||||
left: calc(50% - 15%);
|
||||
|
||||
&:hover {
|
||||
opacity: .8;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<div class="sfng-confirm-dialog">
|
||||
<caption *ngIf="config.caption">{{config.caption}}</caption>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" *ngIf="config.canCancel" class="w-5 h-5 close-icon" viewBox="0 0 20 20"
|
||||
fill="currentColor" (click)="select()">
|
||||
<path fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
||||
|
||||
<h1 *ngIf="config.header">{{config.header}}</h1>
|
||||
|
||||
<span class="message" *ngIf="config.message">{{ config.message }}</span>
|
||||
|
||||
<input *ngIf="!!config.inputType" [attr.type]="config.inputType" [(ngModel)]="config.inputModel"
|
||||
[attr.placeholder]="config.inputPlaceholder || null">
|
||||
|
||||
<div class="actions" *ngIf="!!config.buttons">
|
||||
<button *ngFor="let button of config.buttons" (click)="select(button.id)" type="button"
|
||||
class="action-button {{button.class}}">{{button.text}}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ChangeDetectionStrategy, Component, Inject, InjectionToken } from '@angular/core';
|
||||
import { SfngDialogRef, SFNG_DIALOG_REF } from './dialog.ref';
|
||||
|
||||
export interface ConfirmDialogButton {
|
||||
text: string;
|
||||
id: string;
|
||||
class?: 'danger' | 'outline';
|
||||
}
|
||||
|
||||
export interface ConfirmDialogConfig {
|
||||
buttons?: ConfirmDialogButton[];
|
||||
canCancel?: boolean;
|
||||
header?: string;
|
||||
message?: string;
|
||||
caption?: string;
|
||||
inputType?: 'text' | 'password';
|
||||
inputModel?: string;
|
||||
inputPlaceholder?: string;
|
||||
}
|
||||
|
||||
export const CONFIRM_DIALOG_CONFIG = new InjectionToken<ConfirmDialogConfig>('ConfirmDialogConfig');
|
||||
|
||||
@Component({
|
||||
templateUrl: './confirm.dialog.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SfngConfirmDialogComponent {
|
||||
constructor(
|
||||
@Inject(SFNG_DIALOG_REF) private dialogRef: SfngDialogRef<any>,
|
||||
@Inject(CONFIRM_DIALOG_CONFIG) public config: ConfirmDialogConfig,
|
||||
) {
|
||||
if (config.inputType !== undefined && config.inputModel === undefined) {
|
||||
config.inputModel = '';
|
||||
}
|
||||
}
|
||||
|
||||
select(action?: string) {
|
||||
this.dialogRef.close(action || null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { animate, state, style, transition, trigger } from "@angular/animations";
|
||||
|
||||
export const dialogAnimation = trigger(
|
||||
'dialogContainer',
|
||||
[
|
||||
state('void, exit', style({ opacity: 0, transform: 'scale(0.7)' })),
|
||||
state('enter', style({ transform: 'none', opacity: 1 })),
|
||||
transition(
|
||||
'* => enter',
|
||||
animate('120ms cubic-bezier(0, 0, 0.2, 1)',
|
||||
style({ opacity: 1, transform: 'translateY(0px)' }))
|
||||
),
|
||||
transition(
|
||||
'* => void, * => exit',
|
||||
animate('120ms cubic-bezier(0, 0, 0.2, 1)',
|
||||
style({ opacity: 0, transform: 'scale(0.7)' }))
|
||||
),
|
||||
]
|
||||
);
|
||||
@@ -0,0 +1,76 @@
|
||||
import { AnimationEvent } from '@angular/animations';
|
||||
import { CdkDrag } from '@angular/cdk/drag-drop';
|
||||
import { CdkPortalOutlet, ComponentPortal, Portal, TemplatePortal } from '@angular/cdk/portal';
|
||||
import { ChangeDetectorRef, Component, ComponentRef, EmbeddedViewRef, HostBinding, HostListener, InjectionToken, Input, ViewChild } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { dialogAnimation } from './dialog.animations';
|
||||
|
||||
export const SFNG_DIALOG_PORTAL = new InjectionToken<Portal<any>>('SfngDialogPortal');
|
||||
|
||||
export type SfngDialogState = 'opening' | 'open' | 'closing' | 'closed';
|
||||
|
||||
@Component({
|
||||
selector: 'sfng-dialog-container',
|
||||
template: `
|
||||
<div class="container" cdkDrag cdkDragRootElement=".cdk-overlay-pane" [cdkDragDisabled]="!dragable">
|
||||
<div *ngIf="dragable" cdkDragHandle id="drag-handle"></div>
|
||||
<ng-container cdkPortalOutlet></ng-container>
|
||||
</div>
|
||||
`,
|
||||
animations: [dialogAnimation]
|
||||
})
|
||||
export class SfngDialogContainerComponent<T> {
|
||||
onStateChange = new Subject<SfngDialogState>();
|
||||
|
||||
ref: ComponentRef<T> | EmbeddedViewRef<T> | null = null;
|
||||
|
||||
constructor(
|
||||
private cdr: ChangeDetectorRef,
|
||||
) { }
|
||||
|
||||
@HostBinding('@dialogContainer')
|
||||
state = 'enter';
|
||||
|
||||
@ViewChild(CdkPortalOutlet, { static: true })
|
||||
_portalOutlet: CdkPortalOutlet | null = null;
|
||||
|
||||
@ViewChild(CdkDrag, { static: true })
|
||||
drag!: CdkDrag;
|
||||
|
||||
attachComponentPortal(portal: ComponentPortal<T>): ComponentRef<T> {
|
||||
this.ref = this._portalOutlet!.attachComponentPortal(portal)
|
||||
return this.ref;
|
||||
}
|
||||
|
||||
attachTemplatePortal(portal: TemplatePortal<T>): EmbeddedViewRef<T> {
|
||||
this.ref = this._portalOutlet!.attachTemplatePortal(portal);
|
||||
return this.ref;
|
||||
}
|
||||
|
||||
@Input()
|
||||
dragable: boolean = false;
|
||||
|
||||
@HostListener('@dialogContainer.start', ['$event'])
|
||||
onAnimationStart({ toState }: AnimationEvent) {
|
||||
if (toState === 'enter') {
|
||||
this.onStateChange.next('opening');
|
||||
} else if (toState === 'exit') {
|
||||
this.onStateChange.next('closing');
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('@dialogContainer.done', ['$event'])
|
||||
onAnimationEnd({ toState }: AnimationEvent) {
|
||||
if (toState === 'enter') {
|
||||
this.onStateChange.next('open');
|
||||
} else if (toState === 'exit') {
|
||||
this.onStateChange.next('closed');
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts the exit animation */
|
||||
_startExit() {
|
||||
this.state = 'exit';
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { DragDropModule } from "@angular/cdk/drag-drop";
|
||||
import { OverlayModule } from "@angular/cdk/overlay";
|
||||
import { PortalModule } from "@angular/cdk/portal";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
import { SfngConfirmDialogComponent } from "./confirm.dialog";
|
||||
import { SfngDialogContainerComponent } from "./dialog.container";
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
OverlayModule,
|
||||
PortalModule,
|
||||
DragDropModule,
|
||||
FormsModule,
|
||||
],
|
||||
declarations: [
|
||||
SfngDialogContainerComponent,
|
||||
SfngConfirmDialogComponent,
|
||||
]
|
||||
})
|
||||
export class SfngDialogModule { }
|
||||
@@ -0,0 +1,62 @@
|
||||
import { OverlayRef } from "@angular/cdk/overlay";
|
||||
import { InjectionToken } from "@angular/core";
|
||||
import { Observable, PartialObserver, Subject } from "rxjs";
|
||||
import { filter, take } from "rxjs/operators";
|
||||
import { SfngDialogContainerComponent, SfngDialogState } from "./dialog.container";
|
||||
|
||||
export const SFNG_DIALOG_REF = new InjectionToken<SfngDialogRef<any>>('SfngDialogRef');
|
||||
|
||||
export class SfngDialogRef<T, R = any, D = any> {
|
||||
constructor(
|
||||
private _overlayRef: OverlayRef,
|
||||
private container: SfngDialogContainerComponent<T>,
|
||||
public readonly data: D,
|
||||
) {
|
||||
this.container.onStateChange
|
||||
.pipe(
|
||||
filter(state => state === 'closed'),
|
||||
take(1)
|
||||
)
|
||||
.subscribe(() => {
|
||||
this._overlayRef.detach();
|
||||
this._overlayRef.dispose();
|
||||
this.onClose.next(this.value);
|
||||
this.onClose.complete();
|
||||
});
|
||||
}
|
||||
|
||||
get onStateChange(): Observable<SfngDialogState> {
|
||||
return this.container.onStateChange;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @returns The overlayref that holds the dialog container.
|
||||
*/
|
||||
overlay() { return this._overlayRef }
|
||||
|
||||
/**
|
||||
* @returns the instance attached to the dialog container
|
||||
*/
|
||||
contentRef() { return this.container.ref! }
|
||||
|
||||
/** Value holds the value passed on close() */
|
||||
private value: R | null = null;
|
||||
|
||||
/**
|
||||
* Emits the result of the dialog and closes the overlay.
|
||||
*/
|
||||
onClose = new Subject<R | null>()
|
||||
|
||||
/** onAction only emits if close() is called with action. */
|
||||
onAction<T extends R>(action: T, observer: PartialObserver<T> | ((value: T) => void)): this {
|
||||
(this.onClose.pipe(filter(val => val === action)) as Observable<T>)
|
||||
.subscribe(observer as any); // typescript does not select the correct type overload here.
|
||||
return this;
|
||||
}
|
||||
|
||||
close(result?: R) {
|
||||
this.value = result || null;
|
||||
this.container._startExit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Overlay, OverlayConfig, OverlayPositionBuilder, PositionStrategy } from '@angular/cdk/overlay';
|
||||
import { ComponentPortal, ComponentType, TemplatePortal } from '@angular/cdk/portal';
|
||||
import { EmbeddedViewRef, Injectable, Injector } from '@angular/core';
|
||||
import { filter, take, takeUntil } from 'rxjs/operators';
|
||||
import { ConfirmDialogConfig, CONFIRM_DIALOG_CONFIG, SfngConfirmDialogComponent } from './confirm.dialog';
|
||||
import { SfngDialogContainerComponent } from './dialog.container';
|
||||
import { SfngDialogModule } from './dialog.module';
|
||||
import { SfngDialogRef, SFNG_DIALOG_REF } from './dialog.ref';
|
||||
|
||||
export interface BaseDialogConfig {
|
||||
/** whether or not the dialog should close on outside-clicks and ESC */
|
||||
autoclose?: boolean;
|
||||
|
||||
/** whether or not a backdrop should be visible */
|
||||
backdrop?: boolean | 'light';
|
||||
|
||||
/** whether or not the dialog should be dragable */
|
||||
dragable?: boolean;
|
||||
|
||||
/**
|
||||
* optional position strategy for the overlay. if omitted, the
|
||||
* overlay will be centered on the screen
|
||||
*/
|
||||
positionStrategy?: PositionStrategy;
|
||||
|
||||
/**
|
||||
* Optional data for the dialog that is available either via the
|
||||
* SfngDialogRef for ComponentPortals as an $implicit context value
|
||||
* for TemplatePortals.
|
||||
*
|
||||
* Note, for template portals, data is only set as an $implicit context
|
||||
* value if it is not yet set in the portal!
|
||||
*/
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export interface ComponentPortalConfig {
|
||||
injector?: Injector;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: SfngDialogModule })
|
||||
export class SfngDialogService {
|
||||
|
||||
constructor(
|
||||
private injector: Injector,
|
||||
private overlay: Overlay,
|
||||
) { }
|
||||
|
||||
position(): OverlayPositionBuilder {
|
||||
return this.overlay.position();
|
||||
}
|
||||
|
||||
create<T>(template: TemplatePortal<T>, opts?: BaseDialogConfig): SfngDialogRef<EmbeddedViewRef<T>>;
|
||||
create<T>(target: ComponentType<T>, opts?: BaseDialogConfig & ComponentPortalConfig): SfngDialogRef<T>;
|
||||
create<T>(target: ComponentType<T> | TemplatePortal<T>, opts: BaseDialogConfig & ComponentPortalConfig = {}): SfngDialogRef<any> {
|
||||
let position: PositionStrategy = opts?.positionStrategy || this.overlay
|
||||
.position()
|
||||
.global()
|
||||
.centerVertically()
|
||||
.centerHorizontally();
|
||||
|
||||
let hasBackdrop = true;
|
||||
let backdropClass = 'dialog-screen-backdrop';
|
||||
if (opts.backdrop !== undefined) {
|
||||
if (opts.backdrop === false) {
|
||||
hasBackdrop = false;
|
||||
} else if (opts.backdrop === 'light') {
|
||||
backdropClass = 'dialog-screen-backdrop-light';
|
||||
}
|
||||
}
|
||||
|
||||
const cfg = new OverlayConfig({
|
||||
scrollStrategy: this.overlay.scrollStrategies.noop(),
|
||||
positionStrategy: position,
|
||||
hasBackdrop: hasBackdrop,
|
||||
backdropClass: backdropClass,
|
||||
});
|
||||
const overlayref = this.overlay.create(cfg);
|
||||
|
||||
// create our dialog container and attach it to the
|
||||
// overlay.
|
||||
const containerPortal = new ComponentPortal<SfngDialogContainerComponent<T>>(
|
||||
SfngDialogContainerComponent,
|
||||
undefined,
|
||||
this.injector,
|
||||
)
|
||||
const containerRef = containerPortal.attach(overlayref);
|
||||
|
||||
if (!!opts.dragable) {
|
||||
containerRef.instance.dragable = true;
|
||||
}
|
||||
|
||||
// create the dialog ref
|
||||
const dialogRef = new SfngDialogRef<T>(overlayref, containerRef.instance, opts.data);
|
||||
|
||||
// prepare the content portal and attach it to the container
|
||||
let result: any;
|
||||
if (target instanceof TemplatePortal) {
|
||||
let r = containerRef.instance.attachTemplatePortal(target)
|
||||
|
||||
if (!!r.context && typeof r.context === 'object' && !('$implicit' in r.context)) {
|
||||
r.context = {
|
||||
$implicit: opts.data,
|
||||
...r.context,
|
||||
}
|
||||
}
|
||||
|
||||
result = r
|
||||
} else {
|
||||
const contentPortal = new ComponentPortal(target, null, Injector.create({
|
||||
providers: [
|
||||
{
|
||||
provide: SFNG_DIALOG_REF,
|
||||
useValue: dialogRef,
|
||||
}
|
||||
],
|
||||
parent: opts?.injector || this.injector,
|
||||
}));
|
||||
result = containerRef.instance.attachComponentPortal(contentPortal);
|
||||
}
|
||||
// update the container position now that we have some content.
|
||||
overlayref.updatePosition();
|
||||
|
||||
if (!!opts?.autoclose) {
|
||||
overlayref.outsidePointerEvents()
|
||||
.pipe(take(1))
|
||||
.subscribe(() => dialogRef.close());
|
||||
overlayref.keydownEvents()
|
||||
.pipe(
|
||||
takeUntil(overlayref.detachments()),
|
||||
filter(event => event.key === 'Escape')
|
||||
)
|
||||
.subscribe(() => {
|
||||
dialogRef.close();
|
||||
})
|
||||
}
|
||||
return dialogRef;
|
||||
}
|
||||
|
||||
confirm(opts: ConfirmDialogConfig): SfngDialogRef<SfngConfirmDialogComponent, string> {
|
||||
return this.create(SfngConfirmDialogComponent, {
|
||||
autoclose: opts.canCancel,
|
||||
injector: Injector.create({
|
||||
providers: [
|
||||
{
|
||||
provide: CONFIRM_DIALOG_CONFIG,
|
||||
useValue: opts,
|
||||
},
|
||||
],
|
||||
parent: this.injector,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { ConfirmDialogConfig } from './confirm.dialog';
|
||||
export * from './dialog.module';
|
||||
export * from './dialog.ref';
|
||||
export * from './dialog.service';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<div *ngIf="!externalTrigger" class="w-full" cdkOverlayOrigin #trigger="cdkOverlayOrigin" (click)="toggle(trigger)">
|
||||
<ng-template [ngTemplateOutlet]="triggerTemplate || defaultTriggerTemplate"></ng-template>
|
||||
</div>
|
||||
|
||||
<ng-template #defaultTriggerTemplate>
|
||||
<!-- TODO(ppacher): use a button rather than a div but first fix the button styling -->
|
||||
<div [class.rounded-b]="!isOpen"
|
||||
class="flex flex-row items-center justify-between w-full px-4 py-2 mt-6 bg-gray-100 rounded-t cursor-pointer hover:bg-gray-100 hover:bg-opacity-75 text-secondary">
|
||||
{{ label }}
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOffsetY]="offsetY" [cdkConnectedOverlayOffsetX]="offsetX"
|
||||
[cdkConnectedOverlayMinWidth]="minWidth" [cdkConnectedOverlayMinHeight]="minHeight"
|
||||
[cdkConnectedOverlayOrigin]="trigger!" [cdkConnectedOverlayOpen]="isOpen" (detach)="onOverlayClosed()"
|
||||
[cdkConnectedOverlayScrollStrategy]="scrollStrategy" (overlayOutsideClick)="onOutsideClick($event)"
|
||||
[cdkConnectedOverlayPositions]="positions">
|
||||
<div class="w-full overflow-hidden bg-gray-200 rounded-b shadow {{ overlayClass }}" [style.maxHeight]="maxHeight"
|
||||
[style.maxWidth]="maxWidth" [@fadeIn] [@fadeOut]>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</ng-template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user