This commit is contained in:
66 changed files with 19815 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,26 @@
ul {
list-style-type: none; /* Remove default list styling */
padding: 0; /* Remove default padding */
}
li {
cursor: pointer; /* Change cursor to pointer on hover */
padding: 10px; /* Add some padding for better click area */
transition: background-color 0.3s; /* Smooth transition for background color */
}
li:hover {
color: #9a5d5d; /* Change background color on hover */
font-weight: bold;
font-size: larger;
}
.center-container {
margin-top: 30px;
display: flex; /* Use Flexbox */
flex-direction: column; /* Stack children vertically */
align-items: center; /* Center horizontally */
// justify-content: center; /* Center vertically */
// height: 100vh; /* Full viewport height */
text-align: center; /* Center text */
}

View File

@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
})
export class AppComponent {}

18
ui/src/app/app.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { ApplicationConfig, isDevMode } from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideServiceWorker } from '@angular/service-worker';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideAnimationsAsync(),
provideHttpClient(),
provideRouter(routes),
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000'
})
],
};

9
ui/src/app/app.routes.ts Normal file
View File

@@ -0,0 +1,9 @@
import { Routes } from '@angular/router';
import { BasicKnockPageComponent } from './basic-knock/basic-knock-page.component';
import { FsaKnockPageComponent } from './fsa-knock/fsa-knock-page.component';
export const routes: Routes = [
{ path: '', component: BasicKnockPageComponent },
{ path: 'fsa', component: FsaKnockPageComponent },
{ path: '**', redirectTo: '' }
];

View File

@@ -0,0 +1,92 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { CardModule } from 'primeng/card';
import { ButtonModule } from 'primeng/button';
import { DialogModule } from 'primeng/dialog';
import { KnockPageComponent } from '../knock/knock-page.component';
@Component({
selector: 'app-basic-knock-page',
standalone: true,
imports: [
CommonModule, RouterModule, CardModule, ButtonModule, DialogModule, KnockPageComponent
],
template: `
<div class="container">
<!-- Встраиваем основной компонент в базовом режиме -->
<app-knock-page [enableFSA]="false" [canUseFSA]="canUseFSA"></app-knock-page>
</div>
<!-- Информационное модальное окно -->
<p-dialog header="📁 Базовая версия"
[(visible)]="showInfoDialog"
[modal]="true"
[closable]="true"
[draggable]="false"
[resizable]="false"
styleClass="info-dialog">
<div class="dialog-content">
<p class="mb-3">
Эта версия работает в любом браузере, но файлы загружаются/скачиваются через стандартные диалоги браузера.
</p>
<div *ngIf="canUseFSA" class="p-3 bg-blue-50 border-round">
<p class="text-sm mb-2">
💡 <strong>Доступна расширенная версия!</strong>
</p>
<p class="text-sm mb-3">
Ваш браузер поддерживает прямое редактирование файлов на диске.
</p>
<button pButton
type="button"
label="Перейти к расширенной версии"
class="p-button-success p-button-sm"
routerLink="/fsa"
(click)="showInfoDialog = false">
</button>
</div>
</div>
</p-dialog>
`,
styles: [`
.container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.info-link {
color: #3b82f6;
cursor: pointer;
text-decoration: none;
font-weight: 500;
transition: color 0.2s ease;
}
.info-link:hover {
color: #1d4ed8;
text-decoration: underline;
}
.bg-blue-50 {
background-color: #eff6ff;
}
.dialog-content {
min-width: 400px;
}
`]
})
export class BasicKnockPageComponent {
canUseFSA = false;
showInfoDialog = false;
constructor() {
this.checkFSASupport();
}
private checkFSASupport() {
const w = window as any;
this.canUseFSA = typeof w.showOpenFilePicker === 'function';
}
}

View File

@@ -0,0 +1,132 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { CardModule } from 'primeng/card';
import { ButtonModule } from 'primeng/button';
import { DialogModule } from 'primeng/dialog';
import { KnockPageComponent } from '../knock/knock-page.component';
@Component({
selector: 'app-fsa-knock-page',
standalone: true,
imports: [
CommonModule, RouterModule, CardModule, ButtonModule, DialogModule, KnockPageComponent
],
template: `
<div class="container">
<div *ngIf="!isFSASupported" class="text-center">
<h3>File System Access API не поддерживается</h3>
<p>Эта функциональность требует браузер с поддержкой File System Access API:</p>
<ul class="text-left mt-3">
<li>Google Chrome 86+</li>
<li>Microsoft Edge 86+</li>
<li>Opera 72+</li>
</ul>
<p class="mt-3">Ваш браузер: <strong>{{ browserInfo }}</strong></p>
<button pButton
type="button"
label="Перейти к основной версии"
class="p-button-outlined mt-3"
routerLink="/">
</button>
</div>
<div *ngIf="isFSASupported">
<!-- Встраиваем основной компонент с поддержкой FSA -->
<app-knock-page [enableFSA]="true" [canUseFSA]="true"></app-knock-page>
</div>
</div>
<!-- Информационное модальное окно -->
<p-dialog header="🚀 Расширенная версия с File System Access"
[(visible)]="showInfoDialog"
[modal]="true"
[closable]="true"
[draggable]="false"
[resizable]="false"
styleClass="info-dialog">
<div class="dialog-content">
<p class="mb-3">
Эта версия поддерживает прямое редактирование файлов на диске.
Файлы будут автоматически перезаписываться после шифрования/дешифрования.
</p>
<div class="p-3 bg-green-50 border-round">
<p class="text-sm mb-2">
✅ <strong>Доступные возможности:</strong>
</p>
<ul class="text-sm mb-0">
<li>Прямое открытие файлов с диска</li>
<li>Автоматическое сохранение изменений</li>
<li>Перезапись зашифрованных файлов "на месте"</li>
<li>Быстрая работа без диалогов загрузки/скачивания</li>
</ul>
</div>
</div>
</p-dialog>
`,
styles: [`
.container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
ul {
display: inline-block;
text-align: left;
}
.info-link {
color: #3b82f6;
cursor: pointer;
text-decoration: none;
font-weight: 500;
transition: color 0.2s ease;
}
.info-link:hover {
color: #1d4ed8;
text-decoration: underline;
}
.bg-green-50 {
background-color: #f0fdf4;
}
.dialog-content {
min-width: 450px;
}
`]
})
export class FsaKnockPageComponent {
isFSASupported = false;
browserInfo = '';
showInfoDialog = false;
constructor() {
this.checkFSASupport();
this.getBrowserInfo();
}
private checkFSASupport() {
const w = window as any;
this.isFSASupported = typeof w.showOpenFilePicker === 'function';
}
private getBrowserInfo() {
const ua = navigator.userAgent;
if (ua.includes('Chrome') && !ua.includes('Edg/')) {
this.browserInfo = 'Google Chrome';
} else if (ua.includes('Edg/')) {
this.browserInfo = 'Microsoft Edge';
} else if (ua.includes('Opera') || ua.includes('OPR/')) {
this.browserInfo = 'Opera';
} else if (ua.includes('Firefox')) {
this.browserInfo = 'Mozilla Firefox';
} else if (ua.includes('Safari') && !ua.includes('Chrome')) {
this.browserInfo = 'Safari';
} else {
this.browserInfo = 'Неизвестный браузер';
}
}
}

View File

@@ -0,0 +1,317 @@
<div class="container">
<p-card [header]="cardHeader">
<ng-template pTemplate="header">
<div class="flex justify-content-between align-items-center">
<h1 style="margin-left: 1rem">Port Knocker</h1>
<!-- <div class="animated-title" [class.animating]="isAnimating">
<span *ngIf="cardHeader">{{ cardHeader }}</span>
</div> -->
<div class="flex gap-2">
<button
*ngIf="!enableFSA"
pButton
type="button"
label="📁 Info"
class="p-button-text p-button-sm"
(click)="showInfoDialog = true"
></button>
<button
*ngIf="canUseFSA && !enableFSA"
pButton
type="button"
label="🚀 FSA Version"
class="p-button-text p-button-sm"
routerLink="/fsa"
></button>
</div>
</div>
</ng-template>
<form [formGroup]="form" (ngSubmit)="execute()" class="p-fluid">
<div class="grid">
<div class="col-12 md:col-6">
<label>Password</label>
<p-password
formControlName="password"
[feedback]="false"
toggleMask
inputStyleClass="w-full"
placeholder="GO_KNOCKER_SERVE_PASS"
></p-password>
<div class="mt-1 text-sm" *ngIf="!form.value.password || wrongPass">
<span class="text-red-500" *ngIf="wrongPass">Invalid password</span>
<span class="text-600" *ngIf="!wrongPass && !form.value.password"
>Password is required</span
>
</div>
</div>
<div class="col-12 md:col-6">
<label>Mode</label>
<p-dropdown
formControlName="mode"
[options]="[
{ label: 'Inline', value: 'inline' },
{ label: 'YAML', value: 'yaml' }
]"
optionLabel="label"
optionValue="value"
class="w-full"
></p-dropdown>
</div>
<div class="col-12" *ngIf="form.value.mode === 'inline'">
<label>Targets</label>
<input
pInputText
type="text"
formControlName="targets"
placeholder="tcp:host:port;udp:host:port"
class="w-full"
/>
</div>
<div class="col-12 md:col-4" *ngIf="form.value.mode === 'inline'">
<label>Delay</label>
<input
pInputText
type="text"
formControlName="delay"
placeholder="1s"
class="w-full"
/>
</div>
<div class="col-6 md:col-4 flex align-items-center gap-2">
<p-checkbox formControlName="verbose" [binary]="true"></p-checkbox>
<label class="checkbox-label">Verbose</label>
</div>
<div class="col-6 md:col-4 flex align-items-center gap-2">
<p-checkbox
formControlName="waitConnection"
[binary]="true"
></p-checkbox>
<label class="checkbox-label">Wait connection</label>
</div>
<div class="col-12">
<label>Gateway</label>
<input
pInputText
type="text"
formControlName="gateway"
placeholder="optional local ip:port"
class="w-full"
/>
</div>
<div class="col-12" *ngIf="form.value.mode === 'yaml'">
<label>YAML</label>
<textarea
pInputTextarea
formControlName="configYAML"
rows="12"
placeholder="paste YAML or ENCRYPTED:"
class="w-full"
></textarea>
</div>
<!-- File controls directly under YAML -->
<div class="col-12" *ngIf="form.value.mode === 'yaml'">
<div class="flex flex-wrap gap-2 align-items-center">
<!-- FSA version -->
<button
*ngIf="enableFSA"
pButton
type="button"
label="Open File (with write access)"
(click)="openFileWithWriteAccess()"
class="p-button-outlined"
></button>
<span
*ngIf="enableFSA && selectedFileName"
class="text-sm text-600"
>{{ selectedFileName }}</span
>
<!-- Basic version -->
<p-fileUpload
*ngIf="!enableFSA"
mode="basic"
name="file"
chooseLabel="Choose File"
(onSelect)="onFileUpload($event)"
[customUpload]="true"
[auto]="false"
accept=".yaml,.yml,.txt,.encrypted"
[maxFileSize]="1048576"
></p-fileUpload>
<input
*ngIf="!enableFSA && !isYamlEncrypted()"
pInputText
type="text"
class="w-full md:w-6"
placeholder="Server file path (optional)"
formControlName="serverFilePath"
/>
</div>
</div>
<!-- Row 1: Execute full width -->
<div class="col-12">
<button
pButton
type="submit"
label="Execute"
class="w-full"
[loading]="executing"
[disabled]="executing || !form.value.password || wrongPass"
[ngClass]="{ 'p-button-danger': !form.value.password || wrongPass }"
></button>
</div>
<!-- Row 2: Encrypt / Decrypt half/half on desktop; stacked on mobile (только для YAML режима) -->
<div class="col-12 md:col-6" *ngIf="!isInlineMode()">
<button
pButton
type="button"
label="Encrypt"
(click)="encrypt()"
class="p-button-secondary w-full"
[disabled]="
executing ||
!form.value.password ||
wrongPass ||
isYamlEncrypted()
"
></button>
</div>
<div class="col-12 md:col-6" *ngIf="!isInlineMode()">
<button
pButton
type="button"
label="Decrypt"
(click)="decrypt()"
class="p-button-secondary w-full"
[disabled]="
executing ||
!form.value.password ||
wrongPass ||
!isYamlEncrypted()
"
></button>
</div>
<!-- Row 3: Download actions half/half on desktop; stacked on mobile (только для YAML режима) -->
<div class="col-12 md:col-6" *ngIf="!isInlineMode()">
<button
pButton
type="button"
label="Download YAML"
(click)="downloadYaml()"
class="p-button-text w-full"
></button>
</div>
<div class="col-12 md:col-6" *ngIf="!isInlineMode()">
<button
pButton
type="button"
label="Download Result"
(click)="downloadResult()"
class="p-button-text w-full"
></button>
</div>
</div>
</form>
<p-divider></p-divider>
<div class="grid">
<div class="col-12">
<p-progressBar
[value]="executing ? 100 : 0"
[mode]="executing ? 'indeterminate' : 'determinate'"
></p-progressBar>
<div class="mt-2 text-600" *ngIf="executing">
Elapsed: {{ elapsedMs / 1000 | number : "1.1-1" }}s
</div>
<div class="mt-2 text-600" *ngIf="!executing && elapsedMs > 0">
Last run: {{ elapsedMs / 1000 | number : "1.1-1" }}s
<span *ngIf="lastRunTime" class="ml-2 text-500">
({{ lastRunTime | date : "short" }})
</span>
</div>
</div>
</div>
</p-card>
</div>
<!-- Модальное окно с результатом выполнения -->
<p-dialog
header="Результат выполнения"
[(visible)]="showResultDialog"
[modal]="true"
[closable]="true"
[draggable]="false"
[resizable]="false"
styleClass="result-dialog"
>
<div class="dialog-content">
<div *ngIf="result" class="mb-3">
<h4 class="text-green-600 mb-2">✅ Успешно выполнено</h4>
<pre class="bg-gray-50 p-3 border-round text-sm">{{ result }}</pre>
</div>
<div *ngIf="error" class="mb-3">
<h4 class="text-red-600 mb-2">❌ Ошибка</h4>
<pre class="bg-red-50 p-3 border-round text-sm text-red-700">{{
error
}}</pre>
</div>
<div *ngIf="lastRunTime" class="text-sm text-600">
Время выполнения: {{ elapsedMs / 1000 | number : "1.1-1" }}s
<br />
Завершено: {{ lastRunTime | date : "short" }}
</div>
</div>
<ng-template pTemplate="footer">
<button
pButton
type="button"
label="OK"
class="p-button-primary"
(click)="showResultDialog = false"
></button>
</ng-template>
</p-dialog>
<!-- Информационное модальное окно -->
<p-dialog
header="📁 Базовая версия"
[(visible)]="showInfoDialog"
[modal]="true"
[closable]="true"
[draggable]="false"
[resizable]="false"
styleClass="info-dialog"
>
<div class="dialog-content">
<p class="mb-3">
Эта версия работает в любом браузере, но файлы загружаются/скачиваются
через стандартные диалоги браузера.
</p>
<div *ngIf="canUseFSA" class="p-3 bg-blue-50 border-round">
<p class="text-sm mb-2">
💡 <strong>Доступна расширенная версия!</strong>
</p>
<p class="text-sm mb-3">
Ваш браузер поддерживает прямое редактирование файлов на диске.
</p>
<button
pButton
type="button"
label="Перейти к расширенной версии"
class="p-button-success p-button-sm"
routerLink="/fsa"
(click)="showInfoDialog = false"
></button>
</div>
</div>
</p-dialog>

View File

@@ -0,0 +1,132 @@
.container {
max-width: 800px;
margin: 24px auto;
padding: 16px;
}
.row {
display: flex;
gap: 12px;
align-items: center;
margin: 8px 0;
}
label {
width: 180px;
}
input[type='text'], input[type='password'], select, textarea {
flex: 1;
}
.actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.result, .error {
margin-top: 16px;
}
.error pre {
white-space: pre-wrap;
word-break: break-word;
}
.dialog-content {
min-width: 400px;
}
.bg-blue-50 {
background-color: #eff6ff;
}
.result-dialog {
.p-dialog {
max-width: 90vw !important;
width: 600px !important;
@media (max-width: 768px) {
width: 95vw !important;
max-width: 95vw !important;
}
}
.p-dialog-content {
max-height: 70vh;
overflow-y: auto;
padding: 1.5rem;
@media (max-width: 768px) {
padding: 1rem;
}
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
font-size: 0.9rem;
line-height: 1.4;
@media (max-width: 768px) {
font-size: 0.8rem;
max-height: 200px;
}
}
.dialog-content {
min-width: 300px;
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
@media (max-width: 768px) {
min-width: auto;
}
}
}
// Анимированный заголовок
.animated-title {
margin-left: 1.5rem;
background: linear-gradient(45deg, #667eea 0%, #764ba2 100%);
background-size: 200% 200%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: gradientShift 3s ease-in-out infinite;
font-weight: bold;
font-size: 1.8rem;
transition: all 0.3s ease;
&.animating {
animation: gradientShift 1s ease-in-out infinite, glow 0.5s ease-in-out infinite alternate;
}
span {
display: inline-block;
min-width: 1ch;
}
}
@keyframes gradientShift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
@keyframes glow {
from {
text-shadow: 0 0 5px rgba(102, 126, 234, 0.5), 0 0 10px rgba(102, 126, 234, 0.3);
}
to {
text-shadow: 0 0 10px rgba(102, 126, 234, 0.8), 0 0 20px rgba(102, 126, 234, 0.5);
}
}

View File

@@ -0,0 +1,780 @@
import { Component, inject, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormBuilder, ReactiveFormsModule, Validators, FormsModule } from '@angular/forms';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { InputTextModule } from 'primeng/inputtext';
import { PasswordModule } from 'primeng/password';
import { DropdownModule } from 'primeng/dropdown';
import { CheckboxModule } from 'primeng/checkbox';
import { InputTextareaModule } from 'primeng/inputtextarea';
import { ButtonModule } from 'primeng/button';
import { CardModule } from 'primeng/card';
import { DividerModule } from 'primeng/divider';
import { FileUploadModule } from 'primeng/fileupload';
import { ProgressBarModule } from 'primeng/progressbar';
import { DialogModule } from 'primeng/dialog';
import * as yaml from 'js-yaml';
import { environment } from '../../environments/environment';
@Component({
selector: 'app-knock-page',
standalone: true,
imports: [
CommonModule, RouterModule, ReactiveFormsModule, FormsModule,
InputTextModule, PasswordModule, DropdownModule, CheckboxModule,
InputTextareaModule, ButtonModule, CardModule, DividerModule,
FileUploadModule, ProgressBarModule, DialogModule
],
templateUrl: './knock-page.component.html',
styleUrls: ['./knock-page.component.scss']
})
export class KnockPageComponent {
private readonly http = inject(HttpClient);
private readonly fb = inject(FormBuilder);
@Input() enableFSA = false; // Включает File System Access API функциональность
@Input() canUseFSA = false; // Доступна ли FSA версия
// cardHeader = 'Port Knocker GUI';
cardHeader = '';
animatedTitle = 'Knock Knock Knock on the heaven\'s door ...';
showInfoDialog = false;
isAnimating = false;
showResultDialog = false;
executing = false;
private timerId: any = null;
private startTs = 0;
elapsedMs = 0;
lastRunTime: Date | null = null;
wrongPass = false;
selectedFileName: string | null = null;
private fileHandle: any = null; // FileSystemFileHandle
private isSyncing = false;
result: string | null = null;
error: string | null = null;
form = this.fb.group({
password: ['', Validators.required],
mode: ['inline', Validators.required],
targets: ['tcp:127.0.0.1:22'],
delay: ['1s'],
verbose: [true],
waitConnection: [false],
gateway: [''],
configYAML: [''],
serverFilePath: ['']
});
constructor() {
// Загружаем сохраненное состояние из localStorage
this.loadStateFromLocalStorage();
// Запускаем анимацию заголовка
this.startTitleAnimation();
// Сбрасываем индикатор неверного пароля при изменении поля
this.form.get('password')?.valueChanges.subscribe(() => {
this.wrongPass = false;
});
// React on YAML text changes: extract path and sync to serverFilePath
this.form.get('configYAML')?.valueChanges.subscribe((val) => {
if (this.isSyncing) return;
if (this.isInlineMode() || this.isYamlEncrypted()) return;
try {
const p = this.extractPathFromYaml(String(val ?? ''));
const currentPath = this.form.value.serverFilePath || '';
if (p && p !== currentPath) {
this.isSyncing = true;
this.form.patchValue({ serverFilePath: p });
this.isSyncing = false;
}
} catch {}
});
// React on serverFilePath changes: update YAML path field
this.form.get('serverFilePath')?.valueChanges.subscribe((newPath) => {
if (this.isSyncing) return;
this.onServerPathChange(newPath || '');
});
// Подписка на изменение режима для автоматического преобразования
this.setupModeConversion();
// Подписки на изменения полей для автосохранения в localStorage
this.setupAutoSave();
// File System Access API detection (для обратной совместимости)
// Логика FSA теперь находится в отдельных компонентах
}
private authHeader(pass: string) {
// Basic auth с пользователем "knocker"
const token = btoa(`knocker:${pass}`);
return { Authorization: `Basic ${token}` };
}
execute() {
this.error = null;
this.result = null;
this.wrongPass = false;
if (this.form.invalid) return;
const v = this.form.value;
const body: any = {
targets: v.targets,
delay: v.delay,
verbose: v.verbose,
waitConnection: v.waitConnection,
gateway: v.gateway,
};
if (v.mode === 'yaml') {
body.config_yaml = v.configYAML;
delete body.targets;
delete body.delay;
}
this.executing = true;
this.startTimer();
this.http.post('/api/v1/knock-actions/execute', body, {
headers: this.authHeader(v.password || '')
}).subscribe({
next: () => {
this.executing = false;
this.stopTimer();
this.lastRunTime = new Date();
this.result = `Done in ${(this.elapsedMs/1000).toFixed(2)}s`;
this.showResultDialog = true;
},
error: (e: HttpErrorResponse) => {
this.executing = false;
this.stopTimer();
if (e.status === 401) {
this.wrongPass = true;
}
this.error = (e.error?.error) || e.message;
this.showResultDialog = true;
}
});
}
encrypt() {
this.error = null;
this.result = null;
const v = this.form.value;
if (this.isInlineMode() || this.isYamlEncrypted() || !v.password || this.wrongPass) {
return;
}
// Проверяем есть ли path в YAML самом
const pathFromYaml = this.getPathFromYaml(v.configYAML || '');
const serverFilePath = (this.form.value.serverFilePath || '').trim();
let url: string;
let body: any;
if (pathFromYaml) {
// Если path в YAML - используем /encrypt, сервер сам найдет path в YAML
url = '/api/v1/knock-actions/encrypt';
body = { yaml: v.configYAML };
} else if (serverFilePath) {
// Если path только в serverFilePath - используем /encrypt-file
url = '/api/v1/knock-actions/encrypt-file';
body = { path: serverFilePath };
} else {
// Нет пути - обычное шифрование содержимого
url = '/api/v1/knock-actions/encrypt';
body = { yaml: v.configYAML };
}
this.http.post(url, body, {
headers: this.authHeader(v.password || '')
}).subscribe({
next: async (res: any) => {
const encrypted: string = res.encrypted || '';
// Всегда обновляем YAML поле зашифрованным содержимым
this.form.patchValue({ configYAML: encrypted });
if (pathFromYaml) {
this.result = `Encrypted (YAML path: ${pathFromYaml})`;
// НЕ сохраняем файл клиентом - сервер уже записал по path из YAML
} else if (serverFilePath) {
this.result = `Encrypted (server path: ${serverFilePath})`;
// НЕ сохраняем файл клиентом - сервер записал по serverFilePath
} else {
this.result = 'Encrypted';
// Только сохраняем в файл если НЕТ серверного пути
await this.saveBackToFileIfPossible(encrypted, this.selectedFileName);
}
},
error: (e: HttpErrorResponse) => this.error = (e.error && e.error.error) || e.message
});
}
decrypt() {
this.error = null;
this.result = null;
const v = this.form.value;
if (this.isInlineMode() || !this.isYamlEncrypted() || !v.password || this.wrongPass) {
return;
}
// Для зашифрованного YAML поле serverFilePath недоступно - используем только decrypt
const url = '/api/v1/knock-actions/decrypt';
const body = { encrypted: v.configYAML as string };
this.http.post(url, body, {
headers: this.authHeader(v.password || '')
}).subscribe({
next: async (res: any) => {
const plain: string = res.yaml || '';
this.form.patchValue({ configYAML: plain });
this.result = 'Decrypted';
// Извлекаем path из расшифрованного YAML и обновляем serverFilePath
const pathFromDecrypted = this.getPathFromYaml(plain);
if (pathFromDecrypted) {
this.isSyncing = true;
this.form.patchValue({ serverFilePath: pathFromDecrypted });
this.isSyncing = false;
this.result += ` (found path: ${pathFromDecrypted})`;
}
// НЕ делаем download - сервер уже обработал файл согласно path в YAML
},
error: (e: HttpErrorResponse) => this.error = (e.error && e.error.error) || e.message
});
}
onFileSelected(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
const file = input.files[0];
this.selectedFileName = file.name;
const reader = new FileReader();
reader.onload = () => {
const text = String(reader.result || '');
this.form.patchValue({ configYAML: text, mode: 'yaml' });
// Sync path from YAML into serverFilePath
const p = this.extractPathFromYaml(text);
if (p) {
this.isSyncing = true;
this.form.patchValue({ serverFilePath: p });
this.isSyncing = false;
}
};
reader.readAsText(file);
this.fileHandle = null; // обычная загрузка не даёт handle на запись
}
downloadYaml() {
const yaml = this.form.value.configYAML || '';
this.triggerDownload('config.yaml', yaml);
}
downloadResult() {
const content = this.result || this.form.value.configYAML || '';
const name = (content || '').startsWith('ENCRYPTED:') ? 'config.encrypted' : 'config.yaml';
this.triggerDownload(name, content);
}
onFileUpload(event: any) {
const files: File[] = event?.files || event?.currentFiles || [];
if (!files.length) return;
const file = files[0];
this.selectedFileName = file.name;
const reader = new FileReader();
reader.onload = () => {
const text = String(reader.result || '');
this.form.patchValue({ configYAML: text, mode: 'yaml' });
const p = this.extractPathFromYaml(text);
if (!p) {
return;
}
this.isSyncing = true;
this.form.patchValue({ serverFilePath: p });
this.isSyncing = false;
};
reader.readAsText(file);
this.fileHandle = null;
}
async openFileWithWriteAccess() {
try {
const w: any = window as any;
if (!w || typeof w.showOpenFilePicker !== 'function') {
this.error = 'File System Access API is not supported by this browser.';
return;
}
const [handle] = await w.showOpenFilePicker({
types: [{ description: 'YAML/Encrypted', accept: { 'text/plain': ['.yaml', '.yml', '.encrypted', '.txt'] } }]
});
this.fileHandle = handle;
const file = await handle.getFile();
this.selectedFileName = file.name;
const text = await file.text();
this.form.patchValue({ configYAML: text, mode: 'yaml' });
const p = this.extractPathFromYaml(text);
if (p) {
this.isSyncing = true;
this.form.patchValue({ serverFilePath: p });
this.isSyncing = false;
}
this.result = `Opened: ${file.name}`;
this.error = null;
} catch (e: any) {
// user cancelled or error
}
}
// Helpers for UI state
isInlineMode(): boolean {
return (this.form.value.mode === 'inline');
}
isYamlEncrypted(): boolean {
const s = (this.form.value.configYAML || '').toString().trim();
return s.startsWith('ENCRYPTED:');
}
private triggerDownload(filename: string, text: string) {
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
private async saveBackToFileIfPossible(content: string, filename: string | null) {
try {
const w: any = window as any;
if (this.fileHandle && typeof this.fileHandle.createWritable === 'function') {
const writable = await this.fileHandle.createWritable();
await writable.write(content);
await writable.close();
return;
}
if (w && typeof w.showSaveFilePicker === 'function') {
const handle = await w.showSaveFilePicker({
suggestedName: filename || 'config.yaml',
types: [{ description: 'YAML/Encrypted', accept: { 'text/plain': ['.yaml', '.yml', '.encrypted', '.txt'] } }]
});
const writable = await handle.createWritable();
await writable.write(content);
await writable.close();
return;
} else if (filename) {
this.triggerDownload(filename, content);
} else {
this.triggerDownload('config.yaml', content);
}
} catch {
if (filename) {
this.triggerDownload(filename, content);
} else {
this.triggerDownload('config.yaml', content);
}
}
}
private startTimer() {
this.elapsedMs = 0;
this.startTs = Date.now();
this.clearTimer();
this.timerId = setInterval(() => {
this.elapsedMs = Date.now() - this.startTs;
}, 100);
}
private stopTimer() {
if (this.startTs > 0) {
this.elapsedMs = Date.now() - this.startTs;
}
this.clearTimer();
}
private clearTimer() {
if (this.timerId) {
clearInterval(this.timerId);
this.timerId = null;
}
}
// YAML path helpers
private getPathFromYaml(text: string): string {
return this.extractPathFromYaml(text);
}
private extractPathFromYaml(text: string): string {
try {
const doc: any = yaml.load(text);
if (doc && typeof doc === 'object' && typeof doc.path === 'string') {
return doc.path;
}
} catch {}
return '';
}
onServerPathChange(newPath: string) {
if (this.isInlineMode() || this.isYamlEncrypted()) return;
environment.log('onServerPathChange', newPath);
const current = String(this.form.value.configYAML || '');
try {
const doc: any = current.trim() ? yaml.load(current) : {};
if (!doc || typeof doc !== 'object') return;
(doc as any).path = newPath || '';
this.isSyncing = true;
this.form.patchValue({ configYAML: yaml.dump(doc, { lineWidth: 120 }) }, { emitEvent: true });
this.isSyncing = false;
} catch {}
}
// LocalStorage functionality
private readonly STORAGE_KEY = 'knocker-ui-state';
private loadStateFromLocalStorage() {
try {
const saved = localStorage.getItem(this.STORAGE_KEY);
if (!saved) return;
const state = JSON.parse(saved);
environment.log('Loading saved state:', state);
// Применяем сохраненные значения к форме
const patchData: any = {};
if (state.mode !== undefined) patchData.mode = state.mode;
if (state.targets !== undefined) patchData.targets = state.targets;
if (state.delay !== undefined) patchData.delay = state.delay;
if (state.verbose !== undefined) patchData.verbose = state.verbose;
if (state.waitConnection !== undefined) patchData.waitConnection = state.waitConnection;
if (state.configYAML !== undefined) patchData.configYAML = state.configYAML;
if (Object.keys(patchData).length > 0) {
this.form.patchValue(patchData);
// Если загружен YAML, извлекаем path и устанавливаем в serverFilePath
if (state.configYAML) {
const pathFromYaml = this.getPathFromYaml(state.configYAML);
if (pathFromYaml) {
this.isSyncing = true;
this.form.patchValue({ serverFilePath: pathFromYaml });
this.isSyncing = false;
environment.log('Extracted path from loaded YAML:', pathFromYaml);
}
}
}
} catch (e) {
console.warn('Failed to load state from localStorage:', e);
}
}
private saveStateToLocalStorage() {
try {
const formValue = this.form.value;
const state = {
mode: formValue.mode,
targets: formValue.targets,
delay: formValue.delay,
verbose: formValue.verbose,
waitConnection: formValue.waitConnection,
configYAML: formValue.configYAML
};
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
environment.log('State saved to localStorage:', state);
} catch (e) {
console.warn('Failed to save state to localStorage:', e);
}
}
private setupAutoSave() {
// Подписываемся на изменения нужных полей
const fieldsToWatch = ['mode', 'targets', 'delay', 'verbose', 'waitConnection', 'configYAML'];
fieldsToWatch.forEach(fieldName => {
this.form.get(fieldName)?.valueChanges.subscribe(() => {
// Небольшая задержка, чтобы не сохранять на каждое нажатие клавиши
setTimeout(() => this.saveStateToLocalStorage(), 300);
});
});
}
// Автоматическое преобразование между режимами
private setupModeConversion() {
let previousMode = this.form.value.mode;
this.form.get('mode')?.valueChanges.subscribe((newMode) => {
if (this.isSyncing) return;
environment.log(`Mode changed from ${previousMode} to ${newMode}`);
if (previousMode === 'inline' && newMode === 'yaml') {
this.handleModeChangeToYaml();
} else if (previousMode === 'yaml' && newMode === 'inline') {
this.handleModeChangeToInline();
}
previousMode = newMode;
});
}
private handleModeChangeToYaml() {
try {
// Проверяем есть ли сохраненный YAML в localStorage
const savedYaml = this.getSavedConfigYAML();
if (savedYaml?.trim()) {
// Используем сохраненный YAML
environment.log('Using saved YAML from localStorage');
this.isSyncing = true;
this.form.patchValue({ configYAML: savedYaml });
this.isSyncing = false;
} else {
// Конвертируем из inline
environment.log('Converting inline to YAML');
this.convertInlineToYaml();
}
// После установки YAML (из localStorage или конвертации) извлекаем path
setTimeout(() => this.extractAndSetServerPath(), 100);
} catch (e) {
console.warn('Failed to handle mode change to YAML:', e);
}
}
private handleModeChangeToInline() {
try {
// Проверяем есть ли сохраненные inline значения в localStorage
const savedTargets = this.getSavedTargets();
if (savedTargets && savedTargets.trim()) {
// Используем сохраненные inline значения
environment.log('Using saved inline targets from localStorage');
this.isSyncing = true;
this.form.patchValue({ targets: savedTargets });
this.isSyncing = false;
} else {
// Конвертируем из YAML
environment.log('Converting YAML to inline');
this.convertYamlToInline();
}
} catch (e) {
console.warn('Failed to handle mode change to inline:', e);
}
}
private getSavedConfigYAML(): string | null {
try {
const saved = localStorage.getItem(this.STORAGE_KEY);
if (!saved) return null;
const state = JSON.parse(saved);
return state.configYAML || null;
} catch {
return null;
}
}
private getSavedTargets(): string | null {
try {
const saved = localStorage.getItem(this.STORAGE_KEY);
if (!saved) return null;
const state = JSON.parse(saved);
return state.targets || null;
} catch {
return null;
}
}
private extractAndSetServerPath() {
try {
const yamlContent = this.form.value.configYAML || '';
if (!yamlContent.trim()) return;
const config: any = yaml.load(yamlContent);
if (config && typeof config === 'object' && config.path) {
environment.log('Extracted path from YAML:', config.path);
this.isSyncing = true;
this.form.patchValue({ serverFilePath: config.path });
this.isSyncing = false;
}
} catch (e) {
console.warn('Failed to extract path from YAML:', e);
}
}
private convertInlineToYaml() {
try {
const formValue = this.form.value;
const targets = (formValue.targets || '').split(';').filter(t => t.trim());
// Создаем YAML конфигурацию из inline параметров
const config: any = {
targets: targets.map(target => {
const [protocol, address] = target.trim().split(':');
const [host, port] = address ? address.split(':') : ['', ''];
return {
protocol: protocol || 'tcp',
host: host || '127.0.0.1',
ports: [parseInt(port) || 22],
wait_connection: formValue.waitConnection || false
};
}),
delay: formValue.delay || '1s'
};
const yamlString = yaml.dump(config, { lineWidth: 120 });
this.isSyncing = true;
this.form.patchValue({ configYAML: yamlString });
this.isSyncing = false;
environment.log('Converted inline to YAML:', config);
} catch (e) {
console.warn('Failed to convert inline to YAML:', e);
}
}
private convertYamlToInline() {
try {
const yamlContent = this.form.value.configYAML || '';
if (!yamlContent.trim()) {
// Если YAML пустой, используем значения по умолчанию
this.isSyncing = true;
this.form.patchValue({
targets: 'tcp:127.0.0.1:22',
delay: '1s',
waitConnection: false
});
this.isSyncing = false;
return;
}
const config: any = yaml.load(yamlContent);
if (!config || !config.targets || !Array.isArray(config.targets)) {
console.warn('Invalid YAML structure for conversion');
return;
}
// Конвертируем targets в строку - создаем отдельную строку для каждого порта
const targetStrings: string[] = [];
config.targets.forEach((target: any) => {
const protocol = target.protocol || 'tcp';
const host = target.host || '127.0.0.1';
// Поддерживаем как ports (массив), так и port (единственное число) для обратной совместимости
const ports = target.ports || [target.port] || [22];
if (Array.isArray(ports)) {
// Создаем отдельную строку для каждого порта
ports.forEach(port => {
targetStrings.push(`${protocol}:${host}:${port}`);
});
} else {
targetStrings.push(`${protocol}:${host}:${ports}`);
}
});
const targetsString = targetStrings.join(';');
const delay = config.delay || '1s';
// Берем wait_connection из первой цели (если есть)
const waitConnection = config.targets[0]?.wait_connection || false;
this.isSyncing = true;
this.form.patchValue({
targets: targetsString,
delay: delay,
waitConnection: waitConnection
});
this.isSyncing = false;
environment.log('Converted YAML to inline:', { targets: targetsString, delay, waitConnection });
} catch (e) {
console.warn('Failed to convert YAML to inline:', e);
}
}
// Публичный метод для очистки сохраненного состояния (опционально)
clearSavedState() {
try {
localStorage.removeItem(this.STORAGE_KEY);
environment.log('Saved state cleared from localStorage');
// Сбрасываем форму к значениям по умолчанию
this.form.patchValue({
mode: 'inline',
targets: 'tcp:127.0.0.1:22',
delay: '1s',
verbose: true,
waitConnection: false,
configYAML: ''
});
} catch (e) {
console.warn('Failed to clear saved state:', e);
}
}
// Анимация заголовка
private startTitleAnimation() {
if (this.isAnimating) return;
this.isAnimating = true;
// Первая анимация: по буквам
this.animateByLetters();
}
private animateByLetters() {
let currentIndex = 0;
const letters = this.animatedTitle.split('');
const interval = setInterval(() => {
if (currentIndex < letters.length) {
this.cardHeader = letters.slice(0, currentIndex + 1).join('');
currentIndex++;
} else {
clearInterval(interval);
// Ждем 2 секунды и начинаем исчезать
setTimeout(() => {
this.fadeOutTitle();
}, 2000);
}
}, 100); // 100ms между буквами
}
private fadeOutTitle() {
let opacity = 1;
const fadeInterval = setInterval(() => {
opacity -= 0.1;
if (opacity <= 0) {
clearInterval(fadeInterval);
this.cardHeader = '';
// Ждем 1 секунду и начинаем анимацию по словам
setTimeout(() => {
this.animateByWords();
}, 1000);
}
}, 50);
}
private animateByWords() {
const words = this.animatedTitle.split(' ');
let currentIndex = 0;
const interval = setInterval(() => {
if (currentIndex < words.length) {
this.cardHeader = words.slice(0, currentIndex + 1).join(' ');
currentIndex++;
} else {
clearInterval(interval);
this.isAnimating = false;
}
}, 300); // 300ms между словами
}
}