Files
web-app-launcher/src/lib/utils/validators.ts
T
alexei.dolgolyov 555ac9ea63 feat(backup): tar.gz format with uploads + manifest, restore guard
- New tar.gz backup format bundling SQLite snapshot + uploads tree + manifest.json (version, app+schema versions, checksums, dbSize)
- BACKUPS_DIR env override; defaults to /app/data/backups in prod, <cwd>/data/backups in dev (matches uploads convention)
- 503 guard in hooks.server.ts while restore is mid-flight (DB file is being swapped); excludes static assets + /api/health; sets Retry-After: 15
- Legacy .db restore still supported (DB-only)
- Restore endpoint adds schema-mismatch detection + force flag; download/schedule endpoints updated
- 256 MiB free-disk safety margin before backup
- tar dep added to package.json; 18 new backupService tests
- i18n labels (en + ru) for new restore/format states
2026-05-28 14:39:24 +03:00

489 lines
15 KiB
TypeScript

import { z } from 'zod';
import {
UserRole,
AuthMode,
WidgetType,
IconType,
PermissionLevel,
EntityType,
TargetType,
HealthcheckMethod,
CardSize,
NotificationType,
ApiTokenScope,
AuditAction,
DEFAULTS
} from './constants.js';
// --- Auth ---
const COMMON_PASSWORDS = new Set([
'password',
'password1',
'12345678',
'qwerty',
'qwerty123',
'letmein',
'welcome',
'admin',
'administrator',
'changeme',
'iloveyou',
'monkey'
]);
function passwordPolicy(min: number) {
return z
.string()
.min(min, `Password must be at least ${min} characters`)
.max(DEFAULTS.MAX_PASSWORD_LENGTH, `Password must be at most ${DEFAULTS.MAX_PASSWORD_LENGTH} characters`)
.refine((p) => !COMMON_PASSWORDS.has(p.toLowerCase()), {
message: 'Password is too common'
});
}
export const userPasswordSchema = passwordPolicy(DEFAULTS.MIN_PASSWORD_LENGTH);
export const adminPasswordSchema = passwordPolicy(DEFAULTS.MIN_ADMIN_PASSWORD_LENGTH);
/**
* URL schema that only accepts http:// or https://. Use this everywhere we
* accept a URL the server will later fetch or the browser will navigate to,
* to block javascript:/data:/file: vectors.
*/
export const httpUrlSchema = z
.string()
.url('Invalid URL')
.refine(
(u) => {
try {
const p = new URL(u).protocol;
return p === 'http:' || p === 'https:';
} catch {
return false;
}
},
{ message: 'Only http(s) URLs are allowed' }
);
export const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(1, 'Password is required').max(DEFAULTS.MAX_PASSWORD_LENGTH),
rememberMe: z.boolean().optional().default(false)
});
export const registerSchema = z.object({
email: z.string().email('Invalid email address'),
password: userPasswordSchema,
displayName: z.string().min(1, 'Display name is required').max(100)
});
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1).max(DEFAULTS.MAX_PASSWORD_LENGTH),
newPassword: userPasswordSchema
});
// --- User ---
export const createUserSchema = z.object({
email: z.string().email('Invalid email address'),
password: userPasswordSchema.optional(),
displayName: z.string().min(1).max(100),
avatarUrl: z.string().url().optional(),
authProvider: z.enum([AuthMode.LOCAL, AuthMode.OAUTH]).optional(),
role: z.enum([UserRole.ADMIN, UserRole.USER]).optional()
});
export const updateUserSchema = z.object({
displayName: z.string().min(1).max(100).optional(),
avatarUrl: z.string().url().nullable().optional(),
role: z.enum([UserRole.ADMIN, UserRole.USER]).optional(),
onboardingComplete: z.boolean().optional(),
trackRecentApps: z.boolean().optional()
});
// --- Group ---
export const createGroupSchema = z.object({
name: z.string().min(1, 'Group name is required').max(100),
description: z.string().max(500).optional(),
isDefault: z.boolean().optional()
});
export const updateGroupSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).nullable().optional(),
isDefault: z.boolean().optional()
});
// --- App ---
export const createAppSchema = z.object({
name: z.string().min(1, 'App name is required').max(200),
url: httpUrlSchema,
icon: z.string().max(500).optional(),
iconType: z.enum([IconType.LUCIDE, IconType.SIMPLE, IconType.URL, IconType.EMOJI]).optional(),
description: z.string().max(1000).optional(),
category: z.string().max(100).optional(),
tags: z.string().max(500).optional(),
healthcheckEnabled: z.boolean().optional(),
healthcheckInterval: z.number().int().min(30).max(86400).optional(),
healthcheckMethod: z.enum([HealthcheckMethod.GET, HealthcheckMethod.HEAD]).optional(),
healthcheckExpectedStatus: z.number().int().min(100).max(599).optional(),
healthcheckTimeout: z.number().int().min(1000).max(30000).optional(),
integrationType: z.string().max(50).nullable().optional(),
integrationConfig: z.string().max(10000).nullable().optional(),
integrationEnabled: z.boolean().optional()
});
export const updateAppSchema = z.object({
name: z.string().min(1).max(200).optional(),
url: httpUrlSchema.optional(),
icon: z.string().max(500).nullable().optional(),
iconType: z.enum([IconType.LUCIDE, IconType.SIMPLE, IconType.URL, IconType.EMOJI]).optional(),
description: z.string().max(1000).nullable().optional(),
category: z.string().max(100).nullable().optional(),
tags: z.string().max(500).optional(),
healthcheckEnabled: z.boolean().optional(),
healthcheckInterval: z.number().int().min(30).max(86400).optional(),
healthcheckMethod: z.enum([HealthcheckMethod.GET, HealthcheckMethod.HEAD]).optional(),
healthcheckExpectedStatus: z.number().int().min(100).max(599).optional(),
healthcheckTimeout: z.number().int().min(1000).max(30000).optional(),
integrationType: z.string().max(50).nullable().optional(),
integrationConfig: z.string().max(10000).nullable().optional(),
integrationEnabled: z.boolean().optional()
});
// --- Board ---
export const createBoardSchema = z.object({
name: z.string().min(1, 'Board name is required').max(200),
icon: z.string().max(500).optional(),
description: z.string().max(1000).optional(),
isDefault: z.boolean().optional(),
isGuestAccessible: z.boolean().optional(),
backgroundConfig: z.string().optional()
});
export const updateBoardSchema = z.object({
name: z.string().min(1).max(200).optional(),
icon: z.string().max(500).nullable().optional(),
description: z.string().max(1000).nullable().optional(),
isDefault: z.boolean().optional(),
isGuestAccessible: z.boolean().optional(),
backgroundConfig: z.string().nullable().optional(),
themeHue: z.number().int().min(0).max(360).nullable().optional(),
themeSaturation: z.number().int().min(0).max(100).nullable().optional(),
backgroundType: z
.enum(['mesh', 'particles', 'aurora', 'wallpaper', 'none'])
.nullable()
.optional(),
cardSize: z.enum([CardSize.COMPACT, CardSize.MEDIUM, CardSize.LARGE]).nullable().optional(),
wallpaperUrl: z.string().url().max(2000).nullable().optional(),
wallpaperBlur: z.number().int().min(0).max(50).nullable().optional(),
wallpaperOverlay: z.number().min(0).max(1).nullable().optional(),
customCss: z.string().max(10000).nullable().optional()
});
// --- Section ---
export const createSectionSchema = z.object({
boardId: z.string().cuid(),
title: z.string().min(1, 'Section title is required').max(200),
icon: z.string().max(500).optional(),
order: z.number().int().min(0).optional(),
isExpandedByDefault: z.boolean().optional()
});
export const updateSectionSchema = z.object({
title: z.string().min(1).max(200).optional(),
icon: z.string().max(500).nullable().optional(),
order: z.number().int().min(0).optional(),
isExpandedByDefault: z.boolean().optional(),
cardSize: z.enum([CardSize.COMPACT, CardSize.MEDIUM, CardSize.LARGE]).nullable().optional()
});
// --- Widget Config Schemas ---
const httpUrl = httpUrlSchema;
export const appWidgetConfigSchema = z.object({
appId: z.string().min(1, 'App ID is required')
});
export const bookmarkWidgetConfigSchema = z.object({
url: httpUrl,
label: z.string().min(1, 'Label is required').max(200),
icon: z.string().max(100).optional(),
description: z.string().max(500).optional()
});
export const noteWidgetConfigSchema = z.object({
content: z.string().max(10000, 'Content too long'),
format: z.enum(['markdown', 'text', 'html']).default('markdown')
});
export const embedWidgetConfigSchema = z.object({
url: httpUrl,
height: z.number().int().min(100).max(2000).default(300),
// Default to a strict sandbox; explicit override required to relax.
sandbox: z
.string()
.max(200)
.optional()
.default('allow-scripts allow-same-origin allow-forms allow-popups')
});
export const statusWidgetConfigSchema = z.object({
appIds: z.array(z.string().min(1)).min(1, 'At least one app is required'),
label: z.string().max(200).optional()
});
// --- Widget ---
const allWidgetTypes = [
WidgetType.APP,
WidgetType.BOOKMARK,
WidgetType.NOTE,
WidgetType.EMBED,
WidgetType.STATUS,
WidgetType.CLOCK,
WidgetType.SYSTEM_STATS,
WidgetType.RSS,
WidgetType.CALENDAR,
WidgetType.MARKDOWN,
WidgetType.METRIC,
WidgetType.LINK_GROUP,
WidgetType.CAMERA,
WidgetType.INTEGRATION
] as const;
export const createWidgetSchema = z.object({
sectionId: z.string().cuid(),
type: z.enum(allWidgetTypes),
order: z.number().int().min(0).optional(),
config: z.string().optional(),
appId: z.string().cuid().optional()
});
export const updateWidgetSchema = z.object({
type: z.enum(allWidgetTypes).optional(),
order: z.number().int().min(0).optional(),
config: z.string().optional(),
appId: z.string().cuid().nullable().optional()
});
// --- Permission ---
export const createPermissionSchema = z.object({
entityType: z.enum([EntityType.BOARD, EntityType.APP]),
entityId: z.string().cuid(),
targetType: z.enum([TargetType.USER, TargetType.GROUP]),
targetId: z.string().cuid(),
level: z.enum([PermissionLevel.VIEW, PermissionLevel.EDIT, PermissionLevel.ADMIN])
});
// --- Backup Schedule ---
export const updateBackupScheduleSchema = z.object({
backupEnabled: z.boolean().optional(),
backupCronExpression: z.string().min(1).max(100).optional(),
backupMaxCount: z.number().int().min(1).max(100).optional()
});
// --- System Settings ---
export const updateSystemSettingsSchema = z.object({
authMode: z.enum([AuthMode.LOCAL, AuthMode.OAUTH, AuthMode.BOTH]).optional(),
registrationEnabled: z.boolean().optional(),
oauthClientId: z.string().nullable().optional(),
oauthClientSecret: z.string().nullable().optional(),
oauthDiscoveryUrl: z.string().url().nullable().optional(),
defaultTheme: z.enum(['dark', 'light']).optional(),
defaultPrimaryColor: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, 'Invalid hex color')
.optional(),
healthcheckDefaults: z.string().optional(),
customCss: z.string().max(10000).nullable().optional(),
onboardingComplete: z.boolean().optional()
});
// --- New widget config schemas for Phases 4-7 ---
export const clockWeatherWidgetConfigSchema = z.object({
timezone: z.string().max(100).optional(),
showWeather: z.boolean().optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
clockStyle: z.enum(['analog', 'digital', '24h']).optional()
});
export const systemStatsWidgetConfigSchema = z.object({
sourceUrl: z.string().url('Invalid source URL'),
sourceType: z.enum(['glances', 'prometheus', 'custom']),
metrics: z.array(z.string().min(1)).min(1, 'At least one metric is required'),
refreshInterval: z.number().int().min(5).max(3600).optional()
});
export const rssWidgetConfigSchema = z.object({
feedUrl: z.string().url('Invalid feed URL'),
maxItems: z.number().int().min(1).max(50).optional(),
showSummary: z.boolean().optional()
});
export const calendarWidgetConfigSchema = z.object({
icalUrls: z
.array(
z.object({
url: z.string().url('Invalid iCal URL'),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, 'Invalid hex color')
.optional(),
label: z.string().max(100).optional()
})
)
.min(1, 'At least one calendar URL is required'),
daysAhead: z.number().int().min(1).max(90).optional()
});
export const markdownWidgetConfigSchema = z.object({
content: z.string().max(50000, 'Content too long'),
syntaxTheme: z.string().max(50).optional()
});
export const metricWidgetConfigSchema = z.object({
label: z.string().min(1, 'Label is required').max(200),
source: z.enum(['static', 'json', 'prometheus']),
value: z.string().max(200).optional(),
url: z.string().url().optional(),
jsonPath: z.string().max(200).optional(),
query: z.string().max(500).optional(),
unit: z.string().max(50).optional(),
refreshInterval: z.number().int().min(5).max(3600).optional()
});
export const linkGroupWidgetConfigSchema = z.object({
links: z
.array(
z.object({
label: z.string().min(1).max(200),
url: z.string().url('Invalid URL'),
icon: z.string().max(100).optional()
})
)
.min(1, 'At least one link is required'),
collapsible: z.boolean().optional()
});
export const cameraWidgetConfigSchema = z.object({
streamUrl: z.string().url('Invalid stream URL'),
type: z.enum(['mjpeg', 'hls', 'image']),
refreshInterval: z.number().int().min(1).max(3600).optional(),
aspectRatio: z.string().max(20).optional()
});
export const integrationWidgetConfigSchema = z.object({
appId: z.string().min(1, 'App ID is required'),
endpointId: z.string().min(1, 'Endpoint ID is required'),
refreshInterval: z.number().int().min(5).max(3600).optional()
});
// --- New entity schemas for Phases 4-7 ---
export const createTagSchema = z.object({
name: z.string().min(1, 'Tag name is required').max(50),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, 'Invalid hex color')
.optional()
});
export const updateTagSchema = z.object({
name: z.string().min(1).max(50).optional(),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, 'Invalid hex color')
.nullable()
.optional()
});
export const createAppLinkSchema = z.object({
appId: z.string().cuid(),
label: z.string().min(1, 'Label is required').max(200),
url: z.string().url('Invalid URL'),
icon: z.string().max(100).optional(),
order: z.number().int().min(0).optional()
});
export const updateAppLinkSchema = z.object({
label: z.string().min(1).max(200).optional(),
url: z.string().url().optional(),
icon: z.string().max(100).nullable().optional(),
order: z.number().int().min(0).optional()
});
export const createNotificationChannelSchema = z.object({
type: z.enum([
NotificationType.DISCORD,
NotificationType.SLACK,
NotificationType.TELEGRAM,
NotificationType.HTTP
]),
config: z.string().min(1, 'Config is required'),
enabled: z.boolean().optional()
});
export const updateNotificationChannelSchema = z.object({
type: z
.enum([
NotificationType.DISCORD,
NotificationType.SLACK,
NotificationType.TELEGRAM,
NotificationType.HTTP
])
.optional(),
config: z.string().min(1).optional(),
enabled: z.boolean().optional()
});
export const createApiTokenSchema = z.object({
name: z.string().min(1, 'Token name is required').max(100),
scope: z.enum([ApiTokenScope.READ, ApiTokenScope.WRITE, ApiTokenScope.ADMIN]),
expiresAt: z.string().datetime().optional()
});
export const createBoardTemplateSchema = z.object({
name: z.string().min(1, 'Template name is required').max(200),
description: z.string().max(1000).optional(),
icon: z.string().max(500).optional(),
config: z.string().min(1, 'Config is required'),
isBuiltin: z.boolean().optional()
});
export const auditLogQuerySchema = z.object({
action: z
.enum([
AuditAction.USER_CREATED,
AuditAction.USER_DELETED,
AuditAction.USER_UPDATED,
AuditAction.BOARD_CREATED,
AuditAction.BOARD_DELETED,
AuditAction.APP_CREATED,
AuditAction.APP_DELETED,
AuditAction.SETTINGS_UPDATED,
AuditAction.IMPORT,
AuditAction.EXPORT,
AuditAction.BACKUP_CREATED,
AuditAction.BACKUP_RESTORED,
AuditAction.BACKUP_DELETED,
AuditAction.BACKUP_FAILED
])
.optional(),
entityType: z.string().max(50).optional(),
dateFrom: z.string().datetime().optional(),
dateTo: z.string().datetime().optional(),
page: z.number().int().min(1).optional(),
limit: z.number().int().min(1).max(100).optional()
});