This commit is contained in:
jarek committed 2026-06-15 14:56:51 +02:00
1 parent d465ecfe96
commit 83c3a5ea09
85 files changed
+10118 -1046

No files matched your search

+31 -8
View File
@@ -388,15 +388,17 @@ export async function getUserThemePreferences(userId: number): Promise<{
gridFontSize: string;
terminalFont: string;
editorFont: string;
animateIcons: boolean;
}> {
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont] = await Promise.all([
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons] = await Promise.all([
getUserSetting(userId, 'light_theme'),
getUserSetting(userId, 'dark_theme'),
getUserSetting(userId, 'font'),
getUserSetting(userId, 'font_size'),
getUserSetting(userId, 'grid_font_size'),
getUserSetting(userId, 'terminal_font'),
getUserSetting(userId, 'editor_font')
getUserSetting(userId, 'editor_font'),
getUserSetting(userId, 'animate_icons')
]);
return {
lightTheme: lightTheme || 'default',
@@ -405,13 +407,15 @@ export async function getUserThemePreferences(userId: number): Promise<{
fontSize: fontSize || 'normal',
gridFontSize: gridFontSize || 'normal',
terminalFont: terminalFont || 'system-mono',
editorFont: editorFont || 'system-mono'
editorFont: editorFont || 'system-mono',
// Default ON — only false when explicitly stored
animateIcons: animateIcons === 'false' ? false : true
};
}
export async function setUserThemePreferences(
userId: number,
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string }
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean }
): Promise<void> {
const updates: Promise<void>[] = [];
if (prefs.lightTheme !== undefined) {
@@ -435,6 +439,9 @@ export async function setUserThemePreferences(
if (prefs.editorFont !== undefined) {
updates.push(setUserSetting(userId, 'editor_font', prefs.editorFont));
}
if (prefs.animateIcons !== undefined) {
updates.push(setUserSetting(userId, 'animate_icons', prefs.animateIcons ? 'true' : 'false'));
}
await Promise.all(updates);
}
@@ -2097,6 +2104,7 @@ export interface GitStackData {
lastCommit: string | null;
syncStatus: GitSyncStatus;
syncError: string | null;
syncedFiles?: string | null; // JSON manifest { commit, files: { relPath: sha256 } } from last successful deploy
createdAt: string;
updatedAt: string;
}
@@ -2303,6 +2311,7 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
lastCommit: gitStacks.lastCommit,
syncStatus: gitStacks.syncStatus,
syncError: gitStacks.syncError,
syncedFiles: gitStacks.syncedFiles,
createdAt: gitStacks.createdAt,
updatedAt: gitStacks.updatedAt,
repoName: gitRepositories.name,
@@ -2337,6 +2346,7 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
lastCommit: row.lastCommit,
syncStatus: row.syncStatus,
syncError: row.syncError,
syncedFiles: row.syncedFiles ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
repository: {
@@ -2548,6 +2558,7 @@ export async function updateGitStack(id: number, data: Partial<GitStackData>): P
if (data.lastCommit !== undefined) updateData.lastCommit = data.lastCommit;
if (data.syncStatus !== undefined) updateData.syncStatus = data.syncStatus;
if (data.syncError !== undefined) updateData.syncError = data.syncError;
if (data.syncedFiles !== undefined) updateData.syncedFiles = data.syncedFiles;
await db.update(gitStacks).set(updateData).where(eq(gitStacks.id, id));
return getGitStack(id);
@@ -3592,9 +3603,15 @@ export async function getContainerEventActions(): Promise<string[]> {
export async function deleteOldContainerEvents(keepDays = 30): Promise<number> {
const cutoffDate = new Date(Date.now() - keepDays * 24 * 60 * 60 * 1000).toISOString();
await db.delete(containerEvents)
const countResult = await db.select({ count: sql<number>`count(*)` })
.from(containerEvents)
.where(sql`timestamp < ${cutoffDate}`);
return 0;
const count = Number(countResult[0]?.count ?? 0);
if (count > 0) {
await db.delete(containerEvents)
.where(sql`timestamp < ${cutoffDate}`);
}
return count;
}
/**
@@ -4082,9 +4099,15 @@ export async function getRecentExecutionsForSchedule(
export async function cleanupOldExecutions(retentionDays: number): Promise<number> {
const cutoffDate = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
const result = await db.delete(scheduleExecutions)
const countResult = await db.select({ count: sql<number>`count(*)` })
.from(scheduleExecutions)
.where(sql`triggered_at < ${cutoffDate}`);
return 0; // SQLite/PG don't return count consistently
const count = Number(countResult[0]?.count ?? 0);
if (count > 0) {
await db.delete(scheduleExecutions)
.where(sql`triggered_at < ${cutoffDate}`);
}
return count;
}
// Settings helpers for retention
+1
View File
@@ -324,6 +324,7 @@ export const gitStacks = sqliteTable('git_stacks', {
lastCommit: text('last_commit'),
syncStatus: text('sync_status').default('pending'),
syncError: text('sync_error'),
syncedFiles: text('synced_files'), // JSON manifest { relativePath: sha256hex } of files written on last sync
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
}, (table) => ({
+1
View File
@@ -327,6 +327,7 @@ export const gitStacks = pgTable('git_stacks', {
lastCommit: text('last_commit'),
syncStatus: text('sync_status').default('pending'),
syncError: text('sync_error'),
syncedFiles: text('synced_files'), // JSON manifest { relativePath: sha256hex } of files written on last sync
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
}, (table) => ({
+157 -17
View File
@@ -15,6 +15,7 @@ import { createHash } from 'node:crypto';
import type { Environment } from './db';
import { getStackEnvVarsAsRecord } from './db';
import { getAdditionalVolumeBinds } from './mount-dedupe';
import { encodeRegistryAuth } from './registry-auth';
import { isSystemContainer } from './scheduler/tasks/update-utils';
import { deepDiff } from '../utils/diff.js';
@@ -286,7 +287,7 @@ const envCache = new Map<number, CachedEnv>();
const CACHE_TTL = 30 * 60 * 1000;
// All known Docker Hub hostname variations for credential matching
const DOCKER_HUB_HOSTS = new Set([
export const DOCKER_HUB_HOSTS = new Set([
'docker.io', 'hub.docker.com', 'registry.hub.docker.com',
'index.docker.io', 'registry-1.docker.io', 'registry.docker.io', 'docker.com'
]);
@@ -1215,6 +1216,61 @@ export async function renameContainer(id: string, newName: string, envId?: numbe
await assertDockerResponse(response);
}
/**
* In-place container property update. Wraps Docker's POST /containers/{id}/update,
* which is the only API that changes container properties WITHOUT recreating it.
*
* The accepted body field set is fixed by Docker — anything outside this set
* requires container recreation (image, env, ports, networks, mounts, etc).
* Whitelist enforced here so callers can't accidentally smuggle a
* recreate-only field through this code path.
*/
export const IN_PLACE_UPDATE_FIELDS = [
// Restart policy — the headline use case (#1153)
'RestartPolicy',
// CPU
'CpuShares', 'CpuPeriod', 'CpuQuota', 'CpuRealtimePeriod', 'CpuRealtimeRuntime',
'CpusetCpus', 'CpusetMems', 'NanoCpus',
// Memory
'Memory', 'MemorySwap', 'MemoryReservation', 'MemorySwappiness', 'KernelMemory',
// Block I/O
'BlkioWeight', 'BlkioWeightDevice',
'BlkioDeviceReadBps', 'BlkioDeviceWriteBps',
'BlkioDeviceReadIOps', 'BlkioDeviceWriteIOps',
// Misc
'PidsLimit'
] as const;
export type InPlaceUpdateField = typeof IN_PLACE_UPDATE_FIELDS[number];
export interface UpdateContainerRuntimeResult {
Warnings: string[] | null;
}
/**
* Apply an in-place update. `updates` keys MUST be from IN_PLACE_UPDATE_FIELDS;
* unknown keys are silently dropped (not passed to Docker) so a malicious or
* confused caller can't sneak a recreate-only field through.
*/
export async function updateContainerRuntime(
id: string,
updates: Partial<Record<InPlaceUpdateField, unknown>>,
envId?: number | null
): Promise<UpdateContainerRuntimeResult> {
const allowed = new Set<string>(IN_PLACE_UPDATE_FIELDS);
const body: Record<string, unknown> = {};
for (const [key, value] of Object.entries(updates)) {
if (allowed.has(key) && value !== undefined) body[key] = value;
}
if (Object.keys(body).length === 0) {
throw new Error('No updatable fields provided');
}
return dockerJsonRequest<UpdateContainerRuntimeResult>(
`/containers/${id}/update`,
{ method: 'POST', body: JSON.stringify(body) },
envId
);
}
export async function getContainerLogs(id: string, tail: number | 'all' = 100, envId?: number | null, since?: string, until?: string): Promise<string> {
// Check if container has TTY enabled
const info = await inspectContainer(id, envId);
@@ -2553,6 +2609,28 @@ export async function listImages(envId?: number | null): Promise<ImageInfo[]> {
}));
}
/**
* Diagnostic log for registry auth headers (#1105).
* Logs lengths and boundary char codes — never the plaintext credentials.
* Header prefix is safe to log: it encodes the start of `{"username":"...`.
*/
function logAuthDiagnostics(
tag: string,
registry: string,
serveraddress: string,
username: string,
password: string,
authHeader: string
): void {
const userLast = username.length ? username.charCodeAt(username.length - 1).toString(16) : 'na';
const pwLast = password.length ? password.charCodeAt(password.length - 1).toString(16) : 'na';
console.log(
`${tag} auth: registry=${registry} user(len=${username.length},last=0x${userLast}) ` +
`pw(len=${password.length},last=0x${pwLast}) serveraddress=${serveraddress} ` +
`authHeader(len=${authHeader.length},prefix=${authHeader.slice(0, 16)})`
);
}
/**
* Build X-Registry-Auth header for authenticated Docker image pulls.
* Looks up stored registry credentials and returns a headers object
@@ -2566,16 +2644,19 @@ export async function buildRegistryAuthHeader(imageName: string): Promise<Record
if (creds) {
// Docker Engine requires 'https://index.docker.io/v1/' as serveraddress
// for Docker Hub auth — just the hostname is treated as unauthenticated
const serveraddress = DOCKER_HUB_HOSTS.has(registry)
? 'https://index.docker.io/v1/'
: registry;
console.log(`[Pull] Using credentials for ${serveraddress} (user: ${creds.username})`);
const isHub = DOCKER_HUB_HOSTS.has(registry);
const serveraddress = isHub ? 'https://index.docker.io/v1/' : registry;
if (isHub) {
console.log(`[Registry] docker-hub variant '${registry}' canonicalized to https://index.docker.io/v1/ for auth`);
}
const authConfig = {
username: creds.username,
password: creds.password,
serveraddress
};
headers['X-Registry-Auth'] = Buffer.from(JSON.stringify(authConfig)).toString('base64');
const authHeader = encodeRegistryAuth(authConfig);
headers['X-Registry-Auth'] = authHeader;
logAuthDiagnostics('[Pull]', registry, serveraddress, creds.username, creds.password, authHeader);
} else {
console.log(`[Pull] No credentials found for ${registry}`);
}
@@ -2618,11 +2699,19 @@ export async function pullImage(imageName: string, onProgress?: (data: any) => v
// Look up registry credentials for authenticated pulls
const headers = await buildRegistryAuthHeader(imageName);
// Diagnostic logging (#1105): what we're sending to the daemon
console.log(`[Pull] POST ${url} headers=${Object.keys(headers).join(',') || '(none)'}`);
// Use streaming: true for longer timeout on edge environments
const response = await dockerFetch(url, { method: 'POST', streaming: true, headers }, envId);
// Diagnostic logging (#1105): daemon response status
console.log(`[Pull] response status=${response.status} ${response.statusText}`);
if (!response.ok) {
throw new Error(`Failed to pull image: ${await response.text()}`);
const body = await response.text();
console.error(`[Pull] error body: ${body}`);
throw new Error(`Failed to pull image: ${body}`);
}
// Stream the response for progress updates
@@ -2644,6 +2733,9 @@ export async function pullImage(imageName: string, onProgress?: (data: any) => v
if (line.trim()) {
try {
const data = JSON.parse(line);
if (data.error || data.errorDetail) {
console.error(`[Pull] stream error: ${line}`);
}
if (onProgress) onProgress(data);
} catch {
// Ignore parse errors
@@ -2780,7 +2872,10 @@ async function findRegistryCredentials(registryHost: string): Promise<{ username
if (stored.fullRegistry === requested.fullRegistry ||
(stored.host === requested.host && !stored.path)) {
if (reg.username && reg.password) {
return { username: reg.username, password: reg.password };
const via = stored.fullRegistry === requested.fullRegistry ? 'full' : 'host-only';
console.log(`[Registry] matched stored=${reg.url} requested=${registryHost} via=${via}`);
// Normalize legacy creds saved before #1105 trim fix
return { username: reg.username.trim(), password: reg.password.trim() };
}
}
}
@@ -2792,12 +2887,20 @@ async function findRegistryCredentials(registryHost: string): Promise<{ username
const stored = parseRegistryUrl(reg.url);
if (DOCKER_HUB_HOSTS.has(stored.host)) {
if (reg.username && reg.password) {
return { username: reg.username, password: reg.password };
console.log(`[Registry] matched stored=${reg.url} requested=${registryHost} via=hub-alias`);
return { username: reg.username.trim(), password: reg.password.trim() };
}
}
}
}
// No match — log what we tried so support cases are diagnosable
const candidates = registries.map(r => parseRegistryUrl(r.url).host).join(', ');
console.log(
`[Registry] no match for requested=${registryHost} ` +
`(hub-alias=${DOCKER_HUB_HOSTS.has(requested.host)}); ` +
`candidates=[${candidates || 'none configured'}]`
);
return null;
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e);
@@ -2875,10 +2978,14 @@ async function getRegistryBearerToken(registry: string, repo: string): Promise<s
const service = serviceMatch ? serviceMatch[1] : '';
const scope = `repository:${repo}:pull`;
// Step 3: Request token from realm (with credentials if available)
// Step 3: Request token from realm (with credentials if available).
// Empty scope is allowed — means "no specific resource permission".
// Useful for credential validation: some registries (Docker Hub)
// reject privileged scopes like registry:catalog:* even for valid
// users, so omitting scope is the only reliable login check.
const tokenUrl = new URL(realm);
if (service) tokenUrl.searchParams.set('service', service);
tokenUrl.searchParams.set('scope', scope);
if (scope) tokenUrl.searchParams.set('scope', scope);
const tokenHeaders: Record<string, string> = { 'User-Agent': 'Dockhand/1.0' };
@@ -2992,10 +3099,14 @@ export async function getRegistryAuthHeader(
const realm = realmMatch[1];
const service = serviceMatch ? serviceMatch[1] : '';
// Step 3: Request token from realm (with credentials if available)
// Step 3: Request token from realm (with credentials if available).
// Empty scope is allowed — means "no specific resource permission".
// Useful for credential validation: some registries (Docker Hub)
// reject privileged scopes like registry:catalog:* even for valid
// users, so omitting scope is the only reliable login check.
const tokenUrl = new URL(realm);
if (service) tokenUrl.searchParams.set('service', service);
tokenUrl.searchParams.set('scope', scope);
if (scope) tokenUrl.searchParams.set('scope', scope);
const tokenHeaders: Record<string, string> = { 'User-Agent': 'Dockhand/1.0' };
@@ -3140,6 +3251,17 @@ export async function checkImageUpdateAvailable(
};
}
// Skip update check for images tagged against a localhost registry.
// These never resolve from inside Dockhand's container and only produce
// ECONNREFUSED noise (see #1083).
if (/^localhost(:\d+)?\//.test(imageName)) {
return {
hasUpdate: false,
isLocalImage: true,
currentDigest: currentImageId
};
}
// Get current image info to get RepoDigests
let currentImageInfo: any;
try {
@@ -3470,7 +3592,11 @@ export async function listVolumes(envId?: number | null): Promise<VolumeInfo[]>
scope: volume.Scope,
created: volume.CreatedAt,
labels: volume.Labels || {},
usedBy: volumeUsageMap.get(volume.Name) || []
usedBy: volumeUsageMap.get(volume.Name) || [],
// Surface driver_opts (e.g. NFS/CIFS type+device+o) so the UI can
// distinguish network-backed volumes from plain local ones. Docker's
// /volumes endpoint already includes this — we were dropping it.
options: volume.Options && Object.keys(volume.Options).length > 0 ? volume.Options : undefined
}));
}
@@ -4458,11 +4584,21 @@ export async function pushImage(
// Parse tag to get registry info
const [repo, tag = 'latest'] = imageTag.split(':');
// Create X-Registry-Auth header
const authHeader = Buffer.from(JSON.stringify(authConfig)).toString('base64');
const authHeader = encodeRegistryAuth(authConfig);
logAuthDiagnostics(
'[Push]',
authConfig.serveraddress,
authConfig.serveraddress,
authConfig.username ?? '',
authConfig.password ?? '',
authHeader
);
const pushUrl = `/images/${encodeURIComponent(imageTag)}/push`;
console.log(`[Push] POST ${pushUrl} headers=X-Registry-Auth`);
const response = await dockerFetch(
`/images/${encodeURIComponent(imageTag)}/push`,
pushUrl,
{
method: 'POST',
streaming: true,
@@ -4473,8 +4609,11 @@ export async function pushImage(
envId
);
console.log(`[Push] response status=${response.status} ${response.statusText}`);
if (!response.ok) {
const error = await response.text();
console.error(`[Push] error body: ${error}`);
throw new Error(`Failed to push image: ${error}`);
}
@@ -4498,6 +4637,7 @@ export async function pushImage(
try {
const data = JSON.parse(line);
if (data.error) {
console.error(`[Push] stream error: ${line}`);
throw new Error(data.error);
}
if (onProgress) onProgress(data);
+435
View File
@@ -0,0 +1,435 @@
/**
* Git stack deletion sync (#966, #1162).
*
* Propagates upstream file deletions to the stack deploy directory using the
* per-stack manifest: a file is deleted ONLY when the manifest of files
* Dockhand wrote on the previous sync lists it, the new clone no longer
* contains it, AND the bytes on disk still match what Dockhand wrote
* (nobody modified it locally).
*
* Every failure mode degrades to "delete less" — never to user-data loss:
* - user-created files (volume data) → never in the manifest → untouchable
* - locally modified files → hash mismatch → skip
* - first sync after upgrade / fresh DB → empty manifest → nothing to delete
* - broken clone walk (empty / compose missing) → deletionSafetyCheck blocks
* ALL deletions for that sync (guards against mass-deleting managed files
* due to a Dockhand bug; those files are repo-restorable anyway)
*
* History rewrites are irrelevant by design: deletion converges the deploy
* dir toward the clone state, regardless of how the commits got there.
*/
import { createHash } from 'node:crypto';
import { readdirSync, readFileSync, unlinkSync, rmdirSync, lstatSync } from 'node:fs';
import { join, resolve, sep, dirname, basename, isAbsolute } from 'node:path';
// =============================================================================
// Types
// =============================================================================
export type DeletionSkipReason =
| 'locally-modified' // disk bytes differ from what Dockhand wrote
| 'load-bearing' // compose/.env files are never auto-deleted
| 'invalid-path' // absolute or escaping the stack directory
| 'already-absent' // nothing to do (benign)
| 'agent-no-support' // Hawser agent too old to apply deletions
| 'apply-failed'; // unexpected error during unlink
export interface FileToDelete {
path: string; // relative to the stack deploy dir, '/' separators
hash: string; // sha256 hex of the content Dockhand wrote
}
export interface DeletionSkip {
path: string;
reason: DeletionSkipReason;
}
export interface DeletionPlan {
toDelete: FileToDelete[];
skipped: DeletionSkip[];
}
export interface DeletionApplyResult {
deleted: string[];
skipped: DeletionSkip[];
}
/** Manifest of files Dockhand wrote on the last successful sync. */
export interface SyncManifest {
/** Full commit hash the manifest files were taken from. Null = legacy/bootstrap. */
commit: string | null;
/** relative path → sha256 hex of written content */
files: Record<string, string>;
}
export interface SyncFileChange {
file: string;
status: 'added' | 'updated' | 'removed' | 'skipped';
reason?: string; // human-readable, only for skipped
}
export interface SyncChangeSummary {
changes: SyncFileChange[];
unchangedCount: number;
}
// =============================================================================
// Constants
// =============================================================================
/** Files that are never auto-deleted, regardless of what the sources say. */
export const LOAD_BEARING_FILES = new Set([
'docker-compose.yml',
'docker-compose.yaml',
'compose.yml',
'compose.yaml',
'.env'
]);
// NOTE: deletion skips are FINAL by design. A deletion is attempted exactly
// once — at the sync where the file first disappears from the clone. Any
// skip (old agent, hash mismatch, apply error) is logged and the file simply
// stays on disk as unmanaged residue. There is deliberately no
// carry-forward/retry state: it would require tracking per-file retry status
// indefinitely (e.g. waiting for an agent upgrade that may never happen).
// Worst case is always "a stale file survives" — visible in the logs,
// recoverable manually. With an old Hawser agent the behavior is identical
// to before this feature existed: nothing is ever deleted remotely.
/** Human-readable explanation for each skip reason (shown in logs and activity). */
export function skipReasonMessage(reason: DeletionSkipReason): string {
switch (reason) {
case 'locally-modified':
return 'deleted from the repository, but the file was modified on this machine since Dockhand deployed it — refusing to delete local changes';
case 'load-bearing':
return 'core stack file — never auto-deleted';
case 'invalid-path':
return 'invalid path outside the stack directory — ignored';
case 'already-absent':
return 'already absent';
case 'agent-no-support':
return 'the Hawser agent does not support file deletion sync — file left on the remote host (upgrade the agent to enable cleanup of future deletions)';
case 'apply-failed':
return 'could not be deleted — leaving the file in place';
default:
// Unknown reason (e.g., from a newer agent)
return 'could not be deleted — leaving the file in place';
}
}
const KNOWN_SKIP_REASONS: ReadonlySet<string> = new Set<DeletionSkipReason>([
'locally-modified',
'load-bearing',
'invalid-path',
'already-absent',
'agent-no-support',
'apply-failed'
]);
/** Normalize a reason string from an external source (Hawser agent). */
export function normalizeSkipReason(reason: string): DeletionSkipReason {
return (KNOWN_SKIP_REASONS.has(reason) ? reason : 'apply-failed') as DeletionSkipReason;
}
// =============================================================================
// Manifest (de)serialization
// =============================================================================
export function parseManifest(raw: string | null | undefined): SyncManifest {
if (!raw) return { commit: null, files: {} };
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && typeof parsed.files === 'object' && parsed.files !== null) {
const files: Record<string, string> = {};
for (const [k, v] of Object.entries(parsed.files)) {
if (typeof v === 'string') files[k] = v;
}
return { commit: typeof parsed.commit === 'string' ? parsed.commit : null, files };
}
} catch {
// Corrupt manifest → behave like a fresh bootstrap (fail closed: no deletions)
}
return { commit: null, files: {} };
}
export function serializeManifest(manifest: SyncManifest): string {
return JSON.stringify(manifest);
}
// =============================================================================
// Hashing
// =============================================================================
export function hashContent(content: Buffer | string): string {
return createHash('sha256').update(content).digest('hex');
}
/**
* Walk a directory and hash every regular file (raw bytes).
* Returns { relativePath: sha256hex } with '/' separators.
* Skips .git directories (mirrors the cpSync filter used by the deploy copy).
*/
export function hashDirFiles(dir: string): Record<string, string> {
const result: Record<string, string> = {};
const root = resolve(dir);
const walk = (current: string, relPrefix: string) => {
let entries;
try {
entries = readdirSync(current, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.name === '.git') continue;
const abs = join(current, entry.name);
const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
walk(abs, rel);
} else if (entry.isFile()) {
try {
result[rel] = hashContent(readFileSync(abs));
} catch {
// Unreadable file: leave out of the manifest → never a deletion candidate
}
}
// Symlinks and other special entries are intentionally excluded:
// Dockhand only writes regular files, so only regular files are managed.
}
};
walk(root, '');
return result;
}
// =============================================================================
// Path safety
// =============================================================================
/** A relative path is safe when it cannot escape the stack directory. */
export function isSafeRelPath(p: string): boolean {
if (!p || isAbsolute(p) || p.includes('\\')) return false;
const segments = p.split('/');
return segments.every((s) => s !== '' && s !== '.' && s !== '..');
}
/** Resolve relPath inside root; returns null when it would escape root. */
function containedPath(root: string, relPath: string): string | null {
if (!isSafeRelPath(relPath)) return null;
const abs = resolve(root, relPath);
if (abs !== root && abs.startsWith(root + sep)) return abs;
return null;
}
// =============================================================================
// Core: manifest vs clone
// =============================================================================
/**
* Sanity guard run BEFORE computing any deletions: when the new-clone walk
* looks broken (no files at all, or the compose file itself is missing from
* the walk even though it was just read from that tree), every manifest
* entry would become a deletion candidate — a Dockhand bug, not a repo
* change. Returns a human-readable reason to skip ALL deletions this sync,
* or null when it is safe to proceed.
*/
export function deletionSafetyCheck(
manifestFiles: Record<string, string>,
newFiles: Record<string, string>,
composeFileName: string | undefined
): string | null {
if (Object.keys(manifestFiles).length === 0) return null; // nothing to delete anyway
if (Object.keys(newFiles).length === 0) {
return 'the new clone appears empty — skipping all deletions this sync (likely a sync problem, not repository changes)';
}
if (composeFileName && !(composeFileName in newFiles)) {
return `the compose file "${composeFileName}" is missing from the new clone walk — skipping all deletions this sync (likely a sync problem, not repository changes)`;
}
return null;
}
/**
* Compute the deletion plan: manifest entries that are absent from the new
* clone. The hash recorded in the manifest travels with each entry — the
* applier deletes only files whose disk bytes still match it.
*
* @param manifestFiles files Dockhand wrote on the last sync (path → hash)
* @param newFiles files in the new clone that will be written (path → hash)
*/
export function computeDeletions(
manifestFiles: Record<string, string>,
newFiles: Record<string, string>
): DeletionPlan {
const toDelete: FileToDelete[] = [];
const skipped: DeletionSkip[] = [];
for (const [path, hash] of Object.entries(manifestFiles)) {
if (path in newFiles) continue; // still present in the repo
if (!isSafeRelPath(path)) {
skipped.push({ path, reason: 'invalid-path' });
continue;
}
if (LOAD_BEARING_FILES.has(basename(path))) {
skipped.push({ path, reason: 'load-bearing' });
continue;
}
toDelete.push({ path, hash });
}
return { toDelete, skipped };
}
// =============================================================================
// Applier — the single chokepoint that touches the filesystem
// =============================================================================
/**
* Apply a deletion list inside a stack directory.
*
* Structurally incapable of touching anything outside stackDir:
* every path is containment-checked, only regular files whose content still
* matches the recorded hash are unlinked, and directory cleanup uses rmdir
* (never recursive) so directories holding any other content survive.
*/
export function applyFileDeletions(stackDir: string, files: FileToDelete[]): DeletionApplyResult {
const root = resolve(stackDir);
const deleted: string[] = [];
const skipped: DeletionSkip[] = [];
const parentDirs = new Set<string>();
for (const { path, hash } of files) {
const abs = containedPath(root, path);
if (!abs) {
skipped.push({ path, reason: 'invalid-path' });
continue;
}
// Defense in depth: computeDeletions already filters these, but the
// applier also runs on lists from external sources (Hawser payloads).
if (LOAD_BEARING_FILES.has(basename(path))) {
skipped.push({ path, reason: 'load-bearing' });
continue;
}
let stat;
try {
stat = lstatSync(abs);
} catch {
skipped.push({ path, reason: 'already-absent' });
continue;
}
// Dockhand only writes regular files. Anything else (symlink, dir,
// socket) means the user replaced it — treat as locally modified.
if (!stat.isFile()) {
skipped.push({ path, reason: 'locally-modified' });
continue;
}
try {
if (hashContent(readFileSync(abs)) !== hash) {
skipped.push({ path, reason: 'locally-modified' });
continue;
}
unlinkSync(abs);
deleted.push(path);
} catch {
skipped.push({ path, reason: 'apply-failed' });
continue;
}
// Collect parent dir chain (inside root) for empty-dir cleanup
let dir = dirname(abs);
while (dir !== root && dir.startsWith(root + sep)) {
parentDirs.add(dir);
dir = dirname(dir);
}
}
// Deepest-first rmdir; fails harmlessly when a directory still has content
const dirsByDepth = [...parentDirs].sort((a, b) => b.length - a.length);
for (const dir of dirsByDepth) {
try {
rmdirSync(dir);
} catch {
// ENOTEMPTY/ENOENT/etc. — directory stays, which is always safe
}
}
return { deleted, skipped };
}
// =============================================================================
// Manifest evolution
// =============================================================================
/**
* Build the manifest to persist after a sync.
*
* Trivial by design: the manifest is always exactly the files written this
* sync, at this sync's commit. Skipped deletions are FINAL (see note above) —
* the affected files drop out of the manifest and become unmanaged residue.
*/
export function buildNextManifest(newCommit: string, newFiles: Record<string, string>): SyncManifest {
return { commit: newCommit, files: { ...newFiles } };
}
// =============================================================================
// Sync summary (per-file status table)
// =============================================================================
export function buildSyncChangeSummary(
previousFiles: Record<string, string>,
newFiles: Record<string, string>,
applyResult: DeletionApplyResult,
planSkipped: DeletionSkip[]
): SyncChangeSummary {
const changes: SyncFileChange[] = [];
let unchangedCount = 0;
for (const [path, hash] of Object.entries(newFiles)) {
const oldHash = previousFiles[path];
if (oldHash === undefined) {
changes.push({ file: path, status: 'added' });
} else if (oldHash !== hash) {
changes.push({ file: path, status: 'updated' });
} else {
unchangedCount++;
}
}
for (const path of applyResult.deleted) {
changes.push({ file: path, status: 'removed' });
}
// Benign "already absent" results are not interesting in the summary
const interestingSkips = [...planSkipped, ...applyResult.skipped].filter(
(s) => s.reason !== 'already-absent'
);
for (const skip of interestingSkips) {
changes.push({ file: skip.path, status: 'skipped', reason: skipReasonMessage(skip.reason) });
}
return { changes, unchangedCount };
}
/** Render the summary as aligned text lines for console and job output. */
export function formatChangeTable(summary: SyncChangeSummary): string[] {
const { changes, unchangedCount } = summary;
const counts = { added: 0, updated: 0, removed: 0, skipped: 0 };
for (const c of changes) counts[c.status]++;
const header = `${counts.added} added, ${counts.updated} updated, ${counts.removed} removed, ${counts.skipped} skipped, ${unchangedCount} unchanged`;
if (changes.length === 0) {
return [header];
}
const fileWidth = Math.min(60, Math.max(4, ...changes.map((c) => c.file.length)));
const lines = [header, `${'STATUS'.padEnd(9)} ${'FILE'.padEnd(fileWidth)} REASON`];
for (const c of changes) {
lines.push(`${c.status.padEnd(9)} ${c.file.padEnd(fileWidth)} ${c.reason ?? ''}`.trimEnd());
}
return lines;
}
+167 -3
View File
@@ -15,6 +15,21 @@ import {
type GitStackWithRepo
} from './db';
import { deployStack, getStackDir } from './stacks';
import {
parseManifest,
serializeManifest,
hashDirFiles,
computeDeletions,
buildNextManifest,
buildSyncChangeSummary,
formatChangeTable,
skipReasonMessage,
deletionSafetyCheck,
type DeletionPlan,
type DeletionApplyResult,
type DeletionSkip,
type SyncManifest
} from './git-deletions';
const MERGED_CA_BUNDLE_PATH = '/tmp/dockhand-merged-ca-bundle.crt';
let mergedCaBundleReady = false;
@@ -363,6 +378,93 @@ async function getChangedFilesInDir(
return { changed: changedFiles.length > 0, files: changedFiles };
}
/**
* Compute the deletion plan for a sync: hash the new clone's compose dir and
* diff against the manifest from the last sync. Deletions converge the deploy
* dir toward the clone state; the applier additionally verifies each file's
* disk hash. A sanity guard blocks ALL deletions when the clone walk looks
* broken (empty, or missing the compose file).
*/
async function computeSyncDeletionPlan(options: {
logPrefix: string;
composeDir: string; // absolute path inside the clone
composeFileName: string | undefined; // compose file relative to composeDir
rawManifest: string | null | undefined;
}): Promise<{ plan: DeletionPlan; newFiles: Record<string, string>; previousManifest: SyncManifest }> {
const { logPrefix, composeDir, composeFileName, rawManifest } = options;
const previousManifest = parseManifest(rawManifest);
const newFiles = hashDirFiles(composeDir);
const manifestSize = Object.keys(previousManifest.files).length;
console.log(`${logPrefix} Deletion sync: manifest has ${manifestSize} file(s)${manifestSize === 0 ? ' (first sync — nothing will be deleted)' : ''}`);
// First sync / legacy manifest: nothing was recorded, so nothing can be deleted
if (manifestSize === 0) {
return { plan: { toDelete: [], skipped: [] }, newFiles, previousManifest };
}
const blocked = deletionSafetyCheck(previousManifest.files, newFiles, composeFileName);
if (blocked) {
console.warn(`${logPrefix} Deletion sync: ${blocked}`);
return { plan: { toDelete: [], skipped: [] }, newFiles, previousManifest };
}
const plan = computeDeletions(previousManifest.files, newFiles);
for (const file of plan.toDelete) {
console.log(`${logPrefix} Deletion sync: will remove "${file.path}" — deleted from the repository`);
}
for (const skip of plan.skipped) {
console.warn(`${logPrefix} Deletion sync: keeping "${skip.path}" — ${skipReasonMessage(skip.reason)}`);
}
return { plan, newFiles, previousManifest };
}
/**
* Persist the manifest after a deploy and log the per-file change summary.
* Called only after a successful deploy (locally applied or agent-confirmed).
*/
async function finalizeDeletionSync(options: {
stackId: number;
logPrefix: string;
previousManifest: SyncManifest;
newCommitFull: string;
newFiles: Record<string, string>;
plan: DeletionPlan;
applyResult: DeletionApplyResult | undefined;
onLine?: (line: string) => void; // extra sink (job output)
}): Promise<void> {
const { stackId, logPrefix, previousManifest, newCommitFull, newFiles, plan, applyResult, onLine } = options;
// No apply result means deletions were requested but nothing reported back
// (defensive — executors always return one). Logged as skips; skips are final.
const effectiveApply: DeletionApplyResult = applyResult ?? {
deleted: [],
skipped: plan.toDelete.map((f): DeletionSkip => ({ path: f.path, reason: 'apply-failed' }))
};
// Pass only the plan-stage skips; buildSyncChangeSummary already merges
// in effectiveApply.skipped itself. Concatenating here duplicated every
// apply-stage skip (locally-modified, agent-no-support, apply-failed).
const summary = buildSyncChangeSummary(previousManifest.files, newFiles, effectiveApply, plan.skipped);
const tableLines = formatChangeTable(summary);
console.log(`${logPrefix} Sync file changes: ${tableLines[0]}`);
for (const line of tableLines.slice(1)) {
console.log(`${logPrefix} ${line}`);
}
if (onLine) {
onLine(`File changes: ${tableLines[0]}`);
for (const line of tableLines.slice(1)) onLine(line);
}
const nextManifest = buildNextManifest(newCommitFull, newFiles);
await updateGitStack(stackId, { syncedFiles: serializeManifest(nextManifest) });
console.log(`${logPrefix} Manifest persisted: ${Object.keys(nextManifest.files).length} file(s) at commit ${nextManifest.commit?.substring(0, 7)}`);
}
export interface SyncResult {
success: boolean;
commit?: string;
@@ -375,6 +477,11 @@ export interface SyncResult {
error?: string;
updated?: boolean;
changedFiles?: string[]; // List of files that changed (for logging/debugging)
// Deletion sync (#966/#1162): manifest-vs-clone data
deletionPlan?: DeletionPlan; // Files safe to delete (manifest entries absent from the new clone) + plan-stage skips
newFiles?: Record<string, string>; // path → sha256 of files in the new clone (next manifest)
newCommitFull?: string; // Full 40-char commit hash (manifest commit)
previousManifest?: SyncManifest; // Manifest from the last successful sync
}
export interface TestResult {
@@ -954,6 +1061,14 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
console.log(`${logPrefix} No env file path configured`);
}
// Deletion sync (#966): manifest-vs-clone deletion plan
const deletionData = await computeSyncDeletionPlan({
logPrefix,
composeDir,
composeFileName,
rawManifest: gitStack.syncedFiles
});
// Update git stack status
await updateGitStack(stackId, {
syncStatus: 'synced',
@@ -982,7 +1097,11 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
envFileVars,
envFileName,
updated,
changedFiles
changedFiles,
deletionPlan: deletionData.plan,
newFiles: deletionData.newFiles,
newCommitFull: newCommit,
previousManifest: deletionData.previousManifest
};
} catch (error: any) {
cleanupSshKey(credential);
@@ -1065,7 +1184,8 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
forceRecreate,
build: gitStack.buildOnDeploy,
noBuildCache: gitStack.noBuildCache,
pullPolicy: gitStack.repullImages ? 'always' : undefined
pullPolicy: gitStack.repullImages ? 'always' : undefined,
filesToDelete: syncResult.deletionPlan?.toDelete
});
console.log(`${logPrefix} ----------------------------------------`);
@@ -1076,6 +1196,19 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
if (result.error) console.log(`${logPrefix} Error:`, result.error);
if (result.success) {
// Deletion sync: persist manifest + log per-file change summary
if (syncResult.previousManifest && syncResult.newFiles && syncResult.newCommitFull && syncResult.deletionPlan) {
await finalizeDeletionSync({
stackId,
logPrefix,
previousManifest: syncResult.previousManifest,
newCommitFull: syncResult.newCommitFull,
newFiles: syncResult.newFiles,
plan: syncResult.deletionPlan,
applyResult: result.deletion
});
}
// Record the stack source with resolved compose path for consistency
const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
const resolvedComposePath = syncResult.composeFileName
@@ -1306,6 +1439,15 @@ export async function deployGitStackWithProgress(
}
}
// Deletion sync (#966): manifest-vs-clone deletion plan
const logPrefix = `[Stack:${gitStack.stackName}]`;
const deletionData = await computeSyncDeletionPlan({
logPrefix,
composeDir,
composeFileName: progressComposeFileName,
rawManifest: gitStack.syncedFiles
});
// Update git stack status
await updateGitStack(stackId, {
syncStatus: 'synced',
@@ -1319,6 +1461,14 @@ export async function deployGitStackWithProgress(
// Step 5: Deploying stack
// Uses `docker compose up -d --remove-orphans` which only recreates changed services
onProgress({ status: 'deploying', message: `Deploying ${gitStack.stackName}...`, step: 5, totalSteps });
if (deletionData.plan.toDelete.length > 0) {
onProgress({
status: 'deploying',
message: `Removing ${deletionData.plan.toDelete.length} file(s) deleted from the repository...`,
step: 5,
totalSteps
});
}
// Determine env filename relative to compose dir (same logic as syncGitStack)
let envFileName: string | undefined;
@@ -1338,10 +1488,24 @@ export async function deployGitStackWithProgress(
envFileName, // Env file relative to compose dir (for --env-file flag, optional)
build: gitStack.buildOnDeploy,
noBuildCache: gitStack.noBuildCache,
pullPolicy: gitStack.repullImages ? 'always' : undefined
pullPolicy: gitStack.repullImages ? 'always' : undefined,
filesToDelete: deletionData.plan.toDelete
});
if (result.success) {
// Deletion sync: persist manifest + log per-file change summary.
// onLine feeds the per-file change table into the deploy progress popover.
await finalizeDeletionSync({
stackId,
logPrefix,
previousManifest: deletionData.previousManifest,
newCommitFull: newCommit,
newFiles: deletionData.newFiles,
plan: deletionData.plan,
applyResult: result.deletion,
onLine: (line) => onProgress({ status: 'deploying', message: line, step: 5, totalSteps })
});
// Record the stack source with resolved compose path for consistency
const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
const resolvedComposePath = join(stackDir, progressComposeFileName);
+8 -1
View File
@@ -8,8 +8,9 @@
import { db, hawserTokens, environments, eq, and } from './db/drizzle.js';
import { logContainerEvent, type ContainerEventAction } from './db.js';
import { containerEventEmitter } from './event-collector.js';
import { sendEnvironmentNotification } from './notifications.js';
import { sendEnvironmentNotification } from './notifications/index.js';
import { isNotifyDisabledByLabel } from './container-labels.js';
import { isHealthTransition } from './subprocess-manager.js';
import { pushMetric } from './metrics-store.js';
import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js';
import { hashPassword, verifyPassword } from './auth.js';
@@ -178,6 +179,12 @@ export async function handleEdgeContainerEvent(
// Log the event
console.log(`[Hawser] Container event from env ${environmentId}: ${event.action} ${event.containerName || event.containerId}`);
// Only store health_status events on transitions (healthy↔unhealthy)
// to avoid flooding the DB with repeated identical health checks
if (!isHealthTransition(environmentId, event.containerId, event.action)) {
return;
}
// Save to database
const savedEvent = await logContainerEvent({
environmentId,
-822
View File
@@ -1,822 +0,0 @@
import nodemailer from 'nodemailer';
import {
getEnabledNotificationSettings,
getEnabledEnvironmentNotifications,
getEnvironment,
type NotificationSettingData,
type SmtpConfig,
type AppriseConfig,
type NotificationEventType
} from './db';
import { escapeTelegramMarkdown, parseTelegramUrl, buildGotifyUrl, parseWorkflowsUrl, buildWorkflowsHttpUrl } from '$lib/utils/notification-parsers';
/** Drain a response body to release the underlying socket/TLS connection. */
async function drainResponse(response: Response): Promise<void> {
if (!response.bodyUsed) {
try { await response.arrayBuffer(); } catch {}
}
}
export interface NotificationPayload {
title: string;
message: string;
type?: 'info' | 'success' | 'warning' | 'error';
environmentId?: number;
environmentName?: string;
}
// Result type for functions that can return detailed errors
export interface NotificationResult {
success: boolean;
error?: string;
}
// Send notification via SMTP
async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
try {
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.username ? {
user: config.username,
pass: config.password
} : undefined,
tls: config.skipTlsVerify ? {
rejectUnauthorized: false
} : undefined
});
const envBadge = payload.environmentName
? `<span style="display: inline-block; background: #3b82f6; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">${payload.environmentName}</span>`
: '';
const envText = payload.environmentName ? ` [${payload.environmentName}]` : '';
const html = `
<div style="font-family: sans-serif; padding: 20px;">
<h2 style="margin: 0 0 10px 0;">${payload.title}${envBadge}</h2>
<p style="margin: 0; white-space: pre-wrap;">${payload.message}</p>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
<p style="margin: 0; font-size: 12px; color: #666;">Sent by Dockhand</p>
</div>
`;
await transporter.sendMail({
from: config.from_name ? `"${config.from_name}" <${config.from_email}>` : config.from_email,
to: config.to_emails.join(', '),
subject: `[Dockhand]${envText} ${payload.title}`,
text: `${payload.title}${envText}\n\n${payload.message}`,
html
});
return { success: true };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return { success: false, error: `SMTP error: ${errorMsg}` };
}
}
// Parse Apprise URL and send notification
async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise<NotificationResult> {
const errors: string[] = [];
for (const url of config.urls) {
try {
const result = await sendToAppriseUrl(url, payload);
if (!result.success && result.error) {
errors.push(result.error);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
errors.push(`Failed to send: ${errorMsg}`);
}
}
if (errors.length > 0) {
return { success: false, error: errors.join('; ') };
}
return { success: true };
}
// Send to a single Apprise URL
async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise<NotificationResult> {
try {
// Extract protocol from Apprise URL format (protocol://...)
// Note: Can't use new URL() because custom schemes like 'tgram://' are not valid URLs
const protocolMatch = url.match(/^([a-z]+):\/\//i);
if (!protocolMatch) {
return { success: false, error: 'Invalid Apprise URL format - missing protocol' };
}
const protocol = protocolMatch[1].toLowerCase();
// Handle different notification services
switch (protocol) {
case 'discord':
case 'discords':
return await sendDiscord(url, payload);
case 'slack':
case 'slacks':
return await sendSlack(url, payload);
case 'mmost':
case 'mmosts':
return await sendMattermost(url, payload);
case 'tgram':
return await sendTelegram(url, payload);
case 'gotify':
case 'gotifys':
return await sendGotify(url, payload);
case 'ntfy':
case 'ntfys':
return await sendNtfy(url, payload);
case 'bark':
case 'barks':
return await sendBark(url, payload);
case 'pushover':
return await sendPushover(url, payload);
case 'json':
case 'jsons':
return await sendGenericWebhook(url, payload);
case 'workflows':
return await sendWorkflows(url, payload);
default:
return { success: false, error: `Unsupported Apprise protocol: ${protocol}` };
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return { success: false, error: `Failed to parse Apprise URL: ${errorMsg}` };
}
}
// Discord webhook
async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// discord://webhook_id/webhook_token or discords://...
const url = appriseUrl.replace(/^discords?:\/\//, 'https://discord.com/api/webhooks/');
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [{
title: titleWithEnv,
description: payload.message,
color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff,
...(payload.environmentName && {
footer: { text: `Environment: ${payload.environmentName}` }
})
}]
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Discord error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Discord connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Slack webhook
async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// slack://token_a/token_b/token_c or webhook URL
let url: string;
if (appriseUrl.includes('hooks.slack.com')) {
url = appriseUrl.replace(/^slacks?:\/\//, 'https://');
} else {
const parts = appriseUrl.replace(/^slacks?:\/\//, '').split('/');
url = `https://hooks.slack.com/services/${parts.join('/')}`;
}
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `*${payload.title}*${envTag}\n${payload.message}`
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Slack error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Slack connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Mattermost webhook
async function sendMattermost(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// mmost://[botname@]hostname[:port][/path]/token or mmosts://...
const isSecure = appriseUrl.startsWith('mmosts');
const protocol = isSecure ? 'https' : 'http';
// Remove the scheme
let urlPart = appriseUrl.replace(/^mmosts?:\/\//, '');
// Check for botname (username@hostname format)
let username: string | undefined;
const atIndex = urlPart.indexOf('@');
if (atIndex !== -1) {
username = urlPart.substring(0, atIndex);
urlPart = urlPart.substring(atIndex + 1);
}
// The token is the last segment, everything else is hostname[:port][/path]
const lastSlashIndex = urlPart.lastIndexOf('/');
if (lastSlashIndex === -1) {
return { success: false, error: 'Invalid Mattermost URL format. Expected: mmost://[botname@]hostname[:port][/path]/token' };
}
const token = urlPart.substring(lastSlashIndex + 1);
const hostAndPath = urlPart.substring(0, lastSlashIndex);
// Build the webhook URL: {protocol}://{hostname}[:{port}][/{path}]/hooks/{token}
const url = `${protocol}://${hostAndPath}/hooks/${token}`;
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
const body: Record<string, string> = {
text: `*${payload.title}*${envTag}\n${payload.message}`
};
if (username) {
body.username = username;
}
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Mattermost error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Mattermost connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Telegram
async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const parsed = parseTelegramUrl(appriseUrl);
if (!parsed) {
return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id or tgram://bot_token/chat_id:topic_id' };
}
const { botToken, chatId, topicId } = parsed;
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
// Escape markdown special characters in title and message
const escapedTitle = escapeTelegramMarkdown(payload.title);
const escapedMessage = escapeTelegramMarkdown(payload.message);
const envTag = payload.environmentName ? ` [${escapeTelegramMarkdown(payload.environmentName)}]` : '';
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: `*${escapedTitle}*${envTag}\n${escapedMessage}`,
...(topicId ? { message_thread_id: topicId } : {}),
parse_mode: 'Markdown',
link_preview_options: {
is_disabled: true
}
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({})) as { description?: string };
const errorMsg = errorData.description || response.statusText;
return { success: false, error: `Telegram error ${response.status}: ${errorMsg}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Telegram connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Gotify
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const parsed = buildGotifyUrl(appriseUrl);
if (!parsed) {
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
const defaultPriority = payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2;
try {
const response = await fetch(parsed.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: titleWithEnv,
message: payload.message,
priority: parsed.priority ?? defaultPriority
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Gotify error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Gotify connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// ntfy
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)
// Query params: ?tags=ship,whale &title=Custom &priority=5
// ntfys:// variants for HTTPS
const isSecure = appriseUrl.startsWith('ntfys');
const path = appriseUrl.replace(/^ntfys?:\/\//, '');
let url: string;
let authHeader: string | null = null;
// Extract query parameters (?auth=, ?tags=, ?title=, ?priority=)
let queryAuth: string | null = null;
let queryTags: string | null = null;
let queryTitle: string | null = null;
let queryPriority: string | null = null;
let cleanPath = path;
const qIndex = path.indexOf('?');
if (qIndex !== -1) {
const params = new URLSearchParams(path.substring(qIndex + 1));
queryAuth = params.get('auth');
queryTags = params.get('tags');
queryTitle = params.get('title');
queryPriority = params.get('priority');
cleanPath = path.substring(0, qIndex);
}
// Check for user:pass@host/topic format (Basic auth)
const basicMatch = cleanPath.match(/^([^:]+):([^@]+)@(.+)$/);
if (basicMatch) {
const [, user, pass, hostAndTopic] = basicMatch;
const basic = Buffer.from(`${user}:${pass}`).toString('base64');
authHeader = `Basic ${basic}`;
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
} else if (cleanPath.includes('@') && cleanPath.includes('/')) {
// token@host/topic -> Bearer token auth
const tokenMatch = cleanPath.match(/^([^@]+)@(.+)$/);
if (tokenMatch) {
const [, token, hostAndTopic] = tokenMatch;
authHeader = `Bearer ${token}`;
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
} else {
// Fallback to custom server without auth
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
}
} else if (cleanPath.includes('/')) {
// Custom server without auth
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
} else {
// Default ntfy.sh
url = `https://ntfy.sh/${cleanPath}`;
}
// Apply ?auth= as fallback if no explicit auth was set
if (!authHeader && queryAuth) {
const decoded = Buffer.from(queryAuth, 'base64').toString();
authHeader = decoded.startsWith('Bearer ') ? decoded : `Bearer ${decoded}`;
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
const defaultTags = payload.type || 'info';
const headers: Record<string, string> = {
'Title': queryTitle || titleWithEnv,
'Priority': queryPriority || (payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3'),
'Tags': queryTags ? `${queryTags},${defaultTags}` : defaultTags
};
if (authHeader) {
headers['Authorization'] = authHeader;
}
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: payload.message
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `ntfy error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `ntfy connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Bark
async function sendBark(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// Supported formats:
// bark://device_key (official api.day.app server)
// bark://host/device_key (custom server over HTTP)
// barks://host/device_key (custom server over HTTPS)
const isSecure = appriseUrl.startsWith('barks');
const path = appriseUrl.replace(/^barks?:\/\//, '');
let url: string;
let deviceKey: string;
if (!path.includes('/')) {
if (!path) {
return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' };
}
url = 'https://api.day.app/push';
deviceKey = path;
} else {
const parts = path.split('/');
if (parts.length !== 2) {
return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' };
}
const [host, key] = parts;
deviceKey = key;
if (!host || !deviceKey) {
return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' };
}
url = `${isSecure ? 'https' : 'http'}://${host}/push`;
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
const body: Record<string, string> = {
device_key: deviceKey,
title: titleWithEnv,
body: payload.message
};
if (payload.type === 'error') {
body.level = 'timeSensitive';
}
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Bark error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Bark connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Pushover
async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// pushover://user_key/api_token
const match = appriseUrl.match(/^pushover:\/\/([^/]+)\/(.+)/);
if (!match) {
return { success: false, error: 'Invalid Pushover URL format. Expected: pushover://user_key/api_token' };
}
const [, userKey, apiToken] = match;
const url = 'https://api.pushover.net/1/messages.json';
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: apiToken,
user: userKey,
title: titleWithEnv,
message: payload.message,
priority: payload.type === 'error' ? 1 : 0
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Pushover error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Pushover connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Generic JSON webhook
async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// json://hostname/path or jsons://hostname/path
const url = appriseUrl.replace(/^jsons?:\/\//, appriseUrl.startsWith('jsons') ? 'https://' : 'http://');
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: payload.title,
message: payload.message,
type: payload.type || 'info',
environment: payload.environmentName || null,
timestamp: new Date().toISOString()
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Webhook error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Webhook connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Microsoft Power Automate Workflows, for e.g. Microsoft Teams
async function sendWorkflows(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const parsed = parseWorkflowsUrl(appriseUrl);
if (!parsed) {
return { success: false, error: 'Invalid Workflows URL format. Expected: workflows://hostname/workflow/signature' };
}
const url = buildWorkflowsHttpUrl(parsed.hostname, parsed.workflow, parsed.signature);
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'message',
attachments: [
{
contentType: 'application/vnd.microsoft.card.adaptive',
content: {
$schema: 'https://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard',
version: '1.2',
body: [
{
type: 'TextBlock',
style: 'heading',
wrap: true,
text: titleWithEnv
},
{
type: 'TextBlock',
style: 'default',
wrap: true,
text: payload.message
}
]
}
}
]
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Workflows error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Workflows connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
// Send notification to all enabled channels
export async function sendNotification(payload: NotificationPayload): Promise<{ success: boolean; results: { name: string; success: boolean }[] }> {
const settings = await getEnabledNotificationSettings();
const results: { name: string; success: boolean }[] = [];
for (const setting of settings) {
let result: NotificationResult = { success: false };
if (setting.type === 'smtp') {
result = await sendSmtpNotification(setting.config as SmtpConfig, payload);
} else if (setting.type === 'apprise') {
result = await sendAppriseNotification(setting.config as AppriseConfig, payload);
}
results.push({ name: setting.name, success: result.success });
}
return {
success: results.every(r => r.success),
results
};
}
// Test a specific notification setting
export async function testNotification(setting: NotificationSettingData): Promise<NotificationResult> {
const payload: NotificationPayload = {
title: 'Dockhand Test Notification',
message: 'This is a test notification from Dockhand. If you receive this, your notification settings are configured correctly.',
type: 'info'
};
if (setting.type === 'smtp') {
return await sendSmtpNotification(setting.config as SmtpConfig, payload);
} else if (setting.type === 'apprise') {
return await sendAppriseNotification(setting.config as AppriseConfig, payload);
}
return { success: false, error: 'Unknown notification type' };
}
// Map Docker action to notification event type
function mapActionToEventType(action: string): NotificationEventType | null {
const mapping: Record<string, NotificationEventType> = {
'start': 'container_started',
'stop': 'container_stopped',
'restart': 'container_restarted',
'die': 'container_exited',
'kill': 'container_exited',
'oom': 'container_oom',
'health_status: unhealthy': 'container_unhealthy',
'health_status: healthy': 'container_healthy',
'pull': 'image_pulled'
};
return mapping[action] || null;
}
// Scanner image patterns to exclude from notifications
const SCANNER_IMAGE_PATTERNS = [
'anchore/grype',
'aquasec/trivy',
'ghcr.io/anchore/grype',
'ghcr.io/aquasecurity/trivy'
];
function isScannerContainer(image: string | null | undefined): boolean {
if (!image) return false;
const lowerImage = image.toLowerCase();
return SCANNER_IMAGE_PATTERNS.some(pattern => lowerImage.includes(pattern.toLowerCase()));
}
// Send notification for an environment-specific event
export async function sendEnvironmentNotification(
environmentId: number,
action: string,
payload: Omit<NotificationPayload, 'environmentId' | 'environmentName'>,
image?: string | null
): Promise<{ success: boolean; sent: number }> {
const eventType = mapActionToEventType(action);
if (!eventType) {
// Not a notifiable event type
return { success: true, sent: 0 };
}
// Get environment name
const env = await getEnvironment(environmentId);
if (!env) {
return { success: false, sent: 0 };
}
// Get enabled notification channels for this environment and event type
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
if (envNotifications.length === 0) {
return { success: true, sent: 0 };
}
const enrichedPayload: NotificationPayload = {
...payload,
environmentId,
environmentName: env.name
};
// Check if this is a scanner container
const isScanner = isScannerContainer(image);
let sent = 0;
let allSuccess = true;
// Skip all notifications for scanner containers (Trivy, Grype)
if (isScanner) {
return { success: true, sent: 0 };
}
for (const notif of envNotifications) {
try {
let result: NotificationResult = { success: false };
if (notif.channelType === 'smtp') {
result = await sendSmtpNotification(notif.config as SmtpConfig, enrichedPayload);
} else if (notif.channelType === 'apprise') {
result = await sendAppriseNotification(notif.config as AppriseConfig, enrichedPayload);
}
if (result.success) sent++;
else allSuccess = false;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[Notifications] Failed to send to channel ${notif.channelName}:`, errorMsg);
allSuccess = false;
}
}
return { success: allSuccess, sent };
}
// Send notification for a specific event type (not mapped from Docker action)
// Used for auto-update, git sync, vulnerability, and system events
export async function sendEventNotification(
eventType: NotificationEventType,
payload: NotificationPayload,
environmentId?: number
): Promise<{ success: boolean; sent: number }> {
// Get environment name if provided
let enrichedPayload = { ...payload };
if (environmentId) {
const env = await getEnvironment(environmentId);
if (env) {
enrichedPayload.environmentId = environmentId;
enrichedPayload.environmentName = env.name;
}
}
// Get enabled notification channels for this event type
let channels: Array<{
channel_type: 'smtp' | 'apprise';
channel_name: string;
config: SmtpConfig | AppriseConfig;
}> = [];
if (environmentId) {
// Environment-specific: get channels subscribed to this env and event type
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
channels = envNotifications
.filter(n => n.channelType && n.channelName)
.map(n => ({
channel_type: n.channelType!,
channel_name: n.channelName!,
config: n.config
}));
} else {
// System-wide: get all globally enabled channels that subscribe to this event type
const globalSettings = await getEnabledNotificationSettings();
channels = globalSettings
.filter(s => s.eventTypes?.includes(eventType))
.map(s => ({
channel_type: s.type,
channel_name: s.name,
config: s.config
}));
}
if (channels.length === 0) {
return { success: true, sent: 0 };
}
let sent = 0;
let allSuccess = true;
for (const channel of channels) {
try {
let result: NotificationResult = { success: false };
if (channel.channel_type === 'smtp') {
result = await sendSmtpNotification(channel.config as SmtpConfig, enrichedPayload);
} else if (channel.channel_type === 'apprise') {
result = await sendAppriseNotification(channel.config as AppriseConfig, enrichedPayload);
}
if (result.success) sent++;
else allSuccess = false;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[Notifications] Failed to send to channel ${channel.channel_name}:`, errorMsg);
allSuccess = false;
}
}
return { success: allSuccess, sent };
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Apprise passthrough — POST to a self-hosted caronc/apprise-api server.
*
* Users configure all their providers (Signal, Matrix, MQTT, IFTTT, AWS SNS,
* dozens more) in their own Apprise server; Dockhand just forwards each
* notification once. The big win: every provider Apprise upstream supports
* is now reachable from Dockhand without us having to write a sender for it.
*
* Supported formats:
* apprise://host[:port]/key → HTTP, stateful (Apprise stored config key)
* apprises://host[:port]/key → HTTPS variant
* apprise://host[:port]/prefix/key → path-prefixed Apprise behind a reverse proxy
* apprise://host[:port]/key?tag=devops → optional tag filter
*
* Setup docs: https://github.com/caronc/apprise-api
*/
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendApprise(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const isSecure = appriseUrl.startsWith('apprises');
const raw = appriseUrl.replace(/^apprises?:\/\//, '');
let cleanPath = raw;
let queryParams = new URLSearchParams();
const qIndex = raw.indexOf('?');
if (qIndex !== -1) {
queryParams = new URLSearchParams(raw.substring(qIndex + 1));
cleanPath = raw.substring(0, qIndex);
}
const parts = cleanPath.split('/').filter(Boolean);
if (parts.length < 2) {
return { success: false, error: 'Invalid Apprise URL. Expected: apprise://host[:port]/key' };
}
const hostPort = parts[0];
// The Apprise key is the last path segment. Anything between host and key
// is a path prefix (some users mount Apprise behind a reverse proxy
// at /apprise/ — we preserve that).
const key = parts[parts.length - 1];
const pathPrefix = parts.slice(1, -1).join('/');
const baseUrl = `${isSecure ? 'https' : 'http'}://${hostPort}${pathPrefix ? '/' + pathPrefix : ''}`;
// Map our payload type to Apprise's NotifyType. 'error' → 'failure' is
// the only rename; everything else lines up.
const apprisesType = payload.type === 'error'
? 'failure'
: payload.type === 'warning'
? 'warning'
: payload.type === 'success'
? 'success'
: 'info';
const titleWithEnv = payload.environmentName
? `${payload.title} [${payload.environmentName}]`
: payload.title;
const body: Record<string, unknown> = {
title: titleWithEnv,
body: payload.message,
type: apprisesType
};
const tag = queryParams.get('tag');
if (tag) body.tag = tag;
const format = queryParams.get('format');
if (format) body.format = format; // text | markdown | html
try {
const response = await fetch(`${baseUrl}/notify/${encodeURIComponent(key)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
// Apprise-API uses specific status codes:
// 200 → success, 204 → key not configured, 424 → at least one
// downstream provider failed or tag didn't match.
if (response.status === 204) {
return { success: false, error: `Apprise: no configuration found for key "${key}"` };
}
if (response.status === 424) {
const text = await response.text().catch(() => '');
return { success: false, error: `Apprise: at least one downstream provider failed${text ? ` — ${text}` : ''}` };
}
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Apprise error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Apprise connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Bark — iOS push via bark-server (https://github.com/Finb/bark-server).
*
* Supported formats:
* bark://device_key → uses official api.day.app over HTTPS
* bark://host/device_key → custom server over HTTP
* bark://host[:port]/k1/k2/... → multi-device batch (Apprise convention)
* barks://host[:port]/... → HTTPS variant
*
* Query params honored (per https://bark.day.app/#/en-us/tutorial):
* ?sound=name, ?level=active|timeSensitive|critical|passive,
* ?group=, ?icon=, ?url=, ?badge=N, ?copy=, ?subtitle=,
* ?volume=, ?ttl=, ?call=1, ?autoCopy=1, ?isArchive=1, ?action=none
*/
import type { NotificationPayload, NotificationResult } from './shared';
export async function sendBark(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const isSecure = appriseUrl.startsWith('barks');
const path = appriseUrl.replace(/^barks?:\/\//, '');
// Split off query string before slicing the path so '?' in a device key
// (in principle possible, though Bark's keys are 22-char base62) doesn't
// confuse the parser.
let cleanPath = path;
let queryParams = new URLSearchParams();
const qIndex = path.indexOf('?');
if (qIndex !== -1) {
queryParams = new URLSearchParams(path.substring(qIndex + 1));
cleanPath = path.substring(0, qIndex);
}
if (!cleanPath) {
return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' };
}
let baseUrl: string;
let deviceKeys: string[];
if (!cleanPath.includes('/')) {
// bark://device_key → official server, HTTPS regardless of bark:// vs barks://
baseUrl = 'https://api.day.app';
deviceKeys = [cleanPath];
} else {
const parts = cleanPath.split('/').filter(Boolean);
if (parts.length < 2) {
return { success: false, error: 'Invalid Bark URL format. Expected: bark://device_key, bark://host/device_key, or barks://host/device_key' };
}
const hostPort = parts[0];
deviceKeys = parts.slice(1);
baseUrl = `${isSecure ? 'https' : 'http'}://${hostPort}`;
}
// Map our payload type to Bark's `level`. Query-supplied level wins.
// info → active (banner + sound, doesn't bypass DND)
// warning → timeSensitive (cuts through Focus modes)
// error → critical (cuts through silent mode; user must enable)
const defaultLevel = payload.type === 'error'
? 'critical'
: payload.type === 'warning'
? 'timeSensitive'
: 'active';
const level = queryParams.get('level') || defaultLevel;
const titleWithEnv = payload.environmentName
? `${payload.title} [${payload.environmentName}]`
: payload.title;
const body: Record<string, unknown> = {
title: titleWithEnv,
body: payload.message,
level
};
// Single-target uses device_key; batch uses device_keys (per Bark API v2).
if (deviceKeys.length === 1) {
body.device_key = deviceKeys[0];
} else {
body.device_keys = deviceKeys;
}
// String passthroughs Bark understands. Unknown params are dropped on the
// server side anyway so no point forwarding them.
const passthroughString = ['sound', 'group', 'icon', 'url', 'copy', 'subtitle', 'category', 'ciphertext', 'isArchive', 'autoCopy', 'call', 'action', 'volume'];
for (const key of passthroughString) {
const v = queryParams.get(key);
if (v !== null && v !== '') body[key] = v;
}
// Numeric passthroughs.
for (const key of ['badge', 'ttl']) {
const v = queryParams.get(key);
if (v !== null && v !== '') {
const n = parseInt(v, 10);
if (!Number.isNaN(n)) body[key] = n;
}
}
try {
const response = await fetch(`${baseUrl}/push`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(body)
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Bark error ${response.status}: ${text || response.statusText}` };
}
// Bark returns HTTP 200 with { code, message, timestamp } — `code !== 200`
// signals a logical failure (e.g. invalid device key) that we'd otherwise
// swallow as a success.
const json: any = await response.json().catch(() => null);
if (json && typeof json.code === 'number' && json.code !== 200) {
return { success: false, error: `Bark error: ${json.message || `code ${json.code}`}` };
}
return { success: true };
} catch (error) {
return { success: false, error: `Bark connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+34
View File
@@ -0,0 +1,34 @@
/** Discord webhook notifications. discord:// or discords://. */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendDiscord(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// discord://webhook_id/webhook_token or discords://...
const url = appriseUrl.replace(/^discords?:\/\//, 'https://discord.com/api/webhooks/');
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
embeds: [{
title: titleWithEnv,
description: payload.message,
color: payload.type === 'error' ? 0xff0000 : payload.type === 'warning' ? 0xffaa00 : payload.type === 'success' ? 0x00ff00 : 0x0099ff,
...(payload.environmentName && {
footer: { text: `Environment: ${payload.environmentName}` }
})
}]
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Discord error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Discord connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
@@ -0,0 +1,30 @@
/** Generic JSON webhook. json:// or jsons:// (HTTPS). */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendGenericWebhook(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// json://hostname/path or jsons://hostname/path
const url = appriseUrl.replace(/^jsons?:\/\//, appriseUrl.startsWith('jsons') ? 'https://' : 'http://');
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: payload.title,
message: payload.message,
type: payload.type || 'info',
environment: payload.environmentName || null,
timestamp: new Date().toISOString()
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Webhook error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Webhook connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+34
View File
@@ -0,0 +1,34 @@
/** Gotify. gotify:// or gotifys:// (HTTPS). */
import { buildGotifyUrl } from '$lib/utils/notification-parsers';
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const parsed = buildGotifyUrl(appriseUrl);
if (!parsed) {
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
const defaultPriority = payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2;
try {
const response = await fetch(parsed.url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: titleWithEnv,
message: payload.message,
priority: parsed.priority ?? defaultPriority
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Gotify error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Gotify connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+314
View File
@@ -0,0 +1,314 @@
/**
* Notification router — picks the right per-provider sender based on the
* channel type (SMTP / Apprise URL) and (for Apprise URLs) the URL scheme.
*
* Public surface used by API routes and the rest of the app:
* - sendNotification (fan out to every enabled channel)
* - testNotification (one channel, with a fixed test payload)
* - sendEnvironmentNotification (Docker container event → matching channels)
* - sendEventNotification (auto-update / git / vuln / system events)
* - NotificationPayload, NotificationResult types
*
* Per-provider implementations live in sibling files (./bark, ./discord, …).
* This file orchestrates only — it never knows what's inside a Bark or
* Telegram URL.
*/
import {
getEnabledNotificationSettings,
getEnabledEnvironmentNotifications,
getEnvironment,
type NotificationSettingData,
type SmtpConfig,
type AppriseConfig,
type NotificationEventType
} from '../db';
import type { NotificationPayload, NotificationResult } from './shared';
export type { NotificationPayload, NotificationResult } from './shared';
import { sendSmtpNotification } from './smtp';
import { sendDiscord } from './discord';
import { sendSlack } from './slack';
import { sendMattermost } from './mattermost';
import { sendTelegram } from './telegram';
import { sendGotify } from './gotify';
import { sendNtfy } from './ntfy';
import { sendBark } from './bark';
import { sendSignal } from './signal';
import { sendApprise } from './apprise';
import { sendPushover } from './pushover';
import { sendGenericWebhook } from './generic-webhook';
import { sendWorkflows } from './workflows';
// Send to every URL in an Apprise channel. Errors are aggregated so a single
// bad URL doesn't silently mask a healthy one.
async function sendAppriseNotification(config: AppriseConfig, payload: NotificationPayload): Promise<NotificationResult> {
const errors: string[] = [];
for (const url of config.urls) {
try {
const result = await sendToAppriseUrl(url, payload);
if (!result.success && result.error) {
errors.push(result.error);
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
errors.push(`Failed to send: ${errorMsg}`);
}
}
if (errors.length > 0) {
return { success: false, error: errors.join('; ') };
}
return { success: true };
}
// Route a single Apprise URL to the right sender. The switch is the ONLY
// place that needs to grow when a new provider is added.
async function sendToAppriseUrl(url: string, payload: NotificationPayload): Promise<NotificationResult> {
try {
// Custom schemes like 'tgram://' aren't valid URLs to new URL(),
// so we match the prefix directly.
const protocolMatch = url.match(/^([a-z]+):\/\//i);
if (!protocolMatch) {
return { success: false, error: 'Invalid Apprise URL format - missing protocol' };
}
const protocol = protocolMatch[1].toLowerCase();
switch (protocol) {
case 'discord':
case 'discords':
return await sendDiscord(url, payload);
case 'slack':
case 'slacks':
return await sendSlack(url, payload);
case 'mmost':
case 'mmosts':
return await sendMattermost(url, payload);
case 'tgram':
return await sendTelegram(url, payload);
case 'gotify':
case 'gotifys':
return await sendGotify(url, payload);
case 'ntfy':
case 'ntfys':
return await sendNtfy(url, payload);
case 'bark':
case 'barks':
return await sendBark(url, payload);
case 'signal':
case 'signals':
return await sendSignal(url, payload);
case 'apprise':
case 'apprises':
return await sendApprise(url, payload);
case 'pushover':
return await sendPushover(url, payload);
case 'json':
case 'jsons':
return await sendGenericWebhook(url, payload);
case 'workflows':
return await sendWorkflows(url, payload);
default:
return { success: false, error: `Unsupported Apprise protocol: ${protocol}` };
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return { success: false, error: `Failed to parse Apprise URL: ${errorMsg}` };
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export async function sendNotification(payload: NotificationPayload): Promise<{ success: boolean; results: { name: string; success: boolean }[] }> {
const settings = await getEnabledNotificationSettings();
const results: { name: string; success: boolean }[] = [];
for (const setting of settings) {
let result: NotificationResult = { success: false };
if (setting.type === 'smtp') {
result = await sendSmtpNotification(setting.config as SmtpConfig, payload);
} else if (setting.type === 'apprise') {
result = await sendAppriseNotification(setting.config as AppriseConfig, payload);
}
results.push({ name: setting.name, success: result.success });
}
return {
success: results.every(r => r.success),
results
};
}
export async function testNotification(setting: NotificationSettingData): Promise<NotificationResult> {
const payload: NotificationPayload = {
title: 'Dockhand Test Notification',
message: 'This is a test notification from Dockhand. If you receive this, your notification settings are configured correctly.',
type: 'info'
};
if (setting.type === 'smtp') {
return await sendSmtpNotification(setting.config as SmtpConfig, payload);
} else if (setting.type === 'apprise') {
return await sendAppriseNotification(setting.config as AppriseConfig, payload);
}
return { success: false, error: 'Unknown notification type' };
}
// Map Docker action to notification event type
function mapActionToEventType(action: string): NotificationEventType | null {
const mapping: Record<string, NotificationEventType> = {
'start': 'container_started',
'stop': 'container_stopped',
'restart': 'container_restarted',
'die': 'container_exited',
'kill': 'container_exited',
'oom': 'container_oom',
'health_status: unhealthy': 'container_unhealthy',
'health_status: healthy': 'container_healthy',
'pull': 'image_pulled'
};
return mapping[action] || null;
}
// Scanner image patterns to exclude from notifications
const SCANNER_IMAGE_PATTERNS = [
'anchore/grype',
'aquasec/trivy',
'ghcr.io/anchore/grype',
'ghcr.io/aquasecurity/trivy'
];
function isScannerContainer(image: string | null | undefined): boolean {
if (!image) return false;
const lowerImage = image.toLowerCase();
return SCANNER_IMAGE_PATTERNS.some(pattern => lowerImage.includes(pattern.toLowerCase()));
}
export async function sendEnvironmentNotification(
environmentId: number,
action: string,
payload: Omit<NotificationPayload, 'environmentId' | 'environmentName'>,
image?: string | null
): Promise<{ success: boolean; sent: number }> {
const eventType = mapActionToEventType(action);
if (!eventType) {
return { success: true, sent: 0 };
}
const env = await getEnvironment(environmentId);
if (!env) {
return { success: false, sent: 0 };
}
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
if (envNotifications.length === 0) {
return { success: true, sent: 0 };
}
const enrichedPayload: NotificationPayload = {
...payload,
environmentId,
environmentName: env.name
};
// Skip all notifications for scanner containers (Trivy, Grype)
if (isScannerContainer(image)) {
return { success: true, sent: 0 };
}
let sent = 0;
let allSuccess = true;
for (const notif of envNotifications) {
try {
let result: NotificationResult = { success: false };
if (notif.channelType === 'smtp') {
result = await sendSmtpNotification(notif.config as SmtpConfig, enrichedPayload);
} else if (notif.channelType === 'apprise') {
result = await sendAppriseNotification(notif.config as AppriseConfig, enrichedPayload);
}
if (result.success) sent++;
else allSuccess = false;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[Notifications] Failed to send to channel ${notif.channelName}:`, errorMsg);
allSuccess = false;
}
}
return { success: allSuccess, sent };
}
export async function sendEventNotification(
eventType: NotificationEventType,
payload: NotificationPayload,
environmentId?: number
): Promise<{ success: boolean; sent: number }> {
let enrichedPayload = { ...payload };
if (environmentId) {
const env = await getEnvironment(environmentId);
if (env) {
enrichedPayload.environmentId = environmentId;
enrichedPayload.environmentName = env.name;
}
}
let channels: Array<{
channel_type: 'smtp' | 'apprise';
channel_name: string;
config: SmtpConfig | AppriseConfig;
}> = [];
if (environmentId) {
const envNotifications = await getEnabledEnvironmentNotifications(environmentId, eventType);
channels = envNotifications
.filter(n => n.channelType && n.channelName)
.map(n => ({
channel_type: n.channelType!,
channel_name: n.channelName!,
config: n.config
}));
} else {
const globalSettings = await getEnabledNotificationSettings();
channels = globalSettings
.filter(s => s.eventTypes?.includes(eventType))
.map(s => ({
channel_type: s.type,
channel_name: s.name,
config: s.config
}));
}
if (channels.length === 0) {
return { success: true, sent: 0 };
}
let sent = 0;
let allSuccess = true;
for (const channel of channels) {
try {
let result: NotificationResult = { success: false };
if (channel.channel_type === 'smtp') {
result = await sendSmtpNotification(channel.config as SmtpConfig, enrichedPayload);
} else if (channel.channel_type === 'apprise') {
result = await sendAppriseNotification(channel.config as AppriseConfig, enrichedPayload);
}
if (result.success) sent++;
else allSuccess = false;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[Notifications] Failed to send to channel ${channel.channel_name}:`, errorMsg);
allSuccess = false;
}
}
return { success: allSuccess, sent };
}
@@ -0,0 +1,55 @@
/** Mattermost incoming webhook. mmost:// or mmosts:// (HTTPS). */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendMattermost(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// mmost://[botname@]hostname[:port][/path]/token or mmosts://...
const isSecure = appriseUrl.startsWith('mmosts');
const protocol = isSecure ? 'https' : 'http';
let urlPart = appriseUrl.replace(/^mmosts?:\/\//, '');
// Check for botname (username@hostname format)
let username: string | undefined;
const atIndex = urlPart.indexOf('@');
if (atIndex !== -1) {
username = urlPart.substring(0, atIndex);
urlPart = urlPart.substring(atIndex + 1);
}
// The token is the last segment, everything else is hostname[:port][/path]
const lastSlashIndex = urlPart.lastIndexOf('/');
if (lastSlashIndex === -1) {
return { success: false, error: 'Invalid Mattermost URL format. Expected: mmost://[botname@]hostname[:port][/path]/token' };
}
const token = urlPart.substring(lastSlashIndex + 1);
const hostAndPath = urlPart.substring(0, lastSlashIndex);
const url = `${protocol}://${hostAndPath}/hooks/${token}`;
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
const body: Record<string, string> = {
text: `*${payload.title}*${envTag}\n${payload.message}`
};
if (username) {
body.username = username;
}
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Mattermost error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Mattermost connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+88
View File
@@ -0,0 +1,88 @@
/** ntfy.sh + self-hosted ntfy. ntfy:// or ntfys:// (HTTPS). */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
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)
// Query params: ?tags=ship,whale &title=Custom &priority=5
// ntfys:// variants for HTTPS
const isSecure = appriseUrl.startsWith('ntfys');
const path = appriseUrl.replace(/^ntfys?:\/\//, '');
let url: string;
let authHeader: string | null = null;
let queryAuth: string | null = null;
let queryTags: string | null = null;
let queryTitle: string | null = null;
let queryPriority: string | null = null;
let cleanPath = path;
const qIndex = path.indexOf('?');
if (qIndex !== -1) {
const params = new URLSearchParams(path.substring(qIndex + 1));
queryAuth = params.get('auth');
queryTags = params.get('tags');
queryTitle = params.get('title');
queryPriority = params.get('priority');
cleanPath = path.substring(0, qIndex);
}
const basicMatch = cleanPath.match(/^([^:]+):([^@]+)@(.+)$/);
if (basicMatch) {
const [, user, pass, hostAndTopic] = basicMatch;
const basic = Buffer.from(`${user}:${pass}`).toString('base64');
authHeader = `Basic ${basic}`;
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
} else if (cleanPath.includes('@') && cleanPath.includes('/')) {
const tokenMatch = cleanPath.match(/^([^@]+)@(.+)$/);
if (tokenMatch) {
const [, token, hostAndTopic] = tokenMatch;
authHeader = `Bearer ${token}`;
url = `${isSecure ? 'https' : 'http'}://${hostAndTopic}`;
} else {
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
}
} else if (cleanPath.includes('/')) {
url = `${isSecure ? 'https' : 'http'}://${cleanPath}`;
} else {
url = `https://ntfy.sh/${cleanPath}`;
}
if (!authHeader && queryAuth) {
const decoded = Buffer.from(queryAuth, 'base64').toString();
authHeader = decoded.startsWith('Bearer ') ? decoded : `Bearer ${decoded}`;
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
const defaultTags = payload.type || 'info';
const headers: Record<string, string> = {
'Title': queryTitle || titleWithEnv,
'Priority': queryPriority || (payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3'),
'Tags': queryTags ? `${queryTags},${defaultTags}` : defaultTags
};
if (authHeader) {
headers['Authorization'] = authHeader;
}
try {
const response = await fetch(url, {
method: 'POST',
headers,
body: payload.message
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `ntfy error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `ntfy connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+36
View File
@@ -0,0 +1,36 @@
/** Pushover. pushover://user_key/api_token. */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const match = appriseUrl.match(/^pushover:\/\/([^/]+)\/(.+)/);
if (!match) {
return { success: false, error: 'Invalid Pushover URL format. Expected: pushover://user_key/api_token' };
}
const [, userKey, apiToken] = match;
const url = 'https://api.pushover.net/1/messages.json';
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: apiToken,
user: userKey,
title: titleWithEnv,
message: payload.message,
priority: payload.type === 'error' ? 1 : 0
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Pushover error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Pushover connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Shared types + helpers used by every notification provider.
*
* Imported by the router (./index.ts) and by every per-provider file
* (discord.ts, slack.ts, …). Keeps the providers free of cross-imports —
* each provider only depends on this module.
*/
export interface NotificationPayload {
title: string;
message: string;
type?: 'info' | 'success' | 'warning' | 'error';
environmentId?: number;
environmentName?: string;
}
export interface NotificationResult {
success: boolean;
error?: string;
}
/** Drain a response body to release the underlying socket/TLS connection. */
export async function drainResponse(response: Response): Promise<void> {
if (!response.bodyUsed) {
try { await response.arrayBuffer(); } catch {}
}
}
/** Append `[env name]` to a title when present. Used by every provider. */
export function titleWithEnv(payload: NotificationPayload): string {
return payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Signal — via bbernhard/signal-cli-rest-api
* (https://github.com/bbernhard/signal-cli-rest-api).
*
* Supported formats:
* signal://host[:port]/+source/+target1[/+target2/...]
* signals://host[:port]/+source/+target1[/+target2/...] (HTTPS)
*
* `+source` is the sender's registered Signal number (E.164 format). The '+'
* is optional in the URL — we re-add it. Recipients can be Signal phone
* numbers (numeric, '+' gets added) or group IDs (signal-cli's "group.<base64>"
* form, passed through untouched).
*/
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendSignal(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const isSecure = appriseUrl.startsWith('signals');
const raw = appriseUrl.replace(/^signals?:\/\//, '');
// Strip query string so a future `?foo=bar` doesn't end up in the last
// recipient. Currently we don't honor any params, but the parsing should
// be forward-compatible.
const qIndex = raw.indexOf('?');
const cleanPath = qIndex === -1 ? raw : raw.substring(0, qIndex);
const parts = cleanPath.split('/').filter(Boolean);
if (parts.length < 3) {
return { success: false, error: 'Invalid Signal URL. Expected: signal://host[:port]/+source/+target1[/+target2/...]' };
}
const hostPort = parts[0];
// Phone numbers may or may not start with '+' in the URL — Signal needs
// the '+'. Group IDs (signal-cli's "group.<base64>" form) and other
// non-numeric recipients are passed through untouched.
const normalize = (n: string) => {
if (n.startsWith('+')) return n;
if (/^\d+$/.test(n)) return `+${n}`;
return n;
};
const source = normalize(parts[1]);
const recipients = parts.slice(2).map(normalize);
// signal-cli-rest-api uses 'message' for body and 'number' for sender;
// title is prepended to the body since Signal messages don't have a title field.
const titleWithEnv = payload.environmentName
? `${payload.title} [${payload.environmentName}]`
: payload.title;
const messageText = `${titleWithEnv}\n\n${payload.message}`;
const baseUrl = `${isSecure ? 'https' : 'http'}://${hostPort}`;
try {
const response = await fetch(`${baseUrl}/v2/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
number: source,
recipients,
message: messageText
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Signal error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Signal connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+34
View File
@@ -0,0 +1,34 @@
/** Slack incoming webhook. slack:// or slacks:// or a raw hooks.slack.com URL. */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendSlack(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// slack://token_a/token_b/token_c or webhook URL
let url: string;
if (appriseUrl.includes('hooks.slack.com')) {
url = appriseUrl.replace(/^slacks?:\/\//, 'https://');
} else {
const parts = appriseUrl.replace(/^slacks?:\/\//, '').split('/');
url = `https://hooks.slack.com/services/${parts.join('/')}`;
}
const envTag = payload.environmentName ? ` \`${payload.environmentName}\`` : '';
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `*${payload.title}*${envTag}\n${payload.message}`
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Slack error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Slack connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+48
View File
@@ -0,0 +1,48 @@
/** SMTP email notifications via nodemailer. */
import nodemailer from 'nodemailer';
import type { SmtpConfig } from '../db';
import type { NotificationPayload, NotificationResult } from './shared';
export async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
try {
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.username ? {
user: config.username,
pass: config.password
} : undefined,
tls: config.skipTlsVerify ? {
rejectUnauthorized: false
} : undefined
});
const envBadge = payload.environmentName
? `<span style="display: inline-block; background: #3b82f6; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">${payload.environmentName}</span>`
: '';
const envText = payload.environmentName ? ` [${payload.environmentName}]` : '';
const html = `
<div style="font-family: sans-serif; padding: 20px;">
<h2 style="margin: 0 0 10px 0;">${payload.title}${envBadge}</h2>
<p style="margin: 0; white-space: pre-wrap;">${payload.message}</p>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
<p style="margin: 0; font-size: 12px; color: #666;">Sent by Dockhand</p>
</div>
`;
await transporter.sendMail({
from: config.from_name ? `"${config.from_name}" <${config.from_email}>` : config.from_email,
to: config.to_emails.join(', '),
subject: `[Dockhand]${envText} ${payload.title}`,
text: `${payload.title}${envText}\n\n${payload.message}`,
html
});
return { success: true };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return { success: false, error: `SMTP error: ${errorMsg}` };
}
}
+43
View File
@@ -0,0 +1,43 @@
/** Telegram bot. tgram://bot_token/chat_id[:topic_id]. */
import { escapeTelegramMarkdown, parseTelegramUrl } from '$lib/utils/notification-parsers';
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const parsed = parseTelegramUrl(appriseUrl);
if (!parsed) {
return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id or tgram://bot_token/chat_id:topic_id' };
}
const { botToken, chatId, topicId } = parsed;
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
const escapedTitle = escapeTelegramMarkdown(payload.title);
const escapedMessage = escapeTelegramMarkdown(payload.message);
const envTag = payload.environmentName ? ` [${escapeTelegramMarkdown(payload.environmentName)}]` : '';
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: `*${escapedTitle}*${envTag}\n${escapedMessage}`,
...(topicId ? { message_thread_id: topicId } : {}),
parse_mode: 'Markdown',
link_preview_options: {
is_disabled: true
}
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({})) as { description?: string };
const errorMsg = errorData.description || response.statusText;
return { success: false, error: `Telegram error ${response.status}: ${errorMsg}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Telegram connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+56
View File
@@ -0,0 +1,56 @@
/** Microsoft Power Automate Workflows (e.g. Microsoft Teams). workflows://. */
import { parseWorkflowsUrl, buildWorkflowsHttpUrl } from '$lib/utils/notification-parsers';
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
export async function sendWorkflows(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
const parsed = parseWorkflowsUrl(appriseUrl);
if (!parsed) {
return { success: false, error: 'Invalid Workflows URL format. Expected: workflows://hostname/workflow/signature' };
}
const url = buildWorkflowsHttpUrl(parsed.hostname, parsed.workflow, parsed.signature);
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'message',
attachments: [
{
contentType: 'application/vnd.microsoft.card.adaptive',
content: {
$schema: 'https://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard',
version: '1.2',
body: [
{
type: 'TextBlock',
style: 'heading',
wrap: true,
text: titleWithEnv
},
{
type: 'TextBlock',
style: 'default',
wrap: true,
text: payload.message
}
]
}
}
]
})
});
if (!response.ok) {
const text = await response.text().catch(() => '');
return { success: false, error: `Workflows error ${response.status}: ${text || response.statusText}` };
}
await drainResponse(response);
return { success: true };
} catch (error) {
return { success: false, error: `Workflows connection failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Encode the AuthConfig JSON as base64url **with `=` padding** for the
* Docker X-Registry-Auth header. The Docker daemon decodes the header with
* Go's `base64.URLEncoding.DecodeString`, which is base64url with padding —
* unpadded base64url (Node's default 'base64url' Buffer encoding) is
* silently treated as malformed, causing the daemon to fall back to
* anonymous and trip the registry rate limit (#1105).
*
* Reference: moby/api/pkg/authconfig/authconfig.go uses
* `base64.URLEncoding.EncodeToString` / `DecodeString`.
*/
export function encodeRegistryAuth(authConfig: object): string {
const unpadded = Buffer.from(JSON.stringify(authConfig)).toString('base64url');
return unpadded + '='.repeat((4 - (unpadded.length % 4)) % 4);
}
@@ -38,7 +38,7 @@ import {
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
import { sendEventNotification } from '../../notifications';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
import { isUpdateDisabledByLabel } from '../../container-labels';
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
// =============================================================================
// TYPES
@@ -382,6 +382,18 @@ export async function runContainerUpdate(
return;
}
// Hidden containers are excluded from update polling and auto-updates (#1083)
if (isHiddenByLabel(inspectData.Config?.Labels)) {
log(`Skipping - dockhand.hidden=true label set on container`);
await updateScheduleExecution(execution.id, {
status: 'skipped',
completedAt: new Date().toISOString(),
duration: Date.now() - startTime,
details: { reason: 'Skipped by dockhand.hidden=true label' }
});
return;
}
// Skip digest-pinned images - they are explicitly locked to a specific version
if (isDigestBasedImage(imageNameFromConfig)) {
log(`Skipping ${containerName} - image pinned to specific digest`);
@@ -31,7 +31,7 @@ import {
import { sendEventNotification } from '../../notifications';
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
import { isUpdateDisabledByLabel } from '../../container-labels';
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
import { recreateContainer } from './container-update';
interface UpdateInfo {
@@ -105,9 +105,12 @@ export async function runEnvUpdateCheckJob(
// Clear pending updates at the start - we'll re-add as we discover updates
await clearPendingContainerUpdates(environmentId);
// Get all containers in this environment
const containers = await listContainers(true, environmentId);
await log(`Found ${containers.length} containers`);
// 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));
const hiddenCount = allContainers.length - containers.length;
await log(`Found ${containers.length} containers${hiddenCount ? ` (${hiddenCount} hidden by label)` : ''}`);
const updatesAvailable: UpdateInfo[] = [];
let checkedCount = 0;
+127 -11
View File
@@ -9,6 +9,15 @@ import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSyn
import { join, resolve, dirname, basename } from 'node:path';
import { spawn as nodeSpawn } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';
import {
applyFileDeletions,
hashDirFiles,
skipReasonMessage,
normalizeSkipReason,
type FileToDelete,
type DeletionApplyResult,
type DeletionSkipReason
} from './git-deletions';
import {
getEnvironment,
getSecretEnvVarsAsRecord,
@@ -61,6 +70,8 @@ export interface StackOperationResult {
error?: string;
/** The docker compose command that was executed (for debugging/testing) */
command?: string;
/** Result of applying git deletion sync (files removed / kept, with reasons) */
deletion?: DeletionApplyResult;
}
/**
@@ -111,6 +122,8 @@ export interface DeployStackOptions {
envPath?: string; // Custom env file path (for adopted/imported stacks)
composeFileName?: string; // Compose filename to use (e.g., "docker-compose.yaml") for git stacks
envFileName?: string; // Env filename relative to compose dir (e.g., ".env") for git stacks
/** Git deletion sync (#966): files confirmed safe to delete from the stack dir */
filesToDelete?: FileToDelete[];
}
// =============================================================================
@@ -830,6 +843,10 @@ interface ComposeCommandOptions {
serviceName?: string;
/** Compose filename for Hawser (e.g., "docker-compose.prod.yml") - extracted from composePath */
composeFileName?: string;
/** Git deletion sync (#966): files to delete on the Hawser agent's stack dir */
filesToDelete?: FileToDelete[];
/** On down: ask the Hawser agent to remove the stack directory entirely (#1162, stack deletion only) */
removeFiles?: boolean;
}
/**
@@ -1285,7 +1302,9 @@ async function executeComposeViaHawser(
composeFileName?: string,
build?: boolean,
noBuildCache?: boolean,
pullPolicy?: string
pullPolicy?: string,
filesToDelete?: FileToDelete[],
removeFiles?: boolean
): Promise<StackOperationResult> {
const logPrefix = `[Stack:${stackName}]`;
// Import dockerFetch dynamically to avoid circular dependency
@@ -1362,7 +1381,14 @@ async function executeComposeViaHawser(
noBuildCache: (build && noBuildCache) || false,
pullPolicy: pullPolicy || '',
registries, // Registry credentials for docker login
serviceName // Target specific service only (with --no-deps)
serviceName, // Target specific service only (with --no-deps)
// Git deletion sync (#966): agent re-verifies containment + content
// hash per file before deleting. Old agents ignore this field.
filesToDelete: filesToDelete && filesToDelete.length > 0
? filesToDelete.map(f => ({ path: f.path, sha256: f.hash }))
: undefined,
// Stack deletion (#1162): remove the agent-side stack dir on down
removeFiles: removeFiles || false
});
console.log(`${logPrefix} Sending request to Hawser agent...`);
@@ -1380,6 +1406,8 @@ async function executeComposeViaHawser(
success: boolean;
output?: string;
error?: string;
deletedFiles?: string[];
skippedFiles?: { path: string; reason: string }[];
};
console.log(`${logPrefix} ----------------------------------------`);
@@ -1393,16 +1421,50 @@ async function executeComposeViaHawser(
console.log(`${logPrefix} Error:`, result.error);
}
// Git deletion sync: interpret the agent's report. An agent that supports
// the feature always returns deletedFiles/skippedFiles (possibly empty
// arrays) when filesToDelete was sent. An old agent ignores the field and
// returns neither — every requested deletion is marked agent-no-support.
// Skips are FINAL (no carry-forward, no retry): the files stay on the
// remote host as unmanaged residue, identical to pre-feature behavior.
let deletion: DeletionApplyResult | undefined;
if (filesToDelete && filesToDelete.length > 0) {
if (result.deletedFiles !== undefined || result.skippedFiles !== undefined) {
deletion = {
deleted: result.deletedFiles ?? [],
skipped: (result.skippedFiles ?? []).map(s => ({
path: s.path,
reason: normalizeSkipReason(s.reason || 'apply-failed')
}))
};
for (const path of deletion.deleted) {
console.log(`${logPrefix} Agent removed "${path}" — deleted from the repository`);
}
for (const skip of deletion.skipped) {
if (skip.reason === 'already-absent') continue;
console.warn(`${logPrefix} Agent kept "${skip.path}" — ${skipReasonMessage(skip.reason)}`);
}
} else {
deletion = {
deleted: [],
skipped: filesToDelete.map(f => ({ path: f.path, reason: 'agent-no-support' as DeletionSkipReason }))
};
console.warn(`${logPrefix} ${skipReasonMessage('agent-no-support')} (${filesToDelete.length} file(s) affected)`);
}
}
if (result.success) {
return {
success: true,
output: result.output || `Stack "${stackName}" ${operation} completed via Hawser`
output: result.output || `Stack "${stackName}" ${operation} completed via Hawser`,
deletion
};
} else {
return {
success: false,
output: result.output || '',
error: result.error || `Compose ${operation} failed`
error: result.error || `Compose ${operation} failed`,
deletion
};
}
} catch (err: any) {
@@ -1431,7 +1493,7 @@ async function executeComposeCommand(
envVars?: Record<string, string>,
secretVars?: Record<string, string>
): Promise<StackOperationResult> {
const { stackName, envId, forceRecreate, build, noBuildCache, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options;
const { stackName, envId, forceRecreate, build, noBuildCache, pullPolicy, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName, filesToDelete, removeFiles } = options;
// Get environment configuration
const env = envId ? await getEnvironment(envId) : null;
@@ -1521,7 +1583,9 @@ async function executeComposeCommand(
composeFileName,
build,
noBuildCache,
pullPolicy
pullPolicy,
filesToDelete,
removeFiles
);
}
@@ -1560,12 +1624,20 @@ async function executeComposeCommand(
}
case 'socket':
default:
default: {
// Honor the environment's configured socket path. Without this,
// docker compose falls back to /var/run/docker.sock regardless of
// the env's setting — wrong daemon for rootless/multi-socket hosts
// (#1172). Default '/var/run/docker.sock' is left as undefined so
// the CLI's own default applies (preserves existing behavior).
const sock = env.socketPath && env.socketPath !== '/var/run/docker.sock'
? `unix://${env.socketPath}`
: undefined;
return executeLocalCompose(
operation,
stackName,
composeContent,
undefined, // dockerHost
sock,
undefined, // tlsConfig
envVars,
secretVars,
@@ -1581,6 +1653,7 @@ async function executeComposeCommand(
noBuildCache,
pullPolicy
);
}
}
}
@@ -2088,6 +2161,24 @@ export async function removeStack(
if (composeResult.success) {
const envVars = await getNonSecretEnvVarsAsRecord(stackName, envId);
const secretVars = await getSecretEnvVarsAsRecord(stackName, envId);
// Stack removal cleanup (#1162): the agent deletes ONLY what Dockhand
// explicitly lists. The list is the local staging dir contents — exactly
// the files Dockhand ever wrote for this stack (compose, .env,
// .env.dockhand, git files), never user volume data (that exists only on
// the agent host). Each entry is hash-verified agent-side; the agent's
// stack dir is removed only if nothing else remains in it.
// Only built for Dockhand-managed staging dirs (inside DATA_DIR/stacks).
let removalFiles: FileToDelete[] | undefined;
if (composeResult.stackDir) {
const resolvedStaging = resolve(composeResult.stackDir);
if (resolvedStaging.startsWith(resolve(getStacksDir()) + '/')) {
removalFiles = Object.entries(hashDirFiles(resolvedStaging)).map(
([path, hash]) => ({ path, hash })
);
}
}
const downResult = await executeComposeCommand(
'down',
{
@@ -2096,7 +2187,10 @@ export async function removeStack(
removeVolumes,
workingDir: composeResult.stackDir,
composePath: composeResult.composePath ?? undefined,
envPath: composeResult.envPath ?? undefined
envPath: composeResult.envPath ?? undefined,
// Full stack removal: the Hawser agent cleans its stack dir (#1162)
removeFiles: true,
filesToDelete: removalFiles
},
composeResult.content!,
envVars,
@@ -2267,7 +2361,7 @@ export async function removeStack(
* Uses stack locking to prevent concurrent deployments.
*/
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
const { name, compose, envId, sourceDir, forceRecreate, build, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName } = options;
const { name, compose, envId, sourceDir, forceRecreate, build, noBuildCache, pullPolicy, composePath, envPath, composeFileName, envFileName, filesToDelete } = options;
const logPrefix = `[Stack:${name}]`;
console.log(`${logPrefix} ========================================`);
@@ -2299,6 +2393,7 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
let actualComposePath: string | undefined;
let actualEnvPath: string | undefined = envPath; // Start with provided envPath (for adopted stacks)
let stackFiles: Record<string, string> | undefined;
let localDeletionResult: DeletionApplyResult | undefined;
if (composePath) {
// Adopted/imported stack: use the original compose file location
@@ -2351,6 +2446,21 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
filter: (src) => !src.includes('/.git/') && !src.endsWith('/.git')
});
console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`);
// Git deletion sync (#966): remove files that were deleted from the
// repository. The list is manifest entries absent from the new clone;
// the applier re-verifies containment + content hash per file, so
// volume data and locally modified files are never touched.
if (filesToDelete && filesToDelete.length > 0) {
localDeletionResult = applyFileDeletions(workingDir, filesToDelete);
for (const path of localDeletionResult.deleted) {
console.log(`${logPrefix} Removed "${path}" — deleted from the repository`);
}
for (const skip of localDeletionResult.skipped) {
if (skip.reason === 'already-absent') continue;
console.warn(`${logPrefix} Kept "${skip.path}" — ${skipReasonMessage(skip.reason)}`);
}
}
} else {
// Internal stack: check if a custom path exists in DB (adopted/imported stacks)
const source = await getStackSource(name, envId);
@@ -2422,7 +2532,8 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
envPath: actualEnvPath,
useOverrideFile: isGitStack,
// Pass compose filename for Hawser (extracted from path or provided explicitly)
composeFileName: composeFileName || (actualComposePath ? basename(actualComposePath) : undefined)
composeFileName: composeFileName || (actualComposePath ? basename(actualComposePath) : undefined),
filesToDelete
},
compose,
isGitStack ? dbNonSecretVars : undefined,
@@ -2438,6 +2549,11 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
if (result.error) {
console.log(`${logPrefix} Error:`, result.error);
}
// Deletion result: the remote (Hawser) result is authoritative when present;
// for local deployments the local applier's result is the truth.
if (!result.deletion && localDeletionResult) {
result.deletion = localDeletionResult;
}
return result;
});
}
+35
View File
@@ -98,6 +98,35 @@ const envNames: Map<number, string> = new Map();
// Track which envIds are currently configured in Go
const configuredEnvs: Set<number> = new Set();
// Health status transition tracking: only store DB events when status changes
// Key: `${envId}-${containerId}` → last known sub-status (e.g. "healthy", "unhealthy")
const lastHealthStatus: Map<string, string> = new Map();
/**
* Check if a health_status event represents a transition (and should be stored in DB).
* Non-health events always return true. Repeated identical health statuses return false.
* Also clears tracking on container destroy/die events.
*/
export function isHealthTransition(envId: number, containerId: string, action: string): boolean {
// Clear tracking when container is removed
if (action === 'destroy' || action === 'die') {
lastHealthStatus.delete(`${envId}-${containerId}`);
return true;
}
if (!action.startsWith('health_status')) return true;
// Extract sub-status: "health_status: healthy" → "healthy"
const subStatus = action.includes(':') ? action.split(':').pop()!.trim() : action;
const key = `${envId}-${containerId}`;
const previous = lastHealthStatus.get(key);
if (previous === subStatus) return false; // Same status, skip DB write
lastHealthStatus.set(key, subStatus);
return true;
}
// Dedup cleanup interval
let dedupCleanupInterval: ReturnType<typeof setInterval> | null = null;
@@ -265,6 +294,12 @@ async function handleContainerEvent(msg: GoMessage): Promise<void> {
const timestamp = new Date(Math.floor(event.timeNano / 1000000)).toISOString();
// Skip redundant health_status events (only store transitions: healthy↔unhealthy)
if (!isHealthTransition(msg.envId, containerId, action)) {
rssAfterOp('events', before);
return;
}
// Sub-category: DB insert
const dbBefore = rssBeforeOp();
try {