Migrate Angular UI from portmaster-ui to desktop/angular. Update Earthfile to build libs, UI and tauri-builtin

This commit is contained in:
Patrick Pacher
2024-03-20 10:43:29 +01:00
parent 66381baa1a
commit 4b77945517
922 changed files with 84071 additions and 26 deletions

View 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/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
});
};

View File

@@ -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 { }

View File

@@ -0,0 +1,3 @@
<ext-header *ngIf="!isAuthorizeView"></ext-header>
<router-outlet></router-outlet>

View File

@@ -0,0 +1,3 @@
:host {
@apply bg-background text-white flex flex-col w-96 h-96;
}

View File

@@ -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);
}
})
}
}

View File

@@ -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 { }

View File

@@ -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>

View File

@@ -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);
})
}
}

View File

@@ -0,0 +1 @@
export * from './domain-list.component';

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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 { }

View File

@@ -0,0 +1 @@
export * from './header.component';

View File

@@ -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)
})
)
}
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,2 @@
export * from './welcome.module';

View File

@@ -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>

View File

@@ -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';
}
})
}
}

View File

@@ -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

View File

@@ -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()
});

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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);
})
})
}

View File

@@ -0,0 +1,3 @@
export const environment = {
production: false
};

View File

@@ -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

View File

@@ -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>

View File

@@ -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));

View File

@@ -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
}
}

View File

@@ -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
*/

View File

@@ -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';

View File

@@ -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(),
);

View File

@@ -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"
]
}

View File

@@ -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"
]
}

View 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.

View 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
});
};

View File

@@ -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"
}
}

View 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"
}
}
}
}

View 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"
}
}

View File

@@ -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}`)
}
}

View File

@@ -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;
}

View File

@@ -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
}
}
}

View File

@@ -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;

View File

@@ -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()
}

View File

@@ -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',
})
}
}

View File

@@ -0,0 +1,8 @@
export enum FeatureID {
None = "",
SPN = "spn",
PrioritySupport = "support",
History = "history",
Bandwidth = "bw-vis",
VPNCompat = "vpn-compat",
}

View File

@@ -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`);
}
}

View File

@@ -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
}
]
}
}
}

View File

@@ -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())
})
)
})
)
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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;
})
);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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';

View 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(),
);

View File

@@ -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"
]
}

View File

@@ -0,0 +1,7 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.lib.json",
"compilerOptions": {
"declarationMap": false
},
}

View File

@@ -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"
]
}

View 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": {}
}
]
}

View 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.

View 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
});
};

View 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"
]
}

View 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"
}
}
}

View File

@@ -0,0 +1 @@
<ng-content></ng-content>

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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 { }

View File

@@ -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,
) { }
}

View File

@@ -0,0 +1,4 @@
export { SfngAccordionComponent } from './accordion';
export { SfngAccordionGroupComponent } from './accordion-group';
export { SfngAccordionModule } from './accordion.module';

View File

@@ -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 })
]),
]
)

View File

@@ -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;
}
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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)' }))
),
]
);

View File

@@ -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();
}
}

View File

@@ -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 { }

View File

@@ -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();
}
}

View File

@@ -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,
})
})
}
}

View File

@@ -0,0 +1,5 @@
export { ConfirmDialogConfig } from './confirm.dialog';
export * from './dialog.module';
export * from './dialog.ref';
export * from './dialog.service';

View File

@@ -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>

View File

@@ -0,0 +1,18 @@
import { OverlayModule } from "@angular/cdk/overlay";
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { SfngDropdownComponent } from "./dropdown";
@NgModule({
imports: [
CommonModule,
OverlayModule,
],
declarations: [
SfngDropdownComponent,
],
exports: [
SfngDropdownComponent,
]
})
export class SfngDropDownModule { }

View File

@@ -0,0 +1,216 @@
import { coerceBooleanProperty, coerceCssPixelValue, coerceNumberProperty } from "@angular/cdk/coercion";
import { CdkOverlayOrigin, ConnectedPosition, ScrollStrategy, ScrollStrategyOptions } from "@angular/cdk/overlay";
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, OnInit, Output, Renderer2, TemplateRef, ViewChild } from "@angular/core";
import { fadeInAnimation, fadeOutAnimation } from '../animations';
@Component({
selector: 'sfng-dropdown',
exportAs: 'sfngDropdown',
templateUrl: './dropdown.html',
styles: [
`
:host {
display: block;
}
`
],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [fadeInAnimation, fadeOutAnimation],
})
export class SfngDropdownComponent implements OnInit {
/** The trigger origin used to open the drop-down */
@ViewChild('trigger', { read: CdkOverlayOrigin })
trigger: CdkOverlayOrigin | null = null;
/**
* The button/drop-down label. Only when not using
* {@Link SfngDropdown.externalTrigger}
*/
@Input()
label: string = '';
/** The trigger template to use when {@Link SfngDropdown.externalTrigger} */
@Input()
triggerTemplate: TemplateRef<any> | null = null;
/** Set to true to provide an external dropdown trigger template using {@Link SfngDropdown.triggerTemplate} */
@Input()
set externalTrigger(v: any) {
this._externalTrigger = coerceBooleanProperty(v)
}
get externalTrigger() {
return this._externalTrigger;
}
private _externalTrigger = false;
/** A list of classes to apply to the overlay element */
@Input()
overlayClass: string = '';
/** Whether or not the drop-down is disabled. */
@Input()
set disabled(v: any) {
this._disabled = coerceBooleanProperty(v)
}
get disabled() {
return this._disabled;
}
private _disabled = false;
/** The Y-offset of the drop-down overlay */
@Input()
set offsetY(v: any) {
this._offsetY = coerceNumberProperty(v);
}
get offsetY() { return this._offsetY }
private _offsetY = 4;
/** The X-offset of the drop-down overlay */
@Input()
set offsetX(v: any) {
this._offsetX = coerceNumberProperty(v);
}
get offsetX() { return this._offsetX }
private _offsetX = 0;
/** The scrollStrategy of the drop-down */
@Input()
scrollStrategy!: ScrollStrategy;
/** Whether or not the pop-over is currently shown. Do not modify this directly */
isOpen = false;
/** The minimum width of the drop-down */
@Input()
set minWidth(val: any) {
this._minWidth = coerceCssPixelValue(val)
}
get minWidth() { return this._minWidth }
private _minWidth: string | number = 0;
/** The maximum width of the drop-down */
@Input()
set maxWidth(val: any) {
this._maxWidth = coerceCssPixelValue(val)
}
get maxWidth() { return this._maxWidth }
private _maxWidth: string | number | null = null;
/** The minimum height of the drop-down */
@Input()
set minHeight(val: any) {
this._minHeight = coerceCssPixelValue(val)
}
get minHeight() { return this._minHeight }
private _minHeight: string | number | null = null;
/** The maximum width of the drop-down */
@Input()
set maxHeight(val: any) {
this._maxHeight = coerceCssPixelValue(val)
}
get maxHeight() { return this._maxHeight }
private _maxHeight: string | number | null = null;
/** Emits whenever the drop-down is opened */
@Output()
opened = new EventEmitter<void>();
/** Emits whenever the drop-down is closed. */
@Output()
closed = new EventEmitter<void>();
@Input()
positions: ConnectedPosition[] = [
{
originX: 'end',
originY: 'bottom',
overlayX: 'end',
overlayY: 'top',
},
{
originX: 'end',
originY: 'top',
overlayX: 'end',
overlayY: 'bottom',
},
{
originX: 'end',
originY: 'bottom',
overlayX: 'start',
overlayY: 'bottom',
},
]
constructor(
public readonly elementRef: ElementRef,
private changeDetectorRef: ChangeDetectorRef,
private renderer: Renderer2,
private scrollOptions: ScrollStrategyOptions,
) {
}
ngOnInit() {
this.scrollStrategy = this.scrollStrategy || this.scrollOptions.close();
}
onOutsideClick(event: MouseEvent) {
if (!!this.trigger) {
const triggerEl = this.trigger.elementRef.nativeElement;
let node = event.target;
while (!!node) {
if (node === triggerEl) {
return;
}
node = this.renderer.parentNode(node);
}
}
this.close();
}
onOverlayClosed() {
this.closed.next();
}
close() {
if (!this.isOpen) {
return;
}
this.isOpen = false;
this.changeDetectorRef.markForCheck();
}
toggle(t: CdkOverlayOrigin | null = this.trigger) {
if (this.isOpen) {
this.close();
return;
}
this.show(t);
}
show(t: CdkOverlayOrigin | null = this.trigger) {
if (t === null) {
return;
}
if (this.isOpen || this._disabled) {
return;
}
if (!!t) {
this.trigger = t;
const rect = (this.trigger.elementRef.nativeElement as HTMLElement).getBoundingClientRect()
this.minWidth = rect ? rect.width : this.trigger.elementRef.nativeElement.offsetWidth;
}
this.isOpen = true;
this.opened.next();
this.changeDetectorRef.markForCheck();
}
}

View File

@@ -0,0 +1,3 @@
export * from './dropdown';
export * from './dropdown.module';

View File

@@ -0,0 +1,5 @@
export * from './overlay-stepper';
export * from './overlay-stepper.module';
export * from './refs';
export * from './step';

View File

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" *ngIf="canAbort" (click)="close()"
class="absolute top-0 right-0 w-5 h-5 -mt-2 -mr-2 opacity-75 cursor-pointer hover:opacity-100" fill="none"
viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
<div class="flex-grow py-4 mb-4" [@moveInOut]="portal.hasAttached()">
<ng-container cdkPortalOutlet #portal="cdkPortalOutlet"></ng-container>
</div>
<ng-template [ngIf]="!!currentStep">
<ng-container *ngTemplateOutlet="currentStep?.buttonTemplate || defaultButtonTemplate"></ng-container>
</ng-template>
<ng-template #defaultButtonTemplate>
<div class="flex flex-row justify-between">
<button class="w-32 py-2" (click)="goBack()">Go Back</button>
<button class="w-32 py-2 custom bg-blue hover:bg-blue hover:bg-opacity-75 active:shadow-inner"
[disabled]="(currentStep?.validChange | async) === false" (click)="next()">
{{ currentStep?.nextButtonLabel || (!isLast ? 'Next' : 'Finish') }}</button>
</div>
</ng-template>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,261 @@
import { animate, style, transition, trigger } from "@angular/animations";
import { CdkPortalOutlet, ComponentPortal, ComponentType } from "@angular/cdk/portal";
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, Inject, InjectionToken, Injector, isDevMode, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { Subject } from "rxjs";
import { SfngDialogRef, SFNG_DIALOG_REF } from "../dialog";
import { StepperControl, StepRef, STEP_REF } from "./refs";
import { Step, StepperConfig } from "./step";
import { StepOutletComponent, STEP_ANIMATION_DIRECTION, STEP_PORTAL } from "./step-outlet";
/**
* STEP_CONFIG is used to inject the StepperConfig into the OverlayStepperContainer.
*/
export const STEP_CONFIG = new InjectionToken<StepperConfig>('StepperConfig');
@Component({
templateUrl: './overlay-stepper-container.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
position: relative;
display: flex;
flex-direction: column;
width: 600px;
}
`
],
animations: [
trigger(
'moveInOut',
[
transition(
':enter',
[
style({ opacity: 0, transform: 'translateX({{ in }})' }),
animate('.2s cubic-bezier(0.4, 0, 0.2, 1)',
style({ opacity: 1, transform: 'translateX(0%)' }))
],
{ params: { in: '100%' } } // default parameters
),
transition(
':leave',
[
style({ opacity: 1 }),
animate('.2s cubic-bezier(0.4, 0, 0.2, 1)',
style({ opacity: 0, transform: 'translateX({{ out }})' }))
],
{ params: { out: '-100%' } } // default parameters
)
]
)]
})
export class OverlayStepperContainerComponent implements OnInit, OnDestroy, StepperControl {
/** Used to keep cache the stepRef instances. See documentation for {@class StepRef} */
private stepRefCache = new Map<number, StepRef>();
/** Used to emit when the stepper finished. This is always folled by emitting on onClose$ */
private onFinish$ = new Subject<void>();
/** Emits when the stepper finished - also see {@link OverlayStepperContainerComponent.onClose}*/
get onFinish() {
return this.onFinish$.asObservable();
}
/**
* Emits when the stepper is closed.
* If the stepper if finished then onFinish will emit first
*/
get onClose() {
return this.dialogRef.onClose;
}
/** The index of the currently displayed step */
currentStepIndex = -1;
/** The component instance of the current step */
currentStep: Step | null = null;
/** A reference to the portalOutlet used to render our steps */
@ViewChild(CdkPortalOutlet, { static: true })
portalOutlet!: CdkPortalOutlet;
/** Whether or not the user can go back */
canGoBack = false;
/** Whether or not the user can abort and close the stepper */
canAbort = false;
/** Whether the current step is the last step */
get isLast() {
return this.currentStepIndex + 1 >= this.config.steps.length;
}
constructor(
@Inject(STEP_CONFIG) public readonly config: StepperConfig,
@Inject(SFNG_DIALOG_REF) public readonly dialogRef: SfngDialogRef<void>,
private injector: Injector,
private cdr: ChangeDetectorRef
) { }
/**
* Moves forward to the next step or closes the stepper
* when moving beyond the last one.
*/
next(): Promise<void> {
if (this.isLast) {
this.onFinish$.next();
this.close();
return Promise.resolve();
}
return this.attachStep(this.currentStepIndex + 1, true)
}
/**
* Moves back to the previous step. This does not take canGoBack
* into account.
*/
goBack(): Promise<void> {
return this.attachStep(this.currentStepIndex - 1, false)
}
/** Closes the stepper - this does not run the onFinish hooks of the steps */
async close(): Promise<void> {
this.dialogRef.close();
}
ngOnInit(): void {
this.next();
}
ngOnDestroy(): void {
this.onFinish$.complete();
}
/**
* Attaches a new step component in the current outlet. It detaches any previous
* step and calls onBeforeBack and onBeforeNext respectively.
*
* @param index The index of the new step to attach.
* @param forward Whether or not the new step is attached by going "forward" or "backward"
* @returns
*/
private async attachStep(index: number, forward = true) {
if (index >= this.config.steps.length) {
if (isDevMode()) {
throw new Error(`Cannot attach step at ${index}: index out of range`)
}
return;
}
// call onBeforeNext or onBeforeBack of the current step
if (this.currentStep) {
if (forward) {
if (!!this.currentStep.onBeforeNext) {
try {
await this.currentStep.onBeforeNext();
} catch (err) {
console.error(`Failed to move to next step`, err)
// TODO(ppacher): display error
return;
}
}
} else {
if (!!this.currentStep.onBeforeBack) {
try {
await this.currentStep.onBeforeBack()
} catch (err) {
console.error(`Step onBeforeBack callback failed`, err)
}
}
}
// detach the current step component.
this.portalOutlet.detach();
}
const stepType = this.config.steps[index];
const contentPortal = this.createStepContentPortal(stepType, index)
const outletPortal = this.createStepOutletPortal(contentPortal, forward ? 'right' : 'left')
// attach the new step (which is wrapped in a StepOutletComponent).
const ref = this.portalOutlet.attachComponentPortal(outletPortal);
// We need to wait for the step to be actually attached in the outlet
// to get access to the actual step component instance.
ref.instance.portalOutlet!.attached
.subscribe((stepRef: ComponentRef<Step>) => {
this.currentStep = stepRef.instance;
this.currentStepIndex = index;
if (typeof this.config.canAbort === 'function') {
this.canAbort = this.config.canAbort(this.currentStepIndex, this.currentStep);
}
// make sure we trigger a change-detection cycle now
// markForCheck() is not enough here as we need a CD to run
// immediately for the Step.buttonTemplate to be accounted for correctly.
this.cdr.detectChanges();
})
}
/**
* Creates a new component portal for a step and provides access to the {@class StepRef}
* using dependency injection.
*
* @param stepType The component type of the step for which a new portal should be created.
* @param index The index of the current step. Used to create/cache the {@class StepRef}
*/
private createStepContentPortal(stepType: ComponentType<Step>, index: number): ComponentPortal<Step> {
let stepRef = this.stepRefCache.get(index);
if (stepRef === undefined) {
stepRef = new StepRef(index, this)
this.stepRefCache.set(index, stepRef);
}
const injector = Injector.create({
providers: [
{
provide: STEP_REF,
useValue: stepRef,
}
],
parent: this.config.injector || this.injector,
})
return new ComponentPortal(stepType, undefined, injector);
}
/**
* Creates a new component portal for a step outlet component that will attach another content
* portal and wrap the attachment in a "move in" animation for a given direction.
*
* @param contentPortal The portal of the actual content that should be attached in the outlet
* @param dir The direction for the animation of the step outlet.
*/
private createStepOutletPortal(contentPortal: ComponentPortal<Step>, dir: 'left' | 'right'): ComponentPortal<StepOutletComponent> {
const injector = Injector.create({
providers: [
{
provide: STEP_PORTAL,
useValue: contentPortal,
},
{
provide: STEP_ANIMATION_DIRECTION,
useValue: dir,
},
],
parent: this.injector,
})
return new ComponentPortal(
StepOutletComponent,
undefined,
injector,
)
}
}

View File

@@ -0,0 +1,21 @@
import { OverlayModule } from "@angular/cdk/overlay";
import { PortalModule } from "@angular/cdk/portal";
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { SfngDialogModule } from "../dialog";
import { OverlayStepperContainerComponent } from "./overlay-stepper-container";
import { StepOutletComponent } from "./step-outlet";
@NgModule({
imports: [
CommonModule,
PortalModule,
OverlayModule,
SfngDialogModule,
],
declarations: [
OverlayStepperContainerComponent,
StepOutletComponent,
]
})
export class OverlayStepperModule { }

View File

@@ -0,0 +1,57 @@
import { ComponentRef, Injectable, Injector } from "@angular/core";
import { SfngDialogService } from "../dialog";
import { OverlayStepperContainerComponent, STEP_CONFIG } from "./overlay-stepper-container";
import { OverlayStepperModule } from "./overlay-stepper.module";
import { StepperRef } from "./refs";
import { StepperConfig } from "./step";
@Injectable({ providedIn: OverlayStepperModule })
export class OverlayStepper {
constructor(
private injector: Injector,
private dialog: SfngDialogService,
) { }
/**
* Creates a new overlay stepper given it's configuration and returns
* a reference to the stepper that can be used to wait for or control
* the stepper from outside.
*
* @param config The configuration for the overlay stepper.
*/
create(config: StepperConfig): StepperRef {
// create a new injector for our OverlayStepperContainer
// that holds a reference to the StepperConfig.
const injector = this.createInjector(config);
const dialogRef = this.dialog.create(OverlayStepperContainerComponent, {
injector: injector,
autoclose: false,
backdrop: 'light',
dragable: false,
})
const containerComponentRef = dialogRef.contentRef() as ComponentRef<OverlayStepperContainerComponent>;
return new StepperRef(containerComponentRef.instance);
}
/**
* Creates a new dependency injector that provides access to the
* stepper configuration using the STEP_CONFIG injection token.
*
* @param config The stepper configuration to provide using DI
* @returns
*/
private createInjector(config: StepperConfig): Injector {
return Injector.create({
providers: [
{
provide: STEP_CONFIG,
useValue: config,
},
],
parent: this.injector,
})
}
}

View File

@@ -0,0 +1,143 @@
import { InjectionToken } from "@angular/core";
import { Observable } from "rxjs";
import { take } from "rxjs/operators";
import { OverlayStepperContainerComponent } from "./overlay-stepper-container";
/**
* STEP_REF is the injection token that is used to provide a reference to the
* Stepper to each step.
*/
export const STEP_REF = new InjectionToken<StepRef<any>>('StepRef')
export interface StepperControl {
/**
* Next should move the stepper forward to the next
* step or close the stepper if no more steps are
* available.
* If the stepper is closed this way all onFinish hooks
* registered at {@link StepRef} are executed.
*/
next(): Promise<void>;
/**
* goBack should move the stepper back to the previous
* step. This is a no-op if there's no previous step to
* display.
*/
goBack(): Promise<void>;
/**
* close closes the stepper but does not run any onFinish hooks
* of {@link StepRef}.
*/
close(): Promise<void>;
}
/**
* StepRef is a reference to the overlay stepper and can be used to control, abort
* or otherwise interact with the stepper.
*
* It is made available to individual steps using the STEP_REF injection token.
* Each step in the OverlayStepper receives it's own StepRef instance and will receive
* a reference to the same instance in case the user goes back and re-opens a step
* again.
*
* Steps should therefore store any configuration data that is needed to restore
* the previous view in the StepRef using it's save() and load() methods.
*/
export class StepRef<T = any> implements StepperControl {
private onFinishHooks: (() => PromiseLike<void> | void)[] = [];
private data: T | null = null;
constructor(
private currentStepIndex: number,
private stepContainerRef: OverlayStepperContainerComponent,
) {
this.stepContainerRef.onFinish
.pipe(take(1))
.subscribe(() => this.runOnFinishHooks)
}
next(): Promise<void> {
return this.stepContainerRef.next();
}
goBack(): Promise<void> {
return this.stepContainerRef.goBack();
}
close(): Promise<void> {
return this.stepContainerRef.close();
}
/**
* Save saves data of the current step in the stepper session.
* This data is saved in case the user decides to "go back" to
* to a previous step so the old view can be restored.
*
* @param data The data to save in the stepper session.
*/
save(data: T): void {
this.data = data;
}
/**
* Load returns the data previously stored using save(). The
* StepperRef automatically makes sure the correct data is returned
* for the current step.
*/
load(): T | null {
return this.data;
}
/**
* registerOnFinish registers fn to be called when the last step
* completes and the stepper is going to finish.
*/
registerOnFinish(fn: () => PromiseLike<void> | void) {
this.onFinishHooks.push(fn);
}
/**
* Executes all onFinishHooks in the order they have been defined
* and waits for each hook to complete.
*/
private async runOnFinishHooks() {
for (let i = 0; i < this.onFinishHooks.length; i++) {
let res = this.onFinishHooks[i]();
if (typeof res === 'object' && 'then' in res) {
// res is a PromiseLike so wait for it
try {
await res;
} catch (err) {
console.error(`Failed to execute on-finish hook of step ${this.currentStepIndex}: `, err)
}
}
}
}
}
export class StepperRef implements StepperControl {
constructor(private stepContainerRef: OverlayStepperContainerComponent) { }
next(): Promise<void> {
return this.stepContainerRef.next();
}
goBack(): Promise<void> {
return this.stepContainerRef.goBack();
}
close(): Promise<void> {
return this.stepContainerRef.close();
}
get onFinish(): Observable<void> {
return this.stepContainerRef.onFinish;
}
get onClose(): Observable<void> {
return this.stepContainerRef.onClose;
}
}

View File

@@ -0,0 +1,90 @@
import { animate, style, transition, trigger } from "@angular/animations";
import { CdkPortalOutlet, ComponentPortal } from "@angular/cdk/portal";
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, Inject, InjectionToken, ViewChild } from "@angular/core";
import { Step } from "./step";
export const STEP_PORTAL = new InjectionToken<ComponentPortal<Step>>('STEP_PORTAL')
export const STEP_ANIMATION_DIRECTION = new InjectionToken<'left' | 'right'>('STEP_ANIMATION_DIRECTION');
/**
* A simple wrapper component around CdkPortalOutlet to add nice
* move animations.
*/
@Component({
template: `
<div [@moveInOut]="{value: _appAnimate, params: {in: in, out: out}}" class="flex flex-col overflow-auto">
<ng-template [cdkPortalOutlet]="portal"></ng-template>
</div>
`,
styles: [
`
:host{
display: flex;
flex-direction: column;
overflow: hidden;
}
`
],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger(
'moveInOut',
[
transition(
':enter',
[
style({ opacity: 0, transform: 'translateX({{ in }})' }),
animate('.2s ease-in',
style({ opacity: 1, transform: 'translateX(0%)' }))
],
{ params: { in: '100%' } } // default parameters
),
transition(
':leave',
[
style({ opacity: 1 }),
animate('.2s ease-out',
style({ opacity: 0, transform: 'translateX({{ out }})' }))
],
{ params: { out: '-100%' } } // default parameters
)
]
)]
})
export class StepOutletComponent implements AfterViewInit {
/** @private - Whether or not the animation should run. */
_appAnimate = false;
/** The actual step instance that has been attached. */
stepInstance: ComponentRef<Step> | null = null;
/** @private - used in animation interpolation for translateX */
get in() {
return this._animateDirection == 'left' ? '-100%' : '100%'
}
/** @private - used in animation interpolation for traslateX */
get out() {
return this._animateDirection == 'left' ? '100%' : '-100%'
}
/** The portal outlet in our view used to attach the step */
@ViewChild(CdkPortalOutlet, { static: true })
portalOutlet!: CdkPortalOutlet;
constructor(
@Inject(STEP_PORTAL) public portal: ComponentPortal<Step>,
@Inject(STEP_ANIMATION_DIRECTION) public _animateDirection: 'left' | 'right',
private cdr: ChangeDetectorRef
) { }
ngAfterViewInit(): void {
this.portalOutlet?.attached
.subscribe(ref => {
this.stepInstance = ref as ComponentRef<Step>;
this._appAnimate = true;
this.cdr.detectChanges();
})
}
}

View File

@@ -0,0 +1,64 @@
import { Injector, TemplateRef, Type } from "@angular/core";
import { Observable } from "rxjs";
export interface Step {
/**
* validChange should emit true or false when the current step
* is valid and the "next" button should be visible.
*/
validChange: Observable<boolean>;
/**
* onBeforeBack, if it exists, is called when the user
* clicks the "Go Back" button but before the current step
* is unloaded.
*
* The OverlayStepper will wait for the callback to resolve or
* reject but will not abort going back!
*/
onBeforeBack?: () => Promise<void>;
/**
* onBeforeNext, if it exists, is called when the user
* clicks the "Next" button but before the current step
* is unloaded.
*
* The OverlayStepper willw ait for the callback to resolve
* or reject. If it rejects the current step will not be unloaded
* and the rejected error will be displayed to the user.
*/
onBeforeNext?: () => Promise<void>;
/**
* nextButtonLabel can overwrite the label for the "Next" button.
*/
nextButtonLabel?: string;
/**
* buttonTemplate may hold a tempalte ref that is rendered instead
* of the default button row with a "Go Back" and a "Next" button.
* Note that if set, the step component must make sure to handle
* navigation itself. See {@class StepRef} for more information on how
* to control the stepper.
*/
buttonTemplate?: TemplateRef<any>;
}
export interface StepperConfig {
/**
* canAbort can be set to a function that is called
* for each step to determine if the stepper is abortable.
*/
canAbort?: (idx: number, step: Step) => boolean;
/** steps holds the list of steps to execute */
steps: Array<Type<Step>>
/**
* injector, if set, defines the parent injector used to
* create dedicated instances of the step types.
*/
injector?: Injector;
}

View File

@@ -0,0 +1,22 @@
sfng-pagination {
.pagination {
@apply my-2 w-full flex justify-between;
button {
@apply text-xxs px-2 flex items-center justify-start;
&.page {
@apply bg-cards-secondary;
@apply opacity-50;
&:hover {
@apply opacity-100;
}
}
&.active-page {
@apply text-blue font-medium opacity-100;
}
}
}
}

View File

@@ -0,0 +1,64 @@
import { BehaviorSubject, Observable, Subscription } from "rxjs";
import { Pagination, clipPage } from "./pagination";
export interface Datasource<T> {
// view should emit all items in the given page using the specified page number.
view(page: number, pageSize: number): Observable<T[]>;
}
export class DynamicItemsPaginator<T> implements Pagination<T> {
private _total = 0;
private _pageNumber$ = new BehaviorSubject<number>(1);
private _pageItems$ = new BehaviorSubject<T[]>([]);
private _pageLoading$ = new BehaviorSubject<boolean>(false);
private _pageSubscription = Subscription.EMPTY;
/** Returns the number of total pages. */
get total() { return this._total; }
/** Emits the current page number */
get pageNumber$() { return this._pageNumber$.asObservable() }
/** Emits all items of the current page */
get pageItems$() { return this._pageItems$.asObservable() }
/** Emits whether or not we're loading the next page */
get pageLoading$() { return this._pageLoading$.asObservable() }
constructor(
private source: Datasource<T>,
public readonly pageSize = 25,
) { }
reset(newTotal: number) {
this._total = Math.ceil(newTotal / this.pageSize);
this.openPage(1);
}
/** Clear resets the current total and emits an empty item set. */
clear() {
this._total = 0;
this._pageItems$.next([]);
this._pageNumber$.next(1);
this._pageSubscription.unsubscribe();
}
openPage(pageNumber: number): void {
pageNumber = clipPage(pageNumber, this.total);
this._pageLoading$.next(true);
this._pageSubscription.unsubscribe()
this._pageSubscription = this.source.view(pageNumber, this.pageSize)
.subscribe({
next: results => {
this._pageLoading$.next(false);
this._pageItems$.next(results);
this._pageNumber$.next(pageNumber);
}
});
}
nextPage(): void { this.openPage(this._pageNumber$.getValue() + 1) }
prevPage(): void { this.openPage(this._pageNumber$.getValue() - 1) }
}

View File

@@ -0,0 +1,5 @@
export * from './dynamic-items-paginator';
export * from './pagination';
export * from './pagination.module';
export * from './snapshot-paginator';

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