1.0.36
This commit is contained in:
63 files changed
+1324
-281
No files matched your search
@@ -46,12 +46,11 @@ function cacheKey(rawToken: string): string {
|
||||
return createHash('sha256').update(rawToken).digest('hex');
|
||||
}
|
||||
|
||||
// Pre-computed dummy hash for timing protection on invalid prefixes
|
||||
let dummyHash: string | null = null;
|
||||
|
||||
async function getDummyHash(): Promise<string> {
|
||||
if (!dummyHash) {
|
||||
dummyHash = await hashPassword('dh_dummy_token_for_timing_protection');
|
||||
dummyHash = await hashPassword('dh_init_seed');
|
||||
}
|
||||
return dummyHash;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
*/
|
||||
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { Permissions } from './db';
|
||||
import { getUserAccessibleEnvironments, userCanAccessEnvironment, userHasAdminRole } from './db';
|
||||
import { validateSession, isAuthEnabled, checkPermission, type AuthenticatedUser } from './auth';
|
||||
@@ -105,6 +106,27 @@ export interface AuthorizationContext {
|
||||
* - (User is admin OR has audit_logs view permission)
|
||||
*/
|
||||
canViewAuditLog: () => Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Gate handler: require a permission. Returns a 403 Response on denial,
|
||||
* null when allowed. Use at the top of a handler with `??`-chaining for
|
||||
* one-line auth/env checks:
|
||||
*
|
||||
* const denied = (await auth.requirePermission('schedules', 'edit'))
|
||||
* ?? (await auth.requireEnvAccess(envId));
|
||||
* if (denied) return denied;
|
||||
*
|
||||
* No-op when authEnabled is false.
|
||||
*/
|
||||
requirePermission: (resource: keyof Permissions, action: string, environmentId?: number) => Promise<Response | null>;
|
||||
|
||||
/**
|
||||
* Gate handler: require access to the given environment. Returns a 403
|
||||
* Response on denial, null when allowed. Pass null/undefined to no-op
|
||||
* (e.g. system-scoped operations that don't target an env). No-op when
|
||||
* authEnabled is false.
|
||||
*/
|
||||
requireEnvAccess: (environmentId: number | null | undefined) => Promise<Response | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -232,6 +254,25 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
|
||||
|
||||
// Check for audit_logs permission
|
||||
return checkPermission(user, 'audit_logs' as keyof Permissions, 'view');
|
||||
},
|
||||
|
||||
async requirePermission(
|
||||
resource: keyof Permissions,
|
||||
action: string,
|
||||
environmentId?: number
|
||||
): Promise<Response | null> {
|
||||
if (!authEnabled) return null;
|
||||
return (await ctx.can(resource, action, environmentId))
|
||||
? null
|
||||
: json({ error: 'Permission denied' }, { status: 403 });
|
||||
},
|
||||
|
||||
async requireEnvAccess(environmentId: number | null | undefined): Promise<Response | null> {
|
||||
if (!authEnabled) return null;
|
||||
if (environmentId === null || environmentId === undefined) return null;
|
||||
return (await ctx.canAccessEnvironment(environmentId))
|
||||
? null
|
||||
: json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+17
-4
@@ -389,8 +389,10 @@ export async function getUserThemePreferences(userId: number): Promise<{
|
||||
terminalFont: string;
|
||||
editorFont: string;
|
||||
animateIcons: boolean;
|
||||
coloredActionButtons: boolean;
|
||||
actionIconSize: string;
|
||||
}> {
|
||||
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons] = await Promise.all([
|
||||
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons, coloredActionButtons, actionIconSize] = await Promise.all([
|
||||
getUserSetting(userId, 'light_theme'),
|
||||
getUserSetting(userId, 'dark_theme'),
|
||||
getUserSetting(userId, 'font'),
|
||||
@@ -398,7 +400,9 @@ export async function getUserThemePreferences(userId: number): Promise<{
|
||||
getUserSetting(userId, 'grid_font_size'),
|
||||
getUserSetting(userId, 'terminal_font'),
|
||||
getUserSetting(userId, 'editor_font'),
|
||||
getUserSetting(userId, 'animate_icons')
|
||||
getUserSetting(userId, 'animate_icons'),
|
||||
getUserSetting(userId, 'colored_action_buttons'),
|
||||
getUserSetting(userId, 'action_icon_size')
|
||||
]);
|
||||
return {
|
||||
lightTheme: lightTheme || 'default',
|
||||
@@ -409,13 +413,16 @@ export async function getUserThemePreferences(userId: number): Promise<{
|
||||
terminalFont: terminalFont || 'system-mono',
|
||||
editorFont: editorFont || 'system-mono',
|
||||
// Default ON — only false when explicitly stored
|
||||
animateIcons: animateIcons === 'false' ? false : true
|
||||
animateIcons: animateIcons === 'false' ? false : true,
|
||||
// Default OFF — only true when explicitly stored
|
||||
coloredActionButtons: coloredActionButtons === 'true',
|
||||
actionIconSize: actionIconSize || 'normal'
|
||||
};
|
||||
}
|
||||
|
||||
export async function setUserThemePreferences(
|
||||
userId: number,
|
||||
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean }
|
||||
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean; coloredActionButtons?: boolean; actionIconSize?: string }
|
||||
): Promise<void> {
|
||||
const updates: Promise<void>[] = [];
|
||||
if (prefs.lightTheme !== undefined) {
|
||||
@@ -442,6 +449,12 @@ export async function setUserThemePreferences(
|
||||
if (prefs.animateIcons !== undefined) {
|
||||
updates.push(setUserSetting(userId, 'animate_icons', prefs.animateIcons ? 'true' : 'false'));
|
||||
}
|
||||
if (prefs.coloredActionButtons !== undefined) {
|
||||
updates.push(setUserSetting(userId, 'colored_action_buttons', prefs.coloredActionButtons ? 'true' : 'false'));
|
||||
}
|
||||
if (prefs.actionIconSize !== undefined) {
|
||||
updates.push(setUserSetting(userId, 'action_icon_size', prefs.actionIconSize));
|
||||
}
|
||||
await Promise.all(updates);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
import * as tls from 'node:tls';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Environment } from './db';
|
||||
import { getStackEnvVarsAsRecord, getSetting } from './db';
|
||||
import { getSetting } from './db';
|
||||
import { getAdditionalVolumeBinds } from './mount-dedupe';
|
||||
import { encodeRegistryAuth } from './registry-auth';
|
||||
import { isSystemContainer } from './scheduler/tasks/update-utils';
|
||||
@@ -4501,6 +4501,8 @@ export async function runContainer(options: {
|
||||
extraHosts?: string[];
|
||||
name?: string;
|
||||
envId?: number | null;
|
||||
networkMode?: string; // Docker network mode (e.g., 'host', 'bridge', custom network name)
|
||||
dns?: string[]; // Custom DNS servers; undefined = inherit from Docker daemon
|
||||
}): Promise<{ stdout: string; stderr: string }> {
|
||||
// Add random suffix to avoid naming conflicts
|
||||
const baseName = options.name || `dockhand-temp-${Date.now()}`;
|
||||
@@ -4524,6 +4526,14 @@ export async function runContainer(options: {
|
||||
containerConfig.HostConfig.ExtraHosts = options.extraHosts;
|
||||
}
|
||||
|
||||
if (options.networkMode) {
|
||||
containerConfig.HostConfig.NetworkMode = options.networkMode;
|
||||
}
|
||||
|
||||
if (options.dns && options.dns.length > 0) {
|
||||
containerConfig.HostConfig.Dns = options.dns;
|
||||
}
|
||||
|
||||
const createResult = await dockerJsonRequest<{ Id: string }>(
|
||||
`/containers/create?name=${encodeURIComponent(containerName)}`,
|
||||
{
|
||||
@@ -4591,6 +4601,7 @@ export async function runContainerWithStreaming(options: {
|
||||
onStderr?: (data: string) => void;
|
||||
timeout?: number; // Overall timeout in ms (0 or undefined = no timeout)
|
||||
networkMode?: string; // Docker network mode (e.g., network name for TCP access)
|
||||
dns?: string[]; // Custom DNS servers; undefined = inherit from Docker daemon
|
||||
}): Promise<string> {
|
||||
const baseName = options.name || `dockhand-stream-${Date.now()}`;
|
||||
const containerName = `${baseName}-${randomSuffix()}`;
|
||||
@@ -4625,6 +4636,10 @@ export async function runContainerWithStreaming(options: {
|
||||
containerConfig.HostConfig.NetworkMode = options.networkMode;
|
||||
}
|
||||
|
||||
if (options.dns && options.dns.length > 0) {
|
||||
containerConfig.HostConfig.Dns = options.dns;
|
||||
}
|
||||
|
||||
const createResult = await dockerJsonRequest<{ Id: string }>(
|
||||
`/containers/create?name=${encodeURIComponent(containerName)}`,
|
||||
{ method: 'POST', body: JSON.stringify(containerConfig) },
|
||||
|
||||
@@ -1,13 +1,40 @@
|
||||
/** ntfy.sh + self-hosted ntfy. ntfy:// or ntfys:// (HTTPS). */
|
||||
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
|
||||
|
||||
// HTTP header values must be ByteString (every char ≤ 0xFF). Reject anything
|
||||
// else so we never blow up fetch() with a 65533 (U+FFFD) replacement char.
|
||||
function isHeaderSafe(value: string): boolean {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (value.charCodeAt(i) > 0xff) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ?auth= accepts either a raw ntfy access token (typically `tk_...`) or a
|
||||
// base64-encoded `Bearer <token>` / `Basic <creds>` string. We try base64 first
|
||||
// only when the input looks plausibly base64; if decoding produces non-ASCII
|
||||
// garbage we fall back to treating the value as a raw token.
|
||||
export function resolveQueryAuth(queryAuth: string): string {
|
||||
if (queryAuth.startsWith('tk_')) {
|
||||
return `Bearer ${queryAuth}`;
|
||||
}
|
||||
const looksBase64 = /^[A-Za-z0-9+/=_-]+$/.test(queryAuth);
|
||||
if (looksBase64) {
|
||||
const decoded = Buffer.from(queryAuth, 'base64').toString();
|
||||
if (isHeaderSafe(decoded) && (decoded.startsWith('Bearer ') || decoded.startsWith('Basic '))) {
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
return `Bearer ${queryAuth}`;
|
||||
}
|
||||
|
||||
export async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// Supported formats:
|
||||
// ntfy://topic (public ntfy.sh)
|
||||
// ntfy://host/topic (custom server, no auth)
|
||||
// ntfy://user:pass@host/topic (custom server with basic auth)
|
||||
// ntfy://token@host/topic (custom server with bearer token)
|
||||
// ntfy://host/topic?auth=BASE64 (custom server with base64-encoded bearer token)
|
||||
// ntfy://host/topic?auth=TOKEN (raw token, e.g. tk_..., or base64-encoded "Bearer <token>")
|
||||
// Query params: ?tags=ship,whale &title=Custom &priority=5
|
||||
// ntfys:// variants for HTTPS
|
||||
const isSecure = appriseUrl.startsWith('ntfys');
|
||||
@@ -53,8 +80,7 @@ export async function sendNtfy(appriseUrl: string, payload: NotificationPayload)
|
||||
}
|
||||
|
||||
if (!authHeader && queryAuth) {
|
||||
const decoded = Buffer.from(queryAuth, 'base64').toString();
|
||||
authHeader = decoded.startsWith('Bearer ') ? decoded : `Bearer ${decoded}`;
|
||||
authHeader = resolveQueryAuth(queryAuth);
|
||||
}
|
||||
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from './host-path';
|
||||
import { resolve } from 'node:path';
|
||||
import { mkdir, chown, rm } from 'node:fs/promises';
|
||||
import { resolveScannerOverrides } from '$lib/utils/scanner-overrides';
|
||||
|
||||
export type ScannerType = 'none' | 'grype' | 'trivy' | 'both';
|
||||
|
||||
@@ -164,24 +165,47 @@ export interface ScanProgress {
|
||||
output?: string; // Line of scanner output
|
||||
}
|
||||
|
||||
function parseDnsSetting(raw: string | null | undefined): string[] {
|
||||
if (!raw) return [];
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return [];
|
||||
// Tolerate either JSON-array storage (preferred) or comma-separated string
|
||||
// (defensive — UI submits JSON, but legacy / hand-edited values may exist).
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return Array.isArray(parsed) ? parsed.filter((v) => typeof v === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return trimmed.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// Get global default scanner CLI args and images from general settings (or fallback to hardcoded defaults)
|
||||
export async function getGlobalScannerDefaults(): Promise<{
|
||||
grypeArgs: string;
|
||||
trivyArgs: string;
|
||||
grypeImage: string;
|
||||
trivyImage: string;
|
||||
networkMode: string;
|
||||
dns: string[];
|
||||
}> {
|
||||
const [grypeArgs, trivyArgs, grypeImage, trivyImage] = await Promise.all([
|
||||
const [grypeArgs, trivyArgs, grypeImage, trivyImage, networkMode, dns] = await Promise.all([
|
||||
getSetting('default_grype_args'),
|
||||
getSetting('default_trivy_args'),
|
||||
getSetting('default_grype_image'),
|
||||
getSetting('default_trivy_image')
|
||||
getSetting('default_trivy_image'),
|
||||
getSetting('default_scanner_network_mode'),
|
||||
getSetting('default_scanner_dns')
|
||||
]);
|
||||
return {
|
||||
grypeArgs: grypeArgs ?? DEFAULT_GRYPE_ARGS,
|
||||
trivyArgs: trivyArgs ?? DEFAULT_TRIVY_ARGS,
|
||||
grypeImage: grypeImage ?? DEFAULT_GRYPE_IMAGE,
|
||||
trivyImage: trivyImage ?? DEFAULT_TRIVY_IMAGE
|
||||
trivyImage: trivyImage ?? DEFAULT_TRIVY_IMAGE,
|
||||
networkMode: networkMode ?? '',
|
||||
dns: parseDnsSetting(dns)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -747,6 +771,19 @@ async function runScannerContainerCore(
|
||||
envVars.push(`DOCKER_HOST=${scannerDockerHost}`);
|
||||
}
|
||||
|
||||
// Apply user-configured overrides on top of auto-detection (#1219).
|
||||
// Empty user settings → resolver returns the auto-detected values unchanged.
|
||||
const userScannerSettings = await getGlobalScannerDefaults();
|
||||
const overrides = resolveScannerOverrides(
|
||||
{ networkMode: scannerNetworkMode, extraHosts: scannerExtraHosts },
|
||||
{ networkMode: userScannerSettings.networkMode, dns: userScannerSettings.dns }
|
||||
);
|
||||
if (userScannerSettings.networkMode || userScannerSettings.dns.length > 0) {
|
||||
console.log(
|
||||
`[Scanner] User overrides applied — network=${overrides.networkMode ?? 'default'}, dns=${overrides.dns?.join(',') ?? 'default'}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[Scanner] Running ${scannerType} with cache mounted at ${basePath}`);
|
||||
console.log(`[Scanner] Container command: ${cmd.join(' ')}`);
|
||||
// Run the scanner container with a 10-minute timeout to prevent indefinite hangs
|
||||
@@ -755,10 +792,11 @@ async function runScannerContainerCore(
|
||||
cmd,
|
||||
binds,
|
||||
env: envVars,
|
||||
extraHosts: scannerExtraHosts,
|
||||
extraHosts: overrides.extraHosts,
|
||||
name: `dockhand-${scannerType}-${Date.now()}`,
|
||||
envId,
|
||||
networkMode: scannerNetworkMode,
|
||||
networkMode: overrides.networkMode,
|
||||
dns: overrides.dns,
|
||||
timeout: 600_000, // 10 minutes
|
||||
onStderr: (data) => {
|
||||
// Stream stderr lines for real-time progress output
|
||||
@@ -1076,14 +1114,22 @@ async function getScannerVersion(
|
||||
);
|
||||
if (!hasImage) return null;
|
||||
|
||||
// Create temporary container to get version
|
||||
// Create temporary container to get version. Apply user overrides so
|
||||
// version probe works in environments where default networking fails
|
||||
// (#1219 — host needs --network host for scanner images to resolve DNS).
|
||||
const versionCmd = scannerType === 'grype' ? ['version'] : ['--version'];
|
||||
const overrides = resolveScannerOverrides(
|
||||
{},
|
||||
{ networkMode: defaults.networkMode, dns: defaults.dns }
|
||||
);
|
||||
console.log(`[Scanner] Getting ${scannerType} version with cmd:`, versionCmd);
|
||||
const { stdout, stderr } = await runContainer({
|
||||
image: scannerImage,
|
||||
cmd: versionCmd,
|
||||
name: `dockhand-${scannerType}-version-${Date.now()}`,
|
||||
envId
|
||||
envId,
|
||||
networkMode: overrides.networkMode,
|
||||
dns: overrides.dns
|
||||
});
|
||||
|
||||
console.log(`[Scanner] ${scannerType} version check result: stdout="${stdout.substring(0, 100)}", stderr="${stderr.substring(0, 100)}"`);
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
} from '../../docker';
|
||||
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
|
||||
import { sendEventNotification } from '../../notifications';
|
||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
|
||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer, isPodmanInfraContainer } from './update-utils';
|
||||
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
|
||||
|
||||
// =============================================================================
|
||||
@@ -343,6 +343,19 @@ export async function runContainerUpdate(
|
||||
const inspectData = await inspectContainer(container.id, envId) as any;
|
||||
const imageNameFromConfig = inspectData.Config?.Image;
|
||||
|
||||
// Podman pod-infra containers may have an empty Config.Image — silently
|
||||
// skip them before the "Could not determine image name" failure (#1221).
|
||||
if (isPodmanInfraContainer(imageNameFromConfig, inspectData.Config?.Labels)) {
|
||||
log(`Skipping Podman pod-infra container`);
|
||||
await updateScheduleExecution(execution.id, {
|
||||
status: 'skipped',
|
||||
completedAt: new Date().toISOString(),
|
||||
duration: Date.now() - startTime,
|
||||
details: { reason: 'Podman pod-infra container' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!imageNameFromConfig) {
|
||||
log(`Could not determine image name from container config`);
|
||||
await updateScheduleExecution(execution.id, {
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from '../../docker';
|
||||
import { sendEventNotification } from '../../notifications';
|
||||
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
|
||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
|
||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer, isPodmanInfraContainer } from './update-utils';
|
||||
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
|
||||
import { recreateContainer } from './container-update';
|
||||
|
||||
@@ -108,9 +108,12 @@ export async function runEnvUpdateCheckJob(
|
||||
// Get all containers in this environment, excluding ones hidden via
|
||||
// dockhand.hidden=true (consistent with manual check-updates, #1083).
|
||||
const allContainers = await listContainers(true, environmentId);
|
||||
const containers = allContainers.filter(c => !isHiddenByLabel(c.labels));
|
||||
// Skip hidden + Podman pod-infra (#1083, #1221)
|
||||
const containers = allContainers.filter(
|
||||
(c) => !isHiddenByLabel(c.labels) && !isPodmanInfraContainer(c.image, c.labels)
|
||||
);
|
||||
const hiddenCount = allContainers.length - containers.length;
|
||||
await log(`Found ${containers.length} containers${hiddenCount ? ` (${hiddenCount} hidden by label)` : ''}`);
|
||||
await log(`Found ${containers.length} containers${hiddenCount ? ` (${hiddenCount} hidden/infra)` : ''}`);
|
||||
|
||||
const updatesAvailable: UpdateInfo[] = [];
|
||||
let checkedCount = 0;
|
||||
|
||||
@@ -127,6 +127,22 @@ export function isSystemContainer(imageName: string): SystemContainerType | null
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Podman creates a hidden "pod-infra" container for every pod it runs (#1221).
|
||||
* The image (`localhost/podman-pause`) is generated locally and never published
|
||||
* to any registry, so update checks always fail noisily. The label is the
|
||||
* authoritative signal — but Podman may set Config.Image empty for these
|
||||
* containers, so the name fallback catches that too.
|
||||
*/
|
||||
export function isPodmanInfraContainer(
|
||||
imageName: string | undefined,
|
||||
labels: Record<string, string> | undefined
|
||||
): boolean {
|
||||
if (labels?.['io.podman.annotations.infra'] === 'true') return true;
|
||||
if (imageName && /podman-pause/i.test(imageName)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine multiple scan summaries by taking the maximum of each severity level.
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@ import { validateSessionById, isAuthEnabled, SESSION_COOKIE } from './auth';
|
||||
import { validateApiToken } from './api-tokens';
|
||||
import { isEnterprise } from './license';
|
||||
import { userHasAdminRole, userCanAccessEnvironment } from './db';
|
||||
import { parseCookieHeader } from '$lib/utils/cookie-parse';
|
||||
|
||||
export interface WsUpgradeAuth {
|
||||
userId: number;
|
||||
@@ -10,20 +11,6 @@ export interface WsUpgradeAuth {
|
||||
authDisabled: boolean;
|
||||
}
|
||||
|
||||
function parseCookieHeader(header: string | undefined): Record<string, string> {
|
||||
if (!header) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const part of header.split(';')) {
|
||||
const eq = part.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const k = part.slice(0, eq).trim();
|
||||
let v = part.slice(eq + 1).trim();
|
||||
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
||||
if (k) out[k] = decodeURIComponent(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type LowercasedHeaders = Record<string, string | string[] | undefined>;
|
||||
|
||||
function pickHeader(headers: LowercasedHeaders, name: string): string | undefined {
|
||||
|
||||
Reference in new issue
Block a user