fix backups

This commit is contained in:
d34dscene
2025-01-26 19:12:37 +01:00
parent 91408076a5
commit e1c00557b3
32 changed files with 424 additions and 2287 deletions
+40 -5
View File
@@ -20,7 +20,11 @@ func DownloadBackup(bm *config.BackupManager) http.HandlerFunc {
bm.CreateBackup(r.Context())
filename, err = bm.GetLatestBackup()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to create backup: %v", err), http.StatusInternalServerError)
http.Error(
w,
fmt.Sprintf("Failed to create backup: %v", err),
http.StatusInternalServerError,
)
return
}
}
@@ -38,6 +42,21 @@ func DownloadBackup(bm *config.BackupManager) http.HandlerFunc {
}
}
func CreateBackup(bm *config.BackupManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
err := bm.CreateBackup(r.Context())
if err != nil {
http.Error(
w,
fmt.Sprintf("Failed to create backup: %v", err),
http.StatusInternalServerError,
)
return
}
w.WriteHeader(http.StatusCreated)
}
}
// RestoreBackup restores a backup from a provided file.
func RestoreBackup(bm *config.BackupManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -66,7 +85,11 @@ func RestoreBackup(bm *config.BackupManager) http.HandlerFunc {
// Create destination file
dst, err := os.Create(backupPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to create backup file: %v", err), http.StatusInternalServerError)
http.Error(
w,
fmt.Sprintf("Failed to create backup file: %v", err),
http.StatusInternalServerError,
)
return
}
defer dst.Close()
@@ -81,7 +104,11 @@ func RestoreBackup(bm *config.BackupManager) http.HandlerFunc {
if err := bm.RestoreBackup(r.Context(), backupPath); err != nil {
// Clean up the uploaded file if restore fails.
os.Remove(backupPath)
http.Error(w, fmt.Sprintf("Failed to restore backup: %v", err), http.StatusInternalServerError)
http.Error(
w,
fmt.Sprintf("Failed to restore backup: %v", err),
http.StatusInternalServerError,
)
return
}
@@ -98,13 +125,21 @@ func ListBackups(bm *config.BackupManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
backups, err := bm.ListBackups()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to list backups: %v", err), http.StatusInternalServerError)
http.Error(
w,
fmt.Sprintf("Failed to list backups: %v", err),
http.StatusInternalServerError,
)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(backups); err != nil {
http.Error(w, fmt.Sprintf("Failed to encode response: %v", err), http.StatusInternalServerError)
http.Error(
w,
fmt.Sprintf("Failed to encode response: %v", err),
http.StatusInternalServerError,
)
return
}
}
+9 -2
View File
@@ -27,6 +27,12 @@ func (s *Server) routes() {
mw.BasicAuth,
)
// adminChain := middlewares.Chain(
// mw.Logger,
// mw.JWT,
// mw.AdminOnly,
// )
// Helper for middleware registration
register := func(method, path string, chain middlewares.Middleware, handler http.HandlerFunc) {
s.mux.Handle(method+" /api"+path, chain(handler))
@@ -93,9 +99,10 @@ func (s *Server) routes() {
register("POST", "/agent/token/{id}", jwtChain, handler.RotateAgentToken(s.app))
// Backup
register("GET", "/backups", jwtChain, handler.ListBackups(s.app.BM))
register("GET", "/backup/list", jwtChain, handler.ListBackups(s.app.BM))
register("GET", "/backup", jwtChain, handler.DownloadBackup(s.app.BM))
register("POST", "/backup", jwtChain, handler.RestoreBackup(s.app.BM))
register("POST", "/backup/restore", jwtChain, handler.RestoreBackup(s.app.BM))
register("POST", "/backup", jwtChain, handler.CreateBackup(s.app.BM))
// IP
// register("GET", "/ip/{id}", jwtChain, GetPublicIP)
+230 -96
View File
@@ -2,6 +2,7 @@ package config
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
@@ -18,10 +19,11 @@ import (
type BackupManager struct {
Config BackupConfig
db *db.Queries
DBType string
DBPath string
cronJob *cron.Cron
db *sql.DB
q *db.Queries
}
type BackupMetadata struct {
@@ -32,7 +34,7 @@ type BackupMetadata struct {
Version string `json:"version"`
}
func NewBackupManager(config Config, db *db.Queries) (*BackupManager, error) {
func NewBackupManager(ctx context.Context, config Config) (*BackupManager, error) {
if err := os.MkdirAll(config.Backup.Dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create backup directory: %w", err)
}
@@ -50,38 +52,79 @@ func NewBackupManager(config Config, db *db.Queries) (*BackupManager, error) {
// return nil, err
// }
return &BackupManager{
bm := &BackupManager{
Config: config.Backup,
db: db,
DBType: config.Database.Type,
DBPath: config.DBPath(),
cronJob: cron.New(),
}, nil
}
if err := bm.Start(ctx); err != nil {
return nil, err
}
return bm, nil
}
func (bm *BackupManager) Start(ctx context.Context) error {
if !bm.Config.Enabled {
return nil
}
// Schedule backup job
_, err := bm.cronJob.AddFunc(bm.Config.Schedule, func() {
if err := bm.CreateBackup(ctx); err != nil {
slog.Error("Scheduled backup failed", "error", err)
// Ensure previous connections are closed
if bm.db != nil {
if err := bm.Stop(); err != nil {
return fmt.Errorf("failed to stop existing connections: %w", err)
}
})
if err != nil {
return fmt.Errorf("failed to schedule backup: %w", err)
}
bm.cronJob.Start()
// Small delay before opening new connections
time.Sleep(100 * time.Millisecond)
// Initialize new database connection with retries
maxRetries := 3
for i := 0; i < maxRetries; i++ {
newDB, err := db.InitDB()
if err == nil {
bm.q = db.New(newDB)
bm.db = newDB
break
}
if i == maxRetries-1 {
return fmt.Errorf(
"failed to initialize database after %d attempts: %w",
maxRetries,
err,
)
}
time.Sleep(100 * time.Millisecond)
}
if bm.Config.Enabled {
// Schedule backup job
_, err := bm.cronJob.AddFunc(bm.Config.Schedule, func() {
if err := bm.CreateBackup(ctx); err != nil {
slog.Error("Scheduled backup failed", "error", err)
}
})
if err != nil {
return fmt.Errorf("failed to schedule backup: %w", err)
}
bm.cronJob.Start()
}
return nil
}
func (bm *BackupManager) Stop() {
func (bm *BackupManager) Stop() error {
if bm.cronJob != nil {
bm.cronJob.Stop()
}
if bm.db != nil {
if err := bm.db.Close(); err != nil {
return fmt.Errorf("failed to close database connection: %w", err)
}
}
// Small delay to ensure SQLite cleanup
time.Sleep(200 * time.Millisecond)
return nil
}
func (bm *BackupManager) CreateBackup(ctx context.Context) error {
@@ -89,8 +132,7 @@ func (bm *BackupManager) CreateBackup(ctx context.Context) error {
backupName := fmt.Sprintf("backup_%s.db", timestamp.Format("20060102_150405"))
backupPath := filepath.Join(bm.Config.Dir, backupName)
// Create backup file
if err := bm.performBackup(ctx, backupPath); err != nil {
if err := bm.backupSQLite(backupPath); err != nil {
return err
}
@@ -114,18 +156,7 @@ func (bm *BackupManager) CreateBackup(ctx context.Context) error {
return nil
}
func (bm *BackupManager) performBackup(ctx context.Context, destPath string) error {
switch bm.DBType {
case "sqlite":
return bm.backupSQLite(ctx, destPath)
case "postgres":
return bm.backupPostgres(ctx, destPath)
default:
return fmt.Errorf("unsupported database type: %s", bm.DBType)
}
}
func (bm *BackupManager) backupSQLite(ctx context.Context, destPath string) error {
func (bm *BackupManager) backupSQLite(destPath string) error {
// For SQLite, we can simply copy the database file
src, err := os.Open(bm.DBPath)
if err != nil {
@@ -146,21 +177,6 @@ func (bm *BackupManager) backupSQLite(ctx context.Context, destPath string) erro
return nil
}
func (bm *BackupManager) backupPostgres(ctx context.Context, destPath string) error {
// Implement pg_dump here
// Example implementation:
/*
cmd := exec.CommandContext(ctx, "pg_dump",
"-h", host,
"-U", username,
"-d", dbname,
"-f", destPath,
)
return cmd.Run()
*/
return fmt.Errorf("postgres backup not implemented")
}
func (bm *BackupManager) saveMetadata(path string, metadata BackupMetadata) error {
file, err := os.Create(path)
if err != nil {
@@ -222,30 +238,6 @@ func (bm *BackupManager) cleanup() error {
return nil
}
// RestoreBackup restores a database from a backup file
func (bm *BackupManager) RestoreBackup(ctx context.Context, backupPath string) error {
// Verify backup metadata
metadataPath := backupPath + ".json"
metadata, err := bm.loadMetadata(metadataPath)
if err != nil {
return fmt.Errorf("failed to load backup metadata: %w", err)
}
if metadata.DBType != bm.DBType {
return fmt.Errorf("backup database type (%s) doesn't match current type (%s)",
metadata.DBType, bm.DBType)
}
switch bm.DBType {
case "sqlite":
return bm.restoreSQLite(ctx, backupPath)
case "postgres":
return bm.restorePostgres(ctx, backupPath)
default:
return fmt.Errorf("unsupported database type: %s", bm.DBType)
}
}
func (bm *BackupManager) loadMetadata(path string) (BackupMetadata, error) {
var metadata BackupMetadata
file, err := os.Open(path)
@@ -261,38 +253,174 @@ func (bm *BackupManager) loadMetadata(path string) (BackupMetadata, error) {
return metadata, nil
}
func (bm *BackupManager) restoreSQLite(ctx context.Context, backupPath string) error {
// Create a temporary database path
tempDB := bm.DBPath + ".tmp"
func (bm *BackupManager) RestoreBackup(ctx context.Context, backupPath string) error {
ext := filepath.Ext(backupPath)
// Copy backup to temporary location
if err := copyFile(backupPath, tempDB); err != nil {
return fmt.Errorf("failed to create temporary database: %w", err)
if ext == ".db" {
// For .db files, use the existing file replacement method
tempDB := bm.DBPath + ".tmp"
defer os.Remove(tempDB)
// Copy backup to temporary location
if err := copyFile(backupPath, tempDB); err != nil {
return fmt.Errorf("failed to create temporary database: %w", err)
}
// Test the temporary database
if err := bm.testDatabase(tempDB); err != nil {
return fmt.Errorf("backup verification failed: %w", err)
}
// Create a backup of the current database
currentBackup := bm.DBPath + ".old"
if err := copyFile(bm.DBPath, currentBackup); err != nil {
return fmt.Errorf("failed to backup current database: %w", err)
}
// Close the current database connection
if err := bm.Stop(); err != nil {
return fmt.Errorf("failed to close database connections: %w", err)
}
// Replace the current database
if err := os.Rename(tempDB, bm.DBPath); err != nil {
// If replacement fails, try to restore the old database
if err = os.Rename(currentBackup, bm.DBPath); err != nil {
return fmt.Errorf("failed to restore old database: %w", err)
}
slog.Error("Failed to replace database", "error", err)
}
// Clean up the old backup
os.Remove(currentBackup)
// Start the new database
if err := bm.Start(ctx); err != nil {
return fmt.Errorf("failed to start database connections: %w", err)
}
return nil
}
// Test the temporary database
if err := bm.testDatabase(tempDB); err != nil {
os.Remove(tempDB)
return fmt.Errorf("backup verification failed: %w", err)
if ext == ".sql" {
// For .sql files, execute the SQL statements
sqlContent, err := os.ReadFile(backupPath)
if err != nil {
return fmt.Errorf("failed to read SQL backup file: %w", err)
}
// Create a new database connection for the restore
db, err := sql.Open("sqlite", bm.DBPath)
if err != nil {
return fmt.Errorf("failed to open database for restore: %w", err)
}
defer db.Close()
// Start a transaction
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to start transaction: %w", err)
}
defer tx.Rollback()
// Execute SQL statements
if _, err := tx.ExecContext(ctx, string(sqlContent)); err != nil {
return fmt.Errorf("failed to execute SQL backup: %w", err)
}
// Commit the transaction
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit restore transaction: %w", err)
}
return nil
}
// Replace the current database
if err := os.Rename(tempDB, bm.DBPath); err != nil {
os.Remove(tempDB)
return fmt.Errorf("failed to replace database: %w", err)
}
return nil
}
func (bm *BackupManager) restorePostgres(ctx context.Context, backupPath string) error {
// Implement pg_restore here
return fmt.Errorf("postgres restore not implemented")
return fmt.Errorf("unsupported backup file format: %s", ext)
}
func (bm *BackupManager) testDatabase(dbPath string) error {
// Implement database verification here
// For example, try to open the database and run a simple query
// Try to open the database
testDB, err := sql.Open("sqlite", dbPath)
if err != nil {
return fmt.Errorf("failed to open backup database: %w", err)
}
defer testDB.Close()
// Test if database is accessible with a simple query
if err = testDB.Ping(); err != nil {
return fmt.Errorf("backup database is not accessible: %w", err)
}
// Start a read-only transaction
tx, err := testDB.BeginTx(context.Background(), &sql.TxOptions{
ReadOnly: true,
})
if err != nil {
return fmt.Errorf("failed to start transaction: %w", err)
}
defer tx.Rollback()
// Get list of tables
rows, err := tx.Query(`
SELECT name FROM sqlite_master
WHERE type='table'
AND name NOT LIKE 'sqlite_%'
AND name NOT LIKE 'goose_%'
`)
if err != nil {
return fmt.Errorf("failed to query tables: %w", err)
}
defer rows.Close()
// Check each table's schema
var tables []string
for rows.Next() {
var tableName string
if err = rows.Scan(&tableName); err != nil {
return fmt.Errorf("failed to scan table name: %w", err)
}
tables = append(tables, tableName)
}
if len(tables) == 0 {
return fmt.Errorf("backup database contains no tables")
}
// Verify schema for each table
for _, table := range tables {
// Get table info
rows, err := tx.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return fmt.Errorf("failed to get schema for table %s: %w", table, err)
}
var hasColumns bool
for rows.Next() {
hasColumns = true
break
}
rows.Close()
if !hasColumns {
return fmt.Errorf("table %s has no columns", table)
}
}
// Test foreign key constraints
_, err = tx.Exec("PRAGMA foreign_key_check")
if err != nil {
return fmt.Errorf("foreign key check failed: %w", err)
}
// Try to read a row from each table to ensure data is accessible
for _, table := range tables {
query := fmt.Sprintf("SELECT * FROM %s LIMIT 1", table)
_, err := tx.Query(query)
if err != nil {
return fmt.Errorf("failed to read from table %s: %w", table, err)
}
}
return nil
}
@@ -363,11 +491,17 @@ func (bm *BackupManager) IsValidBackupFile(filename string) bool {
}
// Check if file matches expected pattern
matched, err := filepath.Match("backup_*.db", filename)
matched, err := filepath.Match("backup_*.*", filename)
if err != nil || !matched {
return false
}
// Check file extension
ext := filepath.Ext(filename)
if ext != ".db" && ext != ".sql" {
return false
}
// Check if file exists in backup directory
fullPath := filepath.Join(bm.Config.Dir, filename)
if _, err := os.Stat(fullPath); err != nil {
+10 -12
View File
@@ -22,12 +22,6 @@ type App struct {
}
func Setup() (*App, error) {
// Initialize database
DB, err := db.InitDB()
if err != nil {
return nil, err
}
// Read environment variables
config, err := ReadConfig()
if err != nil {
@@ -40,28 +34,32 @@ func Setup() (*App, error) {
return nil, err
}
// Setup backup manager
bm, err := NewBackupManager(*config, db.New(DB))
// Setup backup manager and initialize database
bm, err := NewBackupManager(context.Background(), *config)
if err != nil {
return nil, fmt.Errorf("failed to create backup manager: %w", err)
}
// Setup settings manager
sm := NewSettingsManager(db.New(DB))
sm := NewSettingsManager(bm.q)
if err := sm.Initialize(context.Background()); err != nil {
return nil, fmt.Errorf("failed to initialize settings: %w", err)
}
app := App{
Config: config,
DB: db.New(DB),
DB: bm.q,
BM: bm,
SM: sm,
}
app.setupLogger()
app.setDefaultAdminUser()
app.setDefaultProfile()
if err := app.setDefaultAdminUser(); err != nil {
return nil, err
}
if err := app.setDefaultProfile(); err != nil {
return nil, err
}
// Update self
util.UpdateSelf(flags.Update)
-85
View File
@@ -1,85 +0,0 @@
// Package traefik provides a client for the Traefik API
// Here are all the models used to convert between the API and the UI
package traefik
import (
"github.com/traefik/traefik/v3/pkg/config/dynamic"
)
type Dynamic struct {
ProfileID int64 `json:"profile_id,omitempty"`
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
}
type Entrypoint struct {
Name string `json:"name,omitempty"`
Address string `json:"address,omitempty"`
AsDefault bool `json:"asDefault,omitempty"`
HTTP struct {
Middlewares []string `json:"middlewares,omitempty"`
TLS *dynamic.RouterTLSConfig `json:"tls,omitempty"`
} `json:"http,omitempty"`
}
type Version struct {
Version string `json:"version,omitempty"`
Codename string `json:"codename,omitempty"`
StartDate string `json:"startDate,omitempty"`
}
type Overview struct {
HTTP HTTPOverview `json:"http,omitempty"`
TCP TCPOverview `json:"tcp,omitempty"`
UDP UDPOverview `json:"udp,omitempty"`
Features struct {
Tracing string `json:"tracing,omitempty"`
Metrics string `json:"metrics,omitempty"`
AccessLog bool `json:"accessLog,omitempty"`
} `json:"features,omitempty"`
Providers []string `json:"providers,omitempty"`
}
type BasicOverview struct {
Total int `json:"total,omitempty"`
Warnings int `json:"warnings,omitempty"`
Errors int `json:"errors,omitempty"`
}
type HTTPOverview struct {
Routers BasicOverview `json:"routers,omitempty"`
Services BasicOverview `json:"services,omitempty"`
Middleware BasicOverview `json:"middlewares,omitempty"`
}
type TCPOverview struct {
Routers BasicOverview `json:"routers,omitempty"`
Services BasicOverview `json:"services,omitempty"`
Middleware BasicOverview `json:"middlewares,omitempty"`
}
type UDPOverview struct {
Routers BasicOverview `json:"routers,omitempty"`
Services BasicOverview `json:"services,omitempty"`
}
type Plugin struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Author string `json:"author"`
Type string `json:"type"`
Import string `json:"import"`
Summary string `json:"summary"`
IconUrl string `json:"iconUrl"`
BannerUrl string `json:"bannerUrl"`
Readme string `json:"readme"`
LatestVersion string `json:"latestVersion"`
Versions []string `json:"versions"`
Stars int `json:"stars"`
Snippet PluginSnippet `json:"snippet"`
CreatedAt string `json:"createdAt"`
}
type PluginSnippet struct {
Yaml string `json:"yaml"`
}
+58 -5
View File
@@ -1,6 +1,7 @@
import {
TraefikSource,
type Agent,
type BackupMetadata,
type DNSProvider,
type Plugin,
type Profile,
@@ -48,6 +49,7 @@ export const dnsProviders: Writable<DNSProvider[]> = writable([]);
export const agents: Writable<Agent[]> = writable([]);
export const settings: Writable<Settings> = writable({} as Settings);
export const plugins: Writable<Plugin[]> = writable([]);
export const backups: Writable<BackupMetadata[]> = writable([]);
// App state
export const profile: Writable<Profile> = writable({} as Profile);
@@ -75,10 +77,15 @@ async function send(endpoint: string, options: APIOptions = {}, fetch?: typeof w
if (token) {
headers.set('Authorization', 'Bearer ' + token); // Add the Authorization header
}
// Don't set Content-Type for FormData
const isFormData = options?.body instanceof FormData;
if (!isFormData) {
headers.set('Content-Type', 'application/json');
}
const customOptions = {
'Content-Type': 'application/json',
...options,
headers
headers,
body: isFormData ? options?.body : options?.body ? JSON.stringify(options.body) : undefined
};
return fetch ? fetch(url, customOptions) : window.fetch(url, customOptions); // Use custom fetch or default
};
@@ -87,14 +94,17 @@ async function send(endpoint: string, options: APIOptions = {}, fetch?: typeof w
loading.set(true);
const response = await customFetch(`${BASE_URL}${endpoint}`, {
method: options.method || 'GET',
body: options.body ? JSON.stringify(options.body) : undefined
body: options.body,
headers: options.headers
});
if (!response.ok) {
throw new Error(`${await response.text()}`);
throw new Error(await response.text());
}
if (response.status !== 204 && response.status !== 201) {
if (response.headers.get('Content-Type') === 'application/json') {
return await response.json();
} else {
return response;
}
} catch (err: unknown) {
error.set(err instanceof Error ? err.message : String(err));
@@ -393,6 +403,49 @@ export const api = {
await api.listSettings();
},
// Backups -------------------------------------------------------------------
async listBackups() {
const data = await send('/backup/list');
backups.set(data);
},
async createBackup() {
await send('/backup', { method: 'POST' });
await api.listBackups();
},
async downloadBackup() {
try {
const response = await send('/backup');
const blob = await response.blob();
const filename =
response.headers.get('content-disposition')?.split('filename=')[1] || 'backup.db';
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
throw new Error(`Failed to download backup: ${error}`);
}
},
async restoreBackup(files: FileList | null) {
if (!files?.length) return;
const formData = new FormData();
formData.append('file', files[0]);
await send(`/backup/restore`, {
method: 'POST',
body: formData
});
toast.success('Backup restored successfully');
},
// Plugins
async getMiddlewarePlugins() {
const data = await send('/middleware/plugins');
@@ -1,58 +0,0 @@
<script lang="ts">
import type { Middleware } from '$lib/types/middlewares';
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { z } from 'zod';
import { onDestroy } from 'svelte';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
// Define validation schema for addPrefix content
const schema = z.object({
prefix: z
.string({ required_error: 'Prefix is required' })
.trim()
.min(1, 'Prefix is required')
.default('/foo')
});
middleware.content = schema.parse({ ...middleware.content });
// Parse and validate middleware.content for addPrefix
let errors: Record<string, string[] | undefined> = $state({});
const validate = () => {
try {
middleware.content = schema.parse(middleware.content);
errors = {};
} catch (err) {
if (err instanceof z.ZodError) {
errors = err.flatten().fieldErrors;
}
}
};
onDestroy(() => {
validate();
});
</script>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="prefix" class="text-right">Prefix</Label>
<div class="relative col-span-3">
<Input
type="text"
id="prefix"
placeholder="/foo"
bind:value={middleware.content.prefix}
on:input={validate}
{disabled}
/>
{#if errors.prefix}
<div class="col-span-4 text-right text-sm text-red-500">{errors.prefix}</div>
{/if}
</div>
</div>
@@ -1,67 +0,0 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import ArrayInput from '../ui/array-input/array-input.svelte';
import type { Middleware } from '$lib/types/middlewares';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
</script>
<ArrayInput
bind:items={middleware.content.users}
label="Users"
placeholder="user:password"
helpText="Username and password are separated by a colon. Password will be hashed automatically. You will not be able to see the password again!"
{disabled}
/>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="users-file" class="text-right">Users File</Label>
<Input
id="users-file"
name="users-file"
type="text"
bind:value={middleware.content.usersFile}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="/path/to/my/usersfile"
{disabled}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="realm" class="text-right">Realm</Label>
<Input
id="realm"
name="realm"
type="text"
bind:value={middleware.content.realm}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="traefik"
{disabled}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="remove-header" class="text-right">Remove Header</Label>
<Switch
id="remove-header"
bind:checked={middleware.content.removeHeader}
class="col-span-3"
{disabled}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="header-field" class="text-right">Header Field</Label>
<Input
id="header-field"
name="header-field"
type="text"
bind:value={middleware.content.headerField}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="X-WebAuth-User"
{disabled}
/>
</div>
@@ -1,137 +0,0 @@
<script lang="ts">
import type { Middleware } from '$lib/types/middlewares';
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { z } from 'zod';
import { onDestroy } from 'svelte';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
const schema = z.object({
maxRequestBodyBytes: z
.union([z.string(), z.number()])
.transform((value) => (value === '' ? null : Number(value)))
.nullish(),
memRequestBodyBytes: z
.union([z.string(), z.number()])
.transform((value) => (value === '' ? null : Number(value)))
.nullish(),
maxResponseBodyBytes: z
.union([z.string(), z.number()])
.transform((value) => (value === '' ? null : Number(value)))
.nullish(),
memResponseBodyBytes: z
.union([z.string(), z.number()])
.transform((value) => (value === '' ? null : Number(value)))
.nullish(),
retryExpression: z.string().trim().nullish()
});
middleware.content = schema.parse({ ...middleware.content });
let errors: Record<any, string[] | undefined> = $state({});
const validate = () => {
try {
middleware.content = schema.parse(middleware.content);
errors = {};
} catch (err) {
if (err instanceof z.ZodError) {
errors = err.flatten().fieldErrors;
}
}
};
onDestroy(() => {
validate();
});
</script>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="max-request-body-bytes" class="text-right">Max Request Body Bytes</Label>
<div class="relative col-span-3">
<Input
id="max-request-body-bytes"
name="max-request-body-bytes"
type="number"
bind:value={middleware.content.maxRequestBodyBytes}
on:input={validate}
placeholder="0"
{disabled}
/>
{#if errors.maxRequestBodyBytes}
<div class="col-span-4 text-right text-sm text-red-500">{errors.maxRequestBodyBytes}</div>
{/if}
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="mem-request-body-bytes" class="text-right">Mem Request Body Bytes</Label>
<div class="relative col-span-3">
<Input
id="mem-request-body-bytes"
name="mem-request-body-bytes"
type="number"
bind:value={middleware.content.memRequestBodyBytes}
on:input={validate}
placeholder="1048576"
{disabled}
/>
{#if errors.memRequestBodyBytes}
<div class="col-span-4 text-right text-sm text-red-500">{errors.memRequestBodyBytes}</div>
{/if}
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="max-response-body-bytes" class="text-right">Max Response Body Bytes</Label>
<div class="relative col-span-3">
<Input
id="max-response-body-bytes"
name="max-response-body-bytes"
type="number"
bind:value={middleware.content.maxResponseBodyBytes}
on:input={validate}
placeholder="0"
{disabled}
/>
{#if errors.maxResponseBodyBytes}
<div class="col-span-4 text-right text-sm text-red-500">{errors.maxResponseBodyBytes}</div>
{/if}
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="mem-response-body-bytes" class="text-right">Mem Response Body Bytes</Label>
<div class="relative col-span-3">
<Input
id="mem-response-body-bytes"
name="mem-response-body-bytes"
type="number"
bind:value={middleware.content.memResponseBodyBytes}
on:input={validate}
placeholder="1048576"
{disabled}
/>
{#if errors.memResponseBodyBytes}
<div class="col-span-4 text-right text-sm text-red-500">{errors.memResponseBodyBytes}</div>
{/if}
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="retry-expression" class="text-right">Retry Expression</Label>
<div class="relative col-span-3">
<Input
id="retry-expression"
name="retry-expression"
type="text"
bind:value={middleware.content.retryExpression}
on:input={validate}
placeholder="IsNetworkError() && Attempts() < 2"
{disabled}
/>
{#if errors.retryExpression}
<div class="col-span-4 text-right text-sm text-red-500">{errors.retryExpression}</div>
{/if}
</div>
</div>
-53
View File
@@ -1,53 +0,0 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label/index.js';
import type { Middleware } from '$lib/types/middlewares';
import * as Select from '$lib/components/ui/select';
import type { Selected } from 'bits-ui';
import { middlewares } from '$lib/api';
import ArrayInput from '../ui/array-input/array-input.svelte';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
// Create a reactive variable for selected middlewares
let selectedMiddlewares: Selected<string>[] | undefined = $state(middleware.content?.middlewares?.map(
(m: string) => ({ value: m, label: m })
));
const changeMiddlewares = (middlewares: Selected<string>[] | undefined) => {
if (!middleware.content) return;
middleware.content.middlewares = middlewares ? middlewares.map((m) => m.value) : [];
selectedMiddlewares = middlewares || [];
};
</script>
{#if disabled}
<ArrayInput
items={middleware.content.middlewares}
label="Middlewares"
placeholder=""
disabled={true}
/>
{:else}
<div class="grid grid-cols-4 items-center gap-4">
<Label for="middlewares" class="text-right">Middlewares</Label>
<div class="col-span-3 space-y-2">
<Select.Root multiple selected={selectedMiddlewares} onSelectedChange={changeMiddlewares}>
<Select.Trigger>
<Select.Value placeholder="Select middlewares to" />
</Select.Trigger>
<Select.Content class="no-scrollbar max-h-[200px] overflow-y-auto">
{#each $middlewares as m}
<Select.Item value={m.name} label={m.name}>
{m.name}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
{/if}
@@ -1,133 +0,0 @@
<script lang="ts">
import type { Middleware } from '$lib/types/middlewares';
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { z } from 'zod';
import { CustomTimeUnitSchemaOptional } from '../utils/validation';
import { onDestroy } from 'svelte';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
const schema = z.object({
expression: z
.string()
.trim()
.min(1, 'Expression is required')
.default('LatencyAtQuantileMS(50.0) > 100'),
checkPeriod: CustomTimeUnitSchemaOptional,
fallbackDuration: CustomTimeUnitSchemaOptional,
recoveryDuration: CustomTimeUnitSchemaOptional,
responseCode: z
.union([z.string(), z.number()])
.transform((value) => (value === '' ? null : Number(value)))
.nullish()
});
middleware.content = schema.parse({ ...middleware.content });
let errors: Record<any, string[] | undefined> = $state({});
const validate = () => {
try {
middleware.content = schema.parse(middleware.content);
errors = {};
} catch (err) {
if (err instanceof z.ZodError) {
errors = err.flatten().fieldErrors;
}
}
};
onDestroy(() => {
validate();
});
</script>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="expression" class="text-right">Expression</Label>
<div class="relative col-span-3">
<Input
id="expression"
name="expression"
type="text"
bind:value={middleware.content.expression}
on:input={validate}
placeholder="LatencyAtQuantileMS(50.0) > 100"
{disabled}
/>
{#if errors.expression}
<div class="col-span-4 text-right text-sm text-red-500">{errors.expression}</div>
{/if}
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="check-period" class="text-right">Check Period</Label>
<div class="relative col-span-3">
<Input
id="check-period"
name="check-period"
type="text"
bind:value={middleware.content.checkPeriod}
on:input={validate}
placeholder="100ms"
{disabled}
/>
{#if errors.checkPeriod}
<div class="col-span-4 text-right text-sm text-red-500">{errors.checkPeriod}</div>
{/if}
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="fallback-duration" class="text-right">Fallback Duration</Label>
<div class="relative col-span-3">
<Input
id="fallback-duration"
name="fallback-duration"
type="text"
bind:value={middleware.content.fallbackDuration}
on:input={validate}
placeholder="10s"
{disabled}
/>
{#if errors.fallbackDuration}
<div class="col-span-4 text-right text-sm text-red-500">{errors.fallbackDuration}</div>
{/if}
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="recovery-duration" class="text-right">Recovery Duration</Label>
<div class="relative col-span-3">
<Input
id="recovery-duration"
name="recovery-duration"
type="text"
bind:value={middleware.content.recoveryDuration}
on:input={validate}
placeholder="10s"
{disabled}
/>
{#if errors.recoveryDuration}
<div class="col-span-4 text-right text-sm text-red-500">{errors.recoveryDuration}</div>
{/if}
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="response-code" class="text-right">Response Code</Label>
<div class="relative col-span-3">
<Input
id="response-code"
name="response-code"
type="number"
bind:value={middleware.content.responseCode}
on:input={validate}
placeholder="503"
{disabled}
/>
{#if errors.responseCode}
<div class="col-span-4 text-right text-sm text-red-500">{errors.responseCode}</div>
{/if}
</div>
</div>
@@ -1,88 +0,0 @@
<script lang="ts">
import type { Middleware } from '$lib/types/middlewares';
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import ArrayInput from '../ui/array-input/array-input.svelte';
import { z } from 'zod';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
const schema = z.object({
minResponseBodyBytes: z
.union([z.string(), z.number()])
.transform((value) => (value === '' ? null : Number(value)))
.nullish(),
defaultEncoding: z.string({ required_error: 'Default Encoding is required' }).trim().optional(),
excludedContentTypes: z
.array(z.string({ required_error: 'Excluded Content Types is required' }).trim())
.optional(),
includeContentTypes: z
.array(z.string({ required_error: 'Include Content Types is required' }).trim())
.optional()
});
middleware.content = schema.parse({ ...middleware.content });
let errors: Record<any, string[] | undefined> = $state({});
const validate = () => {
try {
middleware.content = schema.parse(middleware.content); // Parse the compress object
errors = {};
} catch (err) {
if (err instanceof z.ZodError) {
errors = err.flatten().fieldErrors;
}
}
};
</script>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="min-response-body-bytes" class="text-right">Min Response Body Bytes</Label>
<div class="relative col-span-3">
<Input
id="min-response-body-bytes"
name="min-response-body-bytes"
type="number"
bind:value={middleware.content.minResponseBodyBytes}
on:input={validate}
placeholder="1024"
{disabled}
/>
{#if errors.minResponseBodyBytes}
<div class="col-span-4 text-right text-sm text-red-500">{errors.minResponseBodyBytes}</div>
{/if}
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="default-encoding" class="text-right">Default Encoding</Label>
<div class="relative col-span-3">
<Input
id="default-encoding"
name="default-encoding"
type="text"
bind:value={middleware.content.defaultEncoding}
on:input={validate}
placeholder="gzip"
{disabled}
/>
{#if errors.defaultEncoding}
<div class="col-span-4 text-right text-sm text-red-500">{errors.defaultEncoding}</div>
{/if}
</div>
</div>
<ArrayInput
bind:items={middleware.content.excludedContentTypes}
label="Excluded Content Types"
placeholder="text/event-stream"
{disabled}
/>
<ArrayInput
bind:items={middleware.content.includeContentTypes}
label="Include Content Types"
placeholder="application/json"
{disabled}
/>
@@ -1,67 +0,0 @@
<script lang="ts">
import type { Middleware } from '$lib/types/middlewares';
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import ArrayInput from '../ui/array-input/array-input.svelte';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
</script>
<ArrayInput
bind:items={middleware.content.users}
label="Users"
placeholder="user:password"
helpText="Username and password are separated by a colon. Password will be hashed automatically. You will not be able to see the password again!"
{disabled}
/>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="users-file" class="text-right">Users File</Label>
<Input
id="users-file"
name="users-file"
type="text"
bind:value={middleware.content.usersFile}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="/path/to/my/usersfile"
{disabled}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="realm" class="text-right">Realm</Label>
<Input
id="realm"
name="realm"
type="text"
bind:value={middleware.content.realm}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="traefik"
{disabled}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="remove-header" class="text-right">Remove Header</Label>
<Switch
id="remove-header"
bind:checked={middleware.content.removeHeader}
class="col-span-3"
{disabled}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="header-field" class="text-right">Header Field</Label>
<Input
id="header-field"
name="header-field"
type="text"
bind:value={middleware.content.headerField}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="X-WebAuth-User"
{disabled}
/>
</div>
@@ -1,39 +0,0 @@
<script lang="ts">
import type { Middleware } from '$lib/types/middlewares';
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import ArrayInput from '../ui/array-input/array-input.svelte';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
</script>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="service" class="text-right">Service</Label>
<Input
id="service"
name="service"
type="text"
bind:value={middleware.content.service}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="serviceError"
{disabled}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="query" class="text-right">Query</Label>
<Input
id="query"
name="query"
type="text"
bind:value={middleware.content.query}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="/status.html"
{disabled}
/>
</div>
<ArrayInput bind:items={middleware.content.status} label="Status" placeholder="500" {disabled} />
@@ -1,78 +0,0 @@
<script lang="ts">
import type { Middleware } from '$lib/types/middlewares';
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import ArrayInput from '../ui/array-input/array-input.svelte';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
</script>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="address" class="text-right">Address</Label>
<Input
id="address"
name="address"
type="text"
bind:value={middleware.content.address}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="https://example.com/auth"
{disabled}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="trust-forward-header" class="text-right">Trust Forward Header</Label>
<Switch
id="trust-forward-header"
bind:checked={middleware.content.trustForwardHeader}
class="col-span-3"
{disabled}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="auth-response-headers-regex" class="text-right">Auth Response Headers Regex</Label>
<Input
id="auth-response-headers-regex"
name="auth-response-headers-regex"
type="text"
bind:value={middleware.content.authResponseHeadersRegex}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="^X-"
{disabled}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="auth-request-headers" class="text-right">Auth Request Headers</Label>
<Input
id="auth-request-headers"
name="auth-request-headers"
type="text"
bind:value={middleware.content.authRequestHeaders}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="X-CustomHeader"
{disabled}
/>
</div>
<ArrayInput
bind:items={middleware.content.authResponseHeaders}
label="Auth Response Headers"
placeholder="X-Auth-User"
{disabled}
/>
<ArrayInput
bind:items={middleware.content.authRequestHeaders}
label="Auth Request Headers"
placeholder="Accept"
{disabled}
/>
<ArrayInput
bind:items={middleware.content.addAuthCookiesToResponse}
label="Add Auth Cookies To Response"
placeholder="Session-Cookie"
{disabled}
/>
-400
View File
@@ -1,400 +0,0 @@
<script lang="ts">
import type { Middleware } from '$lib/types/middlewares';
import { Button } from '$lib/components/ui/button/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import ArrayInput from '../ui/array-input/array-input.svelte';
import ObjectInput from '../ui/object-input/object-input.svelte';
import { z } from 'zod';
import { CustomIPSchemaOptional } from '../utils/validation';
interface Props {
middleware: Middleware;
}
let { middleware = $bindable() }: Props = $props();
const emptyHeaders = {
// SSL and Security Headers (commonly used)
sslProxyHeaders: {},
// Security and Privacy Policies (high importance)
contentSecurityPolicy: '',
contentTypeNosniff: false,
browserXssFilter: false,
frameDeny: false,
customFrameOptionsValue: '',
referrerPolicy: '',
permissionsPolicy: '',
// Access Control Headers (important for CORS and security)
accessControlAllowOriginList: [],
accessControlAllowOriginListRegex: [],
accessControlAllowHeaders: [],
accessControlAllowMethods: [],
accessControlAllowCredentials: false,
accessControlExposeHeaders: [],
// STS (HTTP Strict Transport Security)
stsIncludeSubdomains: false,
stsPreload: false,
forceSTSHeader: false,
// Custom Headers (for custom configurations)
customRequestHeaders: {},
customResponseHeaders: {},
// Less frequently used security options
addVaryHeader: false,
allowedHosts: [],
hostsProxyHeaders: [],
publicKey: '',
// Miscellaneous
customBrowserXSSValue: ''
};
const defaultTemplate = {
// SSL and Security Headers
sslProxyHeaders: {
'X-Forwarded-Proto': 'https'
},
// Security and Privacy Policies
contentSecurityPolicy:
"default-src 'self'; script-src 'self'; object-src 'none'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none';", // Mitigates XSS attacks
contentTypeNosniff: true, // Prevents MIME-type sniffing
browserXssFilter: true, // Helps prevent XSS attacks
frameDeny: true, // Denies embedding in iframes
customFrameOptionsValue: '', // Can be set for more granular control
referrerPolicy: 'no-referrer', // Prevents referrer leakage
permissionsPolicy: 'geolocation=(), microphone=(), camera=(), fullscreen=(self)', // Restricts access to sensitive APIs
// Access Control Headers (CORS and Security)
accessControlAllowHeaders: ['Authorization', 'Content-Type'],
accessControlAllowMethods: ['GET', 'POST', 'OPTIONS'],
accessControlAllowCredentials: true, // Allow sending credentials
accessControlExposeHeaders: ['Authorization'],
accessControlMaxAge: 86400, // Cache CORS preflight requests for 1 day
// STS (HTTP Strict Transport Security)
stsSeconds: 31536000, // Enforce HTTPS for 1 year
stsIncludeSubdomains: true, // Apply STS to subdomains
stsPreload: true, // Preload into browsers for STS
forceSTSHeader: true, // Force the STS header
// Custom Headers (for custom configurations)
customResponseHeaders: {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'X-Robots-Tag': 'none,noarchive,nosnippet,notranslate,noimageindex'
},
customRequestHeaders: {
'X-Forwarded-Proto': 'https',
'X-Permitted-Cross-Domain-Policies': 'none'
},
// Less frequently used security options
addVaryHeader: true, // Useful for caching and negotiation
hostsProxyHeaders: ['X-Forwarded-Host']
};
const headersSchema = z.object({
sslProxyHeaders: z.record(z.string(), z.string()).nullish(),
contentSecurityPolicy: z.string().nullish(),
contentTypeNosniff: z.boolean().nullish(),
browserXssFilter: z.boolean().nullish(),
frameDeny: z.boolean().nullish(),
customFrameOptionsValue: z.string().nullish(),
referrerPolicy: z.string().nullish(),
permissionsPolicy: z.string().nullish(),
accessControlAllowOriginList: z.array(CustomIPSchemaOptional).default([]).nullish(),
accessControlAllowOriginListRegex: z.array(z.string()).default([]).nullish(),
accessControlAllowHeaders: z.array(z.string()).default([]).nullish(),
accessControlAllowMethods: z.array(z.string()).default([]).nullish(),
accessControlAllowCredentials: z.boolean().default(false).nullish(),
accessControlExposeHeaders: z.array(z.string()).default([]).nullish(),
accessControlMaxAge: z.coerce.number().int().nonnegative().nullish(),
stsSeconds: z.coerce.number().int().nonnegative().nullish(),
stsIncludeSubdomains: z.boolean().default(false).nullish(),
stsPreload: z.boolean().default(false).nullish(),
forceSTSHeader: z.boolean().default(false).nullish(),
customResponseHeaders: z.record(z.string(), z.string()).nullish(),
customRequestHeaders: z.record(z.string(), z.string()).nullish(),
addVaryHeader: z.boolean().default(true).nullish(),
hostsProxyHeaders: z.array(z.string()).default([]).nullish()
});
middleware.content = headersSchema.parse({ ...middleware.content });
let errors: Record<any, string[] | undefined> = $state({});
const validate = () => {
try {
middleware.content = headersSchema.parse(middleware.content);
errors = {};
} catch (err) {
if (err instanceof z.ZodError) {
errors = err.flatten().fieldErrors;
}
}
};
let isTemplate = $state(false);
const toggleTemplate = () => {
isTemplate = !isTemplate;
if (isTemplate) {
middleware.content = { ...emptyHeaders, ...defaultTemplate };
} else {
middleware.content = { ...emptyHeaders };
}
validate();
};
</script>
<div class="flex items-center justify-end gap-2">
<Button on:click={toggleTemplate}>
{isTemplate ? 'Clear Config' : 'Use Secure Template'}
</Button>
</div>
<!-- SSL and Security Headers (commonly used) -->
<ObjectInput
bind:items={middleware.content.sslProxyHeaders}
label="SSL Proxy Headers"
keyPlaceholder="Header Name"
valuePlaceholder="Header Value"
on:update={validate}
class="my-4"
/>
{#if errors.sslProxyHeaders}
<span class="text-sm text-red-500">{errors.sslProxyHeaders}</span>
{/if}
<!-- Security and Privacy Policies (high importance) -->
<span class="my-4 border-b border-gray-200 pb-2 font-bold">Security and Privacy Policies</span>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="content-security-policy" class="text-right">Content Security Policy</Label>
<Input
id="content-security-policy"
name="content-security-policy"
type="text"
bind:value={middleware.content.contentSecurityPolicy}
on:input={validate}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="default-src 'self'; script-src 'self' 'unsafe-inline';"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="custom-frame-options-value" class="text-right">Custom Frame Options Value</Label>
<Input
id="custom-frame-options-value"
name="custom-frame-options-value"
type="text"
bind:value={middleware.content.customFrameOptionsValue}
on:input={validate}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="SAMEORIGIN"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="referrer-policy" class="text-right">Referrer Policy</Label>
<Input
id="referrer-policy"
name="referrer-policy"
type="text"
bind:value={middleware.content.referrerPolicy}
on:input={validate}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="no-referrer"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="permissions-policy" class="text-right">Permissions Policy</Label>
<Input
id="permissions-policy"
name="permissions-policy"
type="text"
bind:value={middleware.content.permissionsPolicy}
on:input={validate}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="geolocation 'none'; microphone 'none';"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="content-type-no-sniff" class="text-right">Content Type No Sniff</Label>
<Switch
id="content-type-no-snuff"
bind:checked={middleware.content.contentTypeNosniff}
class="col-span-3"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="browser-xss-filter" class="text-right">Browser XSS Filter</Label>
<Switch
id="browser-xss-filter"
bind:checked={middleware.content.browserXssFilter}
class="col-span-3"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="frame-deny" class="text-right">Frame Deny</Label>
<Switch id="frame-deny" bind:checked={middleware.content.frameDeny} class="col-span-3" />
</div>
<!-- Access Control Headers -->
<span class="my-4 border-b border-gray-200 pb-2 font-bold">Access Control Headers</span>
<ArrayInput
bind:items={middleware.content.accessControlAllowOriginList}
placeholder="*"
label="Access Control Allow Origin List"
on:update={validate}
class="my-2"
/>
<ArrayInput
bind:items={middleware.content.accessControlAllowOriginListRegex}
placeholder="example\\.com"
label="Access Control Allow Origin List Regex"
class="my-2"
/>
<ArrayInput
bind:items={middleware.content.accessControlAllowHeaders}
placeholder="Authorization"
label="Access Control Allow Headers"
on:update={validate}
class="my-2"
/>
<ArrayInput
bind:items={middleware.content.accessControlAllowMethods}
placeholder="GET, POST, PUT, DELETE, OPTIONS"
label="Access Control Allow Methods"
on:update={validate}
class="my-2"
/>
<ArrayInput
bind:items={middleware.content.accessControlExposeHeaders}
placeholder="Authorization"
label="Access Control Expose Headers"
on:update={validate}
class="my-2"
/>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="access-control-max-age" class="text-right">Access Control Max Age</Label>
<Input
id="access-control-max-age"
name="access-control-max-age"
type="number"
bind:value={middleware.content.accessControlMaxAge}
on:input={validate}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="0"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="access-control-allow-credentials" class="text-right"
>Access Control Allow Credentials</Label
>
<Switch
id="access-control-allow-credentials"
bind:checked={middleware.content.accessControlAllowCredentials}
class="col-span-3"
/>
</div>
<!-- STS -->
<span class="my-4 border-b border-gray-200 pb-2 font-bold">Strict Transport Security (STS)</span>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="sts-seconds" class="text-right">STS Seconds</Label>
<Input
id="sts-seconds"
name="sts-seconds"
type="number"
bind:value={middleware.content.stsSeconds}
on:input={validate}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="86400"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="sts-include-sub-domains" class="text-right">STS Include Sub Domains</Label>
<Switch
id="sts-include-sub-domains"
bind:checked={middleware.content.stsIncludeSubdomains}
class="col-span-3"
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="sts-pre-load" class="text-right">STS Pre Load</Label>
<Switch id="sts-pre-load" bind:checked={middleware.content.stsPreload} class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="force-sts-header" class="text-right">Force STS Header</Label>
<Switch
id="force-sts-header"
bind:checked={middleware.content.forceSTSHeader}
class="col-span-3"
/>
</div>
<!-- Custom Headers -->
<span class="my-4 border-b border-gray-200 pb-2 font-bold">Custom Headers</span>
<ObjectInput
bind:items={middleware.content.customResponseHeaders}
label="Custom Response Headers"
keyPlaceholder="Header Name"
valuePlaceholder="Header Value"
on:update={validate}
class="my-2"
/>
<ObjectInput
bind:items={middleware.content.customRequestHeaders}
label="Custom Request Headers"
keyPlaceholder="Header Name"
valuePlaceholder="Header Value"
on:update={validate}
class="my-2"
/>
<!-- Less frequently used headers -->
<span class="my-4 border-b border-gray-200 pb-2 font-bold">Various extra headers</span>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="add-vary-header" class="text-right">Add Vary Header</Label>
<Switch id="add-vary-header" bind:checked={middleware.content.addVaryHeader} class="col-span-3" />
</div>
<ArrayInput
bind:items={middleware.content.allowedHosts}
placeholder="example.com"
label="Allowed Hosts"
on:update={validate}
class="my-2"
/>
<ArrayInput
bind:items={middleware.content.hostsProxyHeaders}
placeholder="X-Forwarded-Host"
label="Hosts Proxy Headers"
on:update={validate}
class="my-2"
/>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="public-key" class="text-right">Public Key</Label>
<Input
id="public-key"
name="public-key"
type="text"
bind:value={middleware.content.publicKey}
on:input={validate}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="MIIBIjANBgkqhkiG9w0BAQEFAA..."
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="custom-browser-xss-value" class="text-right">Custom Browser XSS Value</Label>
<Input
id="custom-browser-xss-value"
name="custom-browser-xss-value"
type="text"
bind:value={middleware.content.customBrowserXSSValue}
on:input={validate}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="1; mode=block"
/>
</div>
@@ -1,56 +0,0 @@
<script lang="ts">
import type { Middleware } from '$lib/types/middlewares';
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { z } from 'zod';
import { onDestroy } from 'svelte';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
const schema = z.object({
amount: z
.union([z.string(), z.number()])
.transform((value) => (value === '' ? null : Number(value)))
.nullish()
});
middleware.content = schema.parse({ ...middleware.content });
let errors: Record<any, string[] | undefined> = $state({});
const validate = () => {
try {
middleware.content = schema.parse(middleware.content);
errors = {};
} catch (err) {
if (err instanceof z.ZodError) {
errors = err.flatten().fieldErrors;
}
}
};
onDestroy(() => {
validate();
});
</script>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="amount" class="text-right">Amount</Label>
<div class="relative col-span-3">
<Input
id="amount"
name="amount"
type="number"
bind:value={middleware.content.amount}
on:input={validate}
placeholder="50"
{disabled}
/>
{#if errors.amount}
<div class="col-span-4 text-right text-sm text-red-500">{errors.amount}</div>
{/if}
</div>
</div>
@@ -1,129 +0,0 @@
<script lang="ts">
import type { Middleware } from '$lib/types/middlewares';
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import ArrayInput from '../ui/array-input/array-input.svelte';
import { z } from 'zod';
import { CustomIPSchemaOptional } from '../utils/validation';
import { onDestroy } from 'svelte';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
const schema = z.object({
amount: z
.union([z.string(), z.number()])
.transform((value) => (value === '' ? null : Number(value)))
.nullish(),
sourceCriterion: z
.object({
ipStrategy: z
.object({
depth: z
.union([z.string(), z.number()])
.transform((value) => (value === '' ? null : Number(value)))
.nullish(),
excludedIPs: z.array(CustomIPSchemaOptional).nullish()
})
.default({}),
requestHeaderName: z.string().trim().nullish(),
requestHost: z.boolean().nullish()
})
.default({})
});
middleware.content = schema.parse({ ...middleware.content });
let errors: Record<any, string[] | undefined> = $state({});
const validate = () => {
try {
middleware.content = schema.parse(middleware.content); // Parse the inFlightReq object
errors = {};
} catch (err) {
if (err instanceof z.ZodError) {
errors = err.flatten().fieldErrors;
}
}
};
onDestroy(() => {
validate();
});
</script>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="amount" class="text-right">Amount</Label>
<div class="relative col-span-3">
<Input
id="amount"
name="amount"
type="number"
bind:value={middleware.content.amount}
on:input={validate}
placeholder="50"
{disabled}
/>
{#if errors.amount}
<div class="col-span-4 text-right text-sm text-red-500">{errors.amount}</div>
{/if}
</div>
</div>
<header class="border-b border-gray-200 py-2 font-bold">Source Criterion</header>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="depth" class="text-right">Depth</Label>
<div class="relative col-span-3">
<Input
id="depth"
name="depth"
type="number"
bind:value={middleware.content.sourceCriterion.ipStrategy.depth}
on:input={validate}
placeholder="0"
{disabled}
/>
{#if errors.ipStrategy}
<div class="col-span-4 text-right text-sm text-red-500">{errors.ipStrategy}</div>
{/if}
</div>
</div>
<div class="grid grid-cols-4 items-center gap-2">
<Label for="request-header-name" class="text-right">Request Header Name</Label>
<div class="relative col-span-3">
<Input
id="request-header-name"
name="request-header-name"
type="text"
bind:value={middleware.content.sourceCriterion.requestHeaderName}
on:input={validate}
placeholder="X-CustomHeader"
{disabled}
/>
{#if errors.requestHeaderName}
<div class="col-span-4 text-right text-sm text-red-500">{errors.requestHeaderName}</div>
{/if}
</div>
</div>
<div class="grid grid-cols-4 items-center gap-2">
<Label for="request-host" class="text-right">Request Host</Label>
<Switch
id="request-host"
bind:checked={middleware.content.sourceCriterion.requestHost}
class="col-span-3"
{disabled}
/>
</div>
<ArrayInput
bind:items={middleware.content.sourceCriterion.ipStrategy.excludedIPs}
label="Excluded IPs"
placeholder="192.168.1.1"
on:update={validate}
{disabled}
/>
{#if errors.sourceCriterion}
<div class="col-span-4 text-right text-sm text-red-500">{errors.sourceCriterion}</div>
{/if}
@@ -1,107 +0,0 @@
<script lang="ts">
import type { Middleware } from '$lib/types/middlewares';
import { Button } from '$lib/components/ui/button/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import ArrayInput from '../ui/array-input/array-input.svelte';
import { z } from 'zod';
import { CustomIPSchema, CustomIPSchemaOptional } from '../utils/validation';
import { onDestroy } from 'svelte';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
const templateRange = ['192.168.0.0/16', '172.16.0.0/12', '127.0.0.1/32', '10.0.0.0/8'];
const ipAllowListSchema = z.object({
sourceRange: z.array(CustomIPSchema).default([]),
ipStrategy: z
.object({
depth: z
.union([z.string(), z.number()])
.transform((value) => (value === '' ? null : Number(value)))
.nullish(),
excludedIPs: z.array(CustomIPSchemaOptional).nullish()
})
.default({})
});
middleware.content = ipAllowListSchema.parse({ ...middleware.content });
let errors: Record<any, string[] | undefined> = $state({});
const validate = () => {
try {
middleware.content = ipAllowListSchema.parse(middleware.content);
errors = {};
} catch (err) {
if (err instanceof z.ZodError) {
errors = err.flatten().fieldErrors;
}
}
};
let isTemplate = $state(middleware.content?.sourceRange?.length > 0);
const toggleIpAllowList = () => {
isTemplate = !isTemplate;
if (isTemplate) {
middleware.content.sourceRange = templateRange;
} else {
middleware.content.sourceRange = [];
}
};
onDestroy(() => {
validate();
});
</script>
<div class="flex items-center justify-end gap-2">
<Button on:click={toggleIpAllowList}>
{isTemplate ? 'Clear IPs' : 'Add Private IP range'}
</Button>
</div>
<ArrayInput
bind:items={middleware.content.sourceRange}
label="Source Range"
placeholder="192.168.1.1/32"
on:update={validate}
{disabled}
/>
{#if errors.sourceRange}
<div class="col-span-4 text-right text-sm text-red-500">{errors.sourceRange}</div>
{/if}
{#if middleware.content.ipStrategy && middleware.protocol === 'http'}
<div class="grid grid-cols-4 items-center gap-4">
<Label for="depth" class="text-right">Depth</Label>
<div class="relative col-span-3">
<Input
id="depth"
name="depth"
type="text"
bind:value={middleware.content.ipStrategy.depth}
on:input={validate}
placeholder="0"
{disabled}
/>
{#if errors.ipStrategy}
<div class="col-span-4 text-right text-sm text-red-500">{errors.ipStrategy}</div>
{/if}
</div>
</div>
<ArrayInput
bind:items={middleware.content.ipStrategy.excludedIPs}
label="Excluded IPs"
placeholder="192.168.1.1"
on:update={validate}
{disabled}
/>
{#if errors.ipStrategy}
<div class="col-span-4 text-right text-sm text-red-500">{errors.ipStrategy}</div>
{/if}
{/if}
@@ -1,145 +0,0 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import * as Select from '$lib/components/ui/select';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import { profile } from '$lib/api';
import type { Middleware } from '$lib/types/middlewares';
import logo from '$lib/images/logo.svg';
import Testform from './testform.svelte';
import type { Component, SvelteComponent } from 'svelte';
interface Props {
middleware: Middleware;
mode: 'create' | 'edit';
disabled?: boolean;
}
let { middleware = $bindable({} as Middleware), mode, disabled = false }: Props = $props();
// Computed properties
let middlewareProvider = $derived(middleware.name ? middleware.name.split('@')[1] : 'http');
let isHttpProvider = $derived(middlewareProvider === 'http' || !middlewareProvider);
const middlewareTypes: Record<string, { value: string; label: string; form: any | undefined }[]> =
{
http: [
{ value: 'rateLimit', label: 'Rate Limit', form: Testform },
{ value: 'headers', label: 'Headers' }
// { value: 'compress', label: 'Compress' },
// { value: 'retry', label: 'Retry' },
// { value: 'ipAllowList', label: 'Whitelist' },
// { value: 'basicAuth', label: 'Basic Auth' },
// { value: 'forwardAuth', label: 'Forward Auth' },
// { value: 'digestAuth', label: 'Digest Auth' },
// { value: 'chain', label: 'Chain' },
// { value: 'redirectScheme', label: 'Redirect Scheme' },
// { value: 'redirectRegex', label: 'Redirect Regex' },
// { value: 'addPrefix', label: 'Add Prefix' },
// { value: 'stripPrefix', label: 'Strip Prefix' },
// { value: 'stripPrefixRegex', label: 'Strip Prefix Regex' },
// { value: 'replacePath', label: 'Replace Path' },
// { value: 'replacePathRegex', label: 'Replace Path Regex' },
// { value: 'inFlightReq', label: 'InFlightReq' },
// { value: 'circuitBreaker', label: 'Circuit Breaker' },
// { value: 'buffering', label: 'Buffering' },
// { value: 'errors', label: 'Errors' },
// { value: 'passTLSClientCert', label: 'Pass TLS Client Cert' },
// { value: 'plugin', label: 'Plugin' }
],
tcp: [
{ value: 'inFlightConn', label: 'InFlightConn', form: Testform },
{ value: 'ipAllowList', label: 'Whitelist', form: null }
]
};
let isHTTP = $state(middleware.protocol === 'http');
let MiddlewareForm = $state(null);
async function handleMiddlewareTypeChange(value: string) {
if (!value || !$profile.id) return;
middleware.type = isHTTP ? 'http' : 'tcp';
middleware.protocol = isHTTP ? 'http' : 'tcp';
MiddlewareForm = middlewareTypes[isHTTP ? 'http' : 'tcp'].find((t) => t.value === value)?.form;
}
</script>
<Card.Root class="mt-4">
<Card.Header>
<Card.Title>{mode === 'create' ? 'Add' : 'Update'} Middleware</Card.Title>
<Card.Description>
{mode === 'create' ? 'Create a new middleware' : 'Edit existing middleware'}
</Card.Description>
</Card.Header>
<Card.Content class="flex flex-col gap-4">
<!-- Protocol Switch -->
{#if isHttpProvider}
<div class="flex items-center justify-end gap-2 pb-2">
<Switch id="protocol-type" bind:checked={isHTTP} />
<Label for="protocol-type">{isHTTP ? 'HTTP' : 'TCP'}</Label>
</div>
{/if}
<!-- Type Selector -->
<div class="grid grid-cols-4 items-center gap-2">
<Label for="type" class="text-right">Type</Label>
<Select.Root type="single" value={middleware.type} onValueChange={handleMiddlewareTypeChange}>
<Select.Trigger class="col-span-3">
{middleware.type
? middlewareTypes[isHTTP ? 'http' : 'tcp'].find((t) => t.value === middleware.type)
?.label
: 'Select type'}
</Select.Trigger>
<Select.Content>
{#each middlewareTypes[isHTTP ? 'http' : 'tcp'] as type}
<Select.Item value={type.value}>
{type.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Name Input -->
<div class="grid grid-cols-4 items-center gap-2">
<Label for="name" class="text-right">Name</Label>
<div class="relative col-span-3">
<Input
id="name"
bind:value={middleware.name}
placeholder="Middleware name"
{disabled}
required
/>
{#if middlewareProvider}
<span
class="pointer-events-none absolute inset-y-0 right-3 flex items-center text-gray-400"
>
{#if isHttpProvider}
<img src={logo} alt="HTTP" width="20" />
{:else if middlewareProvider.includes('docker')}
<iconify-icon icon="logos:docker-icon" height="20"></iconify-icon>
{:else if middlewareProvider.includes('kubernetes')}
<iconify-icon icon="logos:kubernetes" height="20"></iconify-icon>
{:else if middlewareProvider === 'consul'}
<iconify-icon icon="logos:consul" height="20"></iconify-icon>
{:else if middlewareProvider === 'nomad'}
<iconify-icon icon="logos:nomad-icon" height="20"></iconify-icon>
{:else if middlewareProvider === 'kv'}
<iconify-icon icon="logos:redis" height="20"></iconify-icon>
{/if}
</span>
{/if}
</div>
</div>
<!-- Dynamic Form -->
{#if MiddlewareForm}
<div class="mt-6 flex flex-col gap-2">
<MiddlewareForm bind:middleware {disabled} />
</div>
{/if}
</Card.Content>
</Card.Root>
@@ -1,17 +0,0 @@
<script lang="ts">
import type { Middleware } from '$lib/types/middlewares';
import { Label } from '$lib/components/ui/label/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
</script>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="pem" class="text-right">Pem</Label>
<Switch id="remove-header" bind:checked={middleware.content.pem} class="col-span-3" {disabled} />
</div>
@@ -1,51 +0,0 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import type { Middleware } from '$lib/types/middlewares';
import { Label } from '$lib/components/ui/label/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
let pluginData = $state('{}');
function extractInnerPluginData() {
if (!middleware.content) return;
pluginData = JSON.stringify(middleware.content, null, 2) || '{}';
}
run(() => {
middleware.content, extractInnerPluginData();
});
let error = $state('');
function validateJSON() {
if (!pluginData || !middleware.content) return;
try {
JSON.parse(pluginData);
middleware.content = JSON.parse(pluginData);
error = '';
} catch (e: any) {
error = e;
}
}
</script>
<div class="grid grid-cols-8 items-center gap-2">
<Label for="config" class="text-right">Config</Label>
<Textarea
id="config"
name="config"
rows={pluginData ? pluginData.split('\n').length + 1 : 3}
bind:value={pluginData}
on:input={validateJSON}
class="col-span-7 max-h-[500px] overflow-y-auto"
{disabled}
/>
{#if error}
<div class="col-span-4 text-right text-sm text-red-500">{error}</div>
{/if}
</div>
@@ -1,169 +0,0 @@
<script lang="ts">
import type { Middleware } from '$lib/types/middlewares';
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import ArrayInput from '../ui/array-input/array-input.svelte';
import { CustomIPSchemaOptional } from '../utils/validation';
import { z } from 'zod';
import { onDestroy } from 'svelte';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
const schema = z.object({
period: z.string({ required_error: 'Period is required' }).trim().default('1s'),
average: z
.union([z.string(), z.number()])
.transform((value) => (value === '' ? null : Number(value)))
.nullish(),
burst: z
.union([z.string(), z.number()])
.transform((value) => (value === '' ? null : Number(value)))
.nullish(),
sourceCriterion: z
.object({
ipStrategy: z
.object({
depth: z
.union([z.string(), z.number()])
.transform((value) => (value === '' ? null : Number(value)))
.nullish(),
excludedIPs: z.array(CustomIPSchemaOptional).nullish()
})
.default({}),
requestHeaderName: z.string().trim().nullish(),
requestHost: z.boolean().nullish()
})
.default({})
});
middleware.content = schema.parse({ ...middleware.content });
let errors: Record<any, string[] | undefined> = $state({});
const validate = () => {
try {
middleware.content = schema.parse(middleware.content); // Parse the rateLimit object
errors = {};
} catch (err) {
if (err instanceof z.ZodError) {
errors = err.flatten().fieldErrors;
}
}
};
onDestroy(() => {
validate();
});
</script>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="average" class="text-right">Average</Label>
<div class="relative col-span-3">
<Input
id="average"
name="average"
type="number"
bind:value={middleware.content.average}
on:input={validate}
placeholder="0"
{disabled}
/>
{#if errors.average}
<div class="col-span-4 text-right text-sm text-red-500">{errors.average}</div>
{/if}
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="period" class="text-right">Period</Label>
<div class="relative col-span-3">
<Input
id="period"
name="period"
type="text"
bind:value={middleware.content.period}
on:input={validate}
placeholder="1s"
{disabled}
/>
{#if errors.period}
<div class="col-span-4 text-right text-sm text-red-500">{errors.period}</div>
{/if}
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="burst" class="text-right">Burst</Label>
<div class="relative col-span-3">
<Input
id="burst"
name="burst"
type="number"
bind:value={middleware.content.burst}
on:input={validate}
placeholder="1"
{disabled}
/>
{#if errors.burst}
<div class="col-span-4 text-right text-sm text-red-500">{errors.burst}</div>
{/if}
</div>
</div>
<header class="border-b border-gray-200 py-2 font-bold">Source Criterion</header>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="depth" class="text-right">Depth</Label>
<div class="relative col-span-3">
<Input
id="depth"
name="depth"
type="number"
bind:value={middleware.content.sourceCriterion.ipStrategy.depth}
placeholder="0"
on:input={validate}
{disabled}
/>
{#if errors.ipStrategy}
<div class="col-span-4 text-right text-sm text-red-500">{errors.ipStrategy}</div>
{/if}
</div>
</div>
<div class="grid grid-cols-4 items-center gap-2">
<Label for="request-header-name" class="text-right">Request Header Name</Label>
<div class="relative col-span-3">
<Input
id="request-header-name"
name="request-header-name"
type="text"
bind:value={middleware.content.sourceCriterion.requestHeaderName}
on:input={validate}
placeholder="username"
{disabled}
/>
{#if errors.requestHeaderName}
<div class="col-span-4 text-right text-sm text-red-500">{errors.requestHeaderName}</div>
{/if}
</div>
</div>
<div class="grid grid-cols-4 items-center gap-2">
<Label for="request-host" class="text-right">Request Host</Label>
<Switch
id="request-host"
bind:checked={middleware.content.sourceCriterion.requestHost}
class="col-span-3"
{disabled}
/>
</div>
<ArrayInput
bind:items={middleware.content.sourceCriterion.ipStrategy.excludedIPs}
label="Excluded IPs"
placeholder="192.168.1.1"
on:update={validate}
{disabled}
/>
{#if errors.sourceCriterion}
<div class="col-span-4 text-right text-sm text-red-500">{errors.sourceCriterion}</div>
{/if}
@@ -1,47 +0,0 @@
<script lang="ts">
import type { Middleware } from '$lib/types/middlewares';
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
</script>
<div class="grid grid-cols-4 items-center gap-2">
<Label for="permanent" class="text-right">Permanent</Label>
<Switch
id="permanent"
bind:checked={middleware.content.permanent}
class="col-span-3"
{disabled}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="regex" class="text-right">Regex</Label>
<Input
id="regex"
name="regex"
type="text"
bind:value={middleware.content.regex}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="^http://localhost/(.*)"
{disabled}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="replacement" class="text-right">Replacement</Label>
<Input
id="replacement"
name="replacement"
type="text"
bind:value={middleware.content.replacement}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="http://mydomain/$1"
{disabled}
/>
</div>
@@ -1,47 +0,0 @@
<script lang="ts">
import type { Middleware } from '$lib/types/middlewares';
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
</script>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="permanent" class="text-right">Permanent</Label>
<Switch
id="permanent"
bind:checked={middleware.content.permanent}
class="col-span-3"
{disabled}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="scheme" class="text-right">Scheme</Label>
<Input
id="scheme"
name="scheme"
type="text"
bind:value={middleware.content.scheme}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="https"
{disabled}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="port" class="text-right">Port</Label>
<Input
id="port"
name="port"
type="text"
bind:value={middleware.content.port}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="443"
{disabled}
/>
</div>
@@ -1,25 +0,0 @@
<script lang="ts">
import type { Middleware } from '$lib/types/middlewares';
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
</script>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="path" class="text-right">Path</Label>
<Input
id="path"
name="path"
type="text"
bind:value={middleware.content.path}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="/foo"
{disabled}
/>
</div>
@@ -1,37 +0,0 @@
<script lang="ts">
import type { Middleware } from '$lib/types/middlewares';
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
</script>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="regex" class="text-right">Regex</Label>
<Input
id="regex"
name="regex"
type="text"
bind:value={middleware.content.regex}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="^/foo/(.*)"
{disabled}
/>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="replacement" class="text-right">Replacement</Label>
<Input
id="replacement"
name="replacement"
type="text"
bind:value={middleware.content.replacement}
class="col-span-3 focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="/bar/$1"
{disabled}
/>
</div>
-71
View File
@@ -1,71 +0,0 @@
<script lang="ts">
import type { Middleware } from '$lib/types/middlewares';
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { z } from 'zod';
import { CustomTimeUnitSchemaOptional } from '../utils/validation';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
const retrySchema = z.object({
attempts: z
.union([z.string(), z.number()])
.transform((value) => (value === '' ? null : Number(value)))
.nullish(),
initialInterval: CustomTimeUnitSchemaOptional
});
middleware.content = retrySchema.parse({ ...middleware.content });
let errors: Record<any, string[] | undefined> = $state({});
const validate = () => {
try {
middleware.content = retrySchema.parse(middleware.content);
errors = {};
} catch (err) {
if (err instanceof z.ZodError) {
errors = err.flatten().fieldErrors;
}
}
};
</script>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="attempts" class="text-right">Attempts</Label>
<div class="relative col-span-3">
<Input
id="attempts"
name="attempts"
type="number"
bind:value={middleware.content.attempts}
on:input={validate}
placeholder="3"
min="0"
{disabled}
/>
{#if errors.attempts}
<div class="col-span-4 text-right text-sm text-red-500">{errors.attempts}</div>
{/if}
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="initial-interval" class="text-right">Initial Interval</Label>
<div class="relative col-span-3">
<Input
id="initial-interval"
name="initial-interval"
type="text"
bind:value={middleware.content.initialInterval}
on:input={validate}
placeholder="100ms"
{disabled}
/>
{#if errors.initialInterval}
<div class="col-span-4 text-right text-sm text-red-500">{errors.initialInterval}</div>
{/if}
</div>
</div>
@@ -1,29 +0,0 @@
<script lang="ts">
import type { Middleware } from '$lib/types/middlewares';
import { Label } from '$lib/components/ui/label/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import ArrayInput from '../ui/array-input/array-input.svelte';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
</script>
<div class="grid grid-cols-4 items-center gap-4">
<Label for="force-slash" class="text-right">Force Slash</Label>
<Switch
id="force-slash"
bind:checked={middleware.content.forceSlash}
class="col-span-3"
{disabled}
/>
</div>
<ArrayInput
bind:items={middleware.content.prefixes}
label="Prefixes"
placeholder="/foo"
{disabled}
/>
@@ -1,18 +0,0 @@
<script lang="ts">
import type { Middleware } from '$lib/types/middlewares';
import ArrayInput from '../ui/array-input/array-input.svelte';
interface Props {
middleware: Middleware;
disabled?: boolean;
}
let { middleware = $bindable(), disabled = false }: Props = $props();
</script>
<ArrayInput
bind:items={middleware.content.regex}
label="Regex"
placeholder="/foo/[a-z0-9]+/[0-9]+/"
{disabled}
/>
+8
View File
@@ -131,3 +131,11 @@ export interface Plugin {
snippet: Record<string, string>;
createdAt: string;
}
export interface BackupMetadata {
filename: string;
size: number;
created: string;
dbType: string;
version: string;
}
+69 -14
View File
@@ -1,26 +1,17 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import { Input } from '$lib/components/ui/input';
import { Separator } from '$lib/components/ui/separator';
import { SaveIcon, Settings } from 'lucide-svelte';
import { settings, api } from '$lib/api';
import { Download, List, SaveIcon, Settings, Upload } from 'lucide-svelte';
import { settings, api, backups, loading } from '$lib/api';
import { onMount } from 'svelte';
import type { Setting } from '$lib/types';
import { toast } from 'svelte-sonner';
// State management
// let fileInput = $state<HTMLInputElement>();
// async function handleFileUpload(event: Event) {
// const file = (event.target as HTMLInputElement).files?.[0];
// if (file) {
// await uploadBackup(file);
// fileInput.value = '';
// }
// }
import { DateFormat } from '$lib/store';
let hasChanges = $state(false);
let changedValues = $state<Record<string, Setting['value']>>({});
@@ -92,8 +83,18 @@
if (e.key === 'Enter') saveSetting(key, value);
}
// Backup handling
let fileInput: HTMLInputElement;
let showBackupList = $state(false);
function humanFileSize(size: number) {
var i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return +(size / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i];
}
onMount(async () => {
await api.listSettings();
await api.listBackups();
});
</script>
@@ -101,7 +102,39 @@
<title>Settings</title>
</svelte:head>
<div class="container">
<div class="container flex flex-col gap-6">
<Card.Root>
<Card.Header>
<Card.Title>Backup Management</Card.Title>
<Card.Description>Download or restore database backups</Card.Description>
</Card.Header>
<Card.Content>
<div class="flex items-center gap-4">
<Button onclick={() => api.downloadBackup()} variant="outline">
<Download class="mr-2 size-4" />
Download Backup
</Button>
<Button variant="outline" onclick={() => fileInput?.click()} disabled={$loading}>
<Upload class="mr-2 size-4" />
{$loading ? 'Uploading...' : 'Restore Backup'}
</Button>
<input
type="file"
accept=".sql,.db"
class="hidden"
bind:this={fileInput}
onchange={(e) => api.restoreBackup(e.currentTarget.files)}
/>
<Button variant="outline" onclick={() => (showBackupList = true)}>
<List class="mr-2 size-4" />
View Backups
</Button>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="mb-3">
@@ -169,3 +202,25 @@
</Card.Content>
</Card.Root>
</div>
<Dialog.Root bind:open={showBackupList}>
<Dialog.Content class="max-w-[600px]">
<Dialog.Header>
<Dialog.Title>Available Backups</Dialog.Title>
</Dialog.Header>
<div class="flex flex-col gap-2">
{#each $backups as backup}
<Button variant="link" class="flex items-center justify-between p-3">
<span class="font-mono text-sm">
Backup:
{DateFormat.format(new Date(backup.created))}
</span>
<span class="font-mono text-sm">{humanFileSize(backup.size)}</span>
</Button>
{/each}
{#if $backups.length === 0}
<p class="text-center text-sm text-muted-foreground">No backups available</p>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>