mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-18 11:29:56 +03:00
Compare commits
128 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22e0429094 | |||
| 1a34f73ae3 | |||
| aaaf252d4c | |||
| 1bf5dec60f | |||
| d7a458f158 | |||
| a7990e2167 | |||
| 8bb95d0a1b | |||
| b8f06426e3 | |||
| 0af1ee6eb2 | |||
| ac19a67cce | |||
| 380fcc34ec | |||
| 32e44c746b | |||
| 84e0a0bf14 | |||
| c44f244b1d | |||
| afee09866d | |||
| c210ef0a8e | |||
| 7fe4b25563 | |||
| 7f26c0a585 | |||
| 2027c9d44c | |||
| 0bb10cabb9 | |||
| e9a9f0ca25 | |||
| 17dafec9de | |||
| b55e1e5aad | |||
| aefa5e7925 | |||
| 63c576e059 | |||
| a6016afdaa | |||
| 0b3658793a | |||
| 05d771d9ba | |||
| 55f3101a19 | |||
| 790ce092ee | |||
| 7729d7e326 | |||
| bcd10c1407 | |||
| a04040e1e9 | |||
| c26fa2d10f | |||
| d51bfb0d60 | |||
| 5527d19198 | |||
| 2829e7c0e9 | |||
| 1066ce9eb1 | |||
| bc00bbfe5c | |||
| 9c451aedf9 | |||
| 4b430340db | |||
| 0372737f3d | |||
| 33bdc39b49 | |||
| 1baedd134d | |||
| ae3aea2296 | |||
| 3a7b856047 | |||
| ae42baa67c | |||
| 83a5a557b0 | |||
| c43bdbcee6 | |||
| f8dcb84c41 | |||
| 8ee4fe4d68 | |||
| d83ca684d7 | |||
| e5becfd87f | |||
| d12196f53a | |||
| ef26d38fce | |||
| 133c9f1e8f | |||
| cb8be12f1a | |||
| 48b9bde8ae | |||
| 1cb47eaa9c | |||
| 265bbc65df | |||
| 188ba1967d | |||
| 9d2266dffe | |||
| 4ab6abf924 | |||
| af9cb55729 | |||
| d7a553cd8d | |||
| e5fec4df71 | |||
| 071571eca9 | |||
| 1229ecc1d9 | |||
| 03992ae227 | |||
| 48e9a3f5ec | |||
| d7eaa5ef70 | |||
| 5b1b7ecb71 | |||
| de1cad422e | |||
| 86448e5b20 | |||
| 8be07ea8dc | |||
| ffde535390 | |||
| cf0e9ab50d | |||
| 95f263c3a6 | |||
| 83063d757a | |||
| 6b49d13236 | |||
| 610548ed66 | |||
| 8f3a7eb435 | |||
| a88d3d5788 | |||
| ac84b20bb0 | |||
| 0b62c5e3bd | |||
| 241b04247e | |||
| bbdb9841fd | |||
| 1d1e85f1fa | |||
| 86a06d9de0 | |||
| a3cc26d958 | |||
| fe48d63164 | |||
| 21aa4a9854 | |||
| 27baab1a86 | |||
| 33a7add751 | |||
| 7abda79214 | |||
| 9905b17f3d | |||
| 6483cea6c6 | |||
| c185d00dc3 | |||
| 62636426bf | |||
| 027aee434c | |||
| f2657a3d4d | |||
| 851e56bc57 | |||
| c15355e159 | |||
| 7643807717 | |||
| bd7b832394 | |||
| 66e723052d | |||
| 80c000c601 | |||
| f2102003e3 | |||
| a1e07b1a10 | |||
| b89470e965 | |||
| 942c8d440b | |||
| 607d340b71 | |||
| 659d074d00 | |||
| 07a5f03aa9 | |||
| 242f8df49d | |||
| 5475112806 | |||
| db9981f2b0 | |||
| c7b9ae7243 | |||
| a0bc234c8a | |||
| 0ef9982aff | |||
| 5194b3a993 | |||
| 62ab0a3065 | |||
| 9c85535a9b | |||
| 9bf4b74e2e | |||
| 73c9f580a1 | |||
| e5828c7d31 | |||
| 8afdea8795 | |||
| ba8d6ce068 |
+4
-4
@@ -37,7 +37,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
|
||||
" - busybox" \
|
||||
" - tzdata" \
|
||||
" - docker-cli" \
|
||||
" - docker-compose=5.0.2-r1" \
|
||||
" - docker-compose=5.1.3-r0" \
|
||||
" - docker-cli-buildx" \
|
||||
" - sqlite" \
|
||||
" - postgresql-client" \
|
||||
@@ -77,8 +77,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
# Copy package files and install dependencies (--ignore-scripts blocks malicious postinstall hooks)
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --ignore-scripts \
|
||||
&& npm rebuild better-sqlite3 argon2
|
||||
RUN MAKEFLAGS="-j$(nproc)" npm ci --ignore-scripts \
|
||||
&& MAKEFLAGS="-j$(nproc)" npm rebuild better-sqlite3 argon2
|
||||
|
||||
# Copy source code and build
|
||||
COPY . .
|
||||
@@ -93,7 +93,7 @@ RUN cp -r node_modules/better-sqlite3/build /tmp/better-sqlite3-build \
|
||||
&& rm -rf node_modules/@types /tmp/better-sqlite3-build
|
||||
|
||||
# Build Go collector
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25.8 AS go-builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25.9 AS go-builder
|
||||
ARG TARGETARCH
|
||||
WORKDIR /app
|
||||
COPY collector/ ./collector/
|
||||
|
||||
+26
-13
@@ -18,25 +18,33 @@ FROM node:24-alpine AS app-builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git curl python3 make g++
|
||||
RUN apk add --no-cache git curl python3 make g++ gcc musl-dev
|
||||
|
||||
# Copy package files and install dependencies
|
||||
# Build getrandom shim for old kernels (< 3.17) that lack the syscall
|
||||
COPY shims/getrandom-shim.c /tmp/
|
||||
RUN gcc -shared -fPIC -O2 -o /tmp/libgetrandom-shim.so /tmp/getrandom-shim.c
|
||||
|
||||
# Copy package files and install dependencies (--ignore-scripts blocks malicious postinstall hooks)
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
RUN npm ci --ignore-scripts \
|
||||
&& npm rebuild better-sqlite3 argon2
|
||||
|
||||
# Copy source code and build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production dependencies only (rebuilds native addons against musl)
|
||||
RUN rm -rf node_modules \
|
||||
&& npm ci --omit=dev \
|
||||
&& rm -rf node_modules/@types
|
||||
# Production dependencies only
|
||||
# Preserve better-sqlite3 native addon (no prebuilds exist for Node 24 ABI 137)
|
||||
RUN cp -r node_modules/better-sqlite3/build /tmp/better-sqlite3-build \
|
||||
&& rm -rf node_modules \
|
||||
&& npm ci --omit=dev --ignore-scripts \
|
||||
&& cp -r /tmp/better-sqlite3-build node_modules/better-sqlite3/build \
|
||||
&& rm -rf node_modules/@types /tmp/better-sqlite3-build
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 2: Go Collector Builder
|
||||
# -----------------------------------------------------------------------------
|
||||
FROM golang:1.24 AS go-builder
|
||||
FROM golang:1.25.8 AS go-builder
|
||||
WORKDIR /app
|
||||
COPY collector/ ./collector/
|
||||
RUN cd collector && CGO_ENABLED=0 go build -o /app/bin/collection-worker .
|
||||
@@ -62,9 +70,10 @@ RUN apk add --no-cache \
|
||||
su-exec \
|
||||
libstdc++
|
||||
|
||||
# Create docker compose plugin symlink
|
||||
# Create docker compose plugin symlink (skip if package already installed it there)
|
||||
RUN mkdir -p /usr/libexec/docker/cli-plugins \
|
||||
&& ln -sf /usr/bin/docker-compose /usr/libexec/docker/cli-plugins/docker-compose
|
||||
&& [ -x /usr/libexec/docker/cli-plugins/docker-compose ] \
|
||||
|| ln -sf /usr/bin/docker-compose /usr/libexec/docker/cli-plugins/docker-compose
|
||||
|
||||
# Create dockhand user and group
|
||||
RUN addgroup -g 1001 dockhand \
|
||||
@@ -80,7 +89,8 @@ ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
|
||||
DATA_DIR=/app/data \
|
||||
HOME=/home/dockhand \
|
||||
PUID=1001 \
|
||||
PGID=1001
|
||||
PGID=1001 \
|
||||
LD_PRELOAD=/usr/lib/libgetrandom-shim.so
|
||||
|
||||
# Copy application files with correct ownership
|
||||
COPY --from=app-builder --chown=dockhand:dockhand /app/node_modules ./node_modules
|
||||
@@ -98,6 +108,9 @@ COPY --chown=dockhand:dockhand drizzle-pg/ ./drizzle-pg/
|
||||
# Copy legal documents
|
||||
COPY --chown=dockhand:dockhand LICENSE.txt PRIVACY.txt ./
|
||||
|
||||
# Copy getrandom shim for old kernels (Synology DS1513+ with kernel 3.10.x)
|
||||
COPY --from=app-builder /tmp/libgetrandom-shim.so /usr/lib/libgetrandom-shim.so
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY docker-entrypoint-node.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
@@ -113,7 +126,7 @@ RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/ || exit 1
|
||||
CMD curl -f http://localhost:${PORT:-3000}/ || exit 1
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["node", "/app/server.js"]
|
||||
CMD []
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
module github.com/Finsys/dockhand/collector
|
||||
|
||||
go 1.25
|
||||
go 1.25.9
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE IF NOT EXISTS "api_tokens" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"user_id" integer NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"token_hash" text NOT NULL,
|
||||
"token_prefix" text NOT NULL,
|
||||
"last_used" timestamp,
|
||||
"expires_at" timestamp,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now(),
|
||||
CONSTRAINT "api_tokens_token_hash_unique" UNIQUE("token_hash")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "api_tokens_user_id_idx" ON "api_tokens" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "api_tokens_token_prefix_idx" ON "api_tokens" USING btree ("token_prefix");
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "b10cba96-4947-484f-84a2-efb65205381f",
|
||||
"prevId": "eef8322a-0ccc-418c-b0f6-f51972a1850e",
|
||||
"id": "cefce4cc-994a-4b79-b55a-e995211b8f6a",
|
||||
"prevId": "b10cba96-4947-484f-84a2-efb65205381f",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
||||
"when": 1774155653752,
|
||||
"tag": "0004_add_git_stack_deploy_options",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1775312212996,
|
||||
"tag": "0005_add_api_tokens",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE `api_tokens` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`token_hash` text NOT NULL,
|
||||
`token_prefix` text NOT NULL,
|
||||
`last_used` text,
|
||||
`expires_at` text,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` text DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `api_tokens_token_hash_unique` ON `api_tokens` (`token_hash`);--> statement-breakpoint
|
||||
CREATE INDEX `api_tokens_user_id_idx` ON `api_tokens` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `api_tokens_token_prefix_idx` ON `api_tokens` (`token_prefix`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
||||
"when": 1774155653752,
|
||||
"tag": "0004_add_git_stack_deploy_options",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1775311743346,
|
||||
"tag": "0005_add_api_tokens",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+5
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dockhand",
|
||||
"private": true,
|
||||
"version": "1.0.23",
|
||||
"version": "1.0.26",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npx vite dev",
|
||||
@@ -77,11 +77,11 @@
|
||||
"croner": "9.1.0",
|
||||
"cronstrue": "3.9.0",
|
||||
"devalue": "5.6.4",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"fast-xml-parser": "5.5.8",
|
||||
"js-yaml": "4.1.1",
|
||||
"ldapts": "8.1.3",
|
||||
"nodemailer": "8.0.4",
|
||||
"nodemailer": "8.0.5",
|
||||
"otpauth": "9.4.1",
|
||||
"postgres": "3.4.8",
|
||||
"qrcode": "1.5.4",
|
||||
@@ -136,6 +136,7 @@
|
||||
"@codemirror/commands": "6.10.1",
|
||||
"@codemirror/search": "6.6.0",
|
||||
"@lezer/common": "1.5.0",
|
||||
"@lezer/highlight": "1.2.3"
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"devalue": "5.6.4"
|
||||
}
|
||||
}
|
||||
|
||||
+91
-7
@@ -4,6 +4,8 @@ import { initDatabase, hasAdminUser } from '$lib/server/db';
|
||||
import { startSubprocesses, stopSubprocesses } from '$lib/server/subprocess-manager';
|
||||
import { startScheduler } from '$lib/server/scheduler';
|
||||
import { isAuthEnabled, validateSession } from '$lib/server/auth';
|
||||
import { validateApiToken } from '$lib/server/api-tokens';
|
||||
import { requestContext } from '$lib/server/request-context';
|
||||
import { setServerStartTime } from '$lib/server/uptime';
|
||||
import { checkLicenseExpiry, getHostname } from '$lib/server/license';
|
||||
import { initCryptoFallback } from '$lib/server/crypto-fallback';
|
||||
@@ -198,6 +200,58 @@ if (!initialized) {
|
||||
}
|
||||
}
|
||||
|
||||
// Bearer token auth failure rate limiting (per IP, 5-minute cooldown after 10 failures)
|
||||
const bearerFailCounts = new Map<string, { count: number; firstFail: number }>();
|
||||
const BEARER_FAIL_WINDOW_MS = 60_000; // 1-minute sliding window
|
||||
const BEARER_FAIL_MAX = 15; // max failures per window
|
||||
const BEARER_COOLDOWN_MS = 5 * 60 * 1000; // 5-minute cooldown after exceeding limit
|
||||
const bearerCooldowns = new Map<string, number>(); // IP → cooldown-until timestamp
|
||||
|
||||
// Periodic cleanup
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, until] of bearerCooldowns) {
|
||||
if (now > until) bearerCooldowns.delete(ip);
|
||||
}
|
||||
for (const [ip, entry] of bearerFailCounts) {
|
||||
if (now - entry.firstFail > BEARER_FAIL_WINDOW_MS) bearerFailCounts.delete(ip);
|
||||
}
|
||||
}, BEARER_COOLDOWN_MS).unref?.();
|
||||
|
||||
function getClientIp(event: { request: Request; getClientAddress?: () => string }): string {
|
||||
// Prefer socket-level IP (SvelteKit resolves proxy headers via adapter config)
|
||||
// This prevents X-Forwarded-For spoofing to bypass rate limiting
|
||||
try {
|
||||
const addr = event.getClientAddress?.();
|
||||
if (addr) return addr;
|
||||
} catch { /* getClientAddress may throw if unavailable */ }
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function recordBearerFailure(ip: string): void {
|
||||
const now = Date.now();
|
||||
const entry = bearerFailCounts.get(ip);
|
||||
if (!entry || now - entry.firstFail > BEARER_FAIL_WINDOW_MS) {
|
||||
bearerFailCounts.set(ip, { count: 1, firstFail: now });
|
||||
return;
|
||||
}
|
||||
entry.count++;
|
||||
if (entry.count >= BEARER_FAIL_MAX) {
|
||||
bearerCooldowns.set(ip, now + BEARER_COOLDOWN_MS);
|
||||
bearerFailCounts.delete(ip);
|
||||
}
|
||||
}
|
||||
|
||||
function isBearerRateLimited(ip: string): boolean {
|
||||
const until = bearerCooldowns.get(ip);
|
||||
if (!until) return false;
|
||||
if (Date.now() > until) {
|
||||
bearerCooldowns.delete(ip);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Routes that don't require authentication
|
||||
const PUBLIC_PATHS = [
|
||||
'/login',
|
||||
@@ -247,21 +301,51 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Check if auth is enabled
|
||||
const authEnabled = await isAuthEnabled();
|
||||
|
||||
// If auth is disabled, allow everything (app works as before)
|
||||
// If auth is disabled, allow everything
|
||||
if (!authEnabled) {
|
||||
event.locals.user = null;
|
||||
event.locals.authEnabled = false;
|
||||
return compressResponse(event.request, await resolve(event));
|
||||
const ctx = { user: null, authEnabled: false, authMethod: 'none' as const };
|
||||
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
|
||||
}
|
||||
|
||||
// Auth is enabled - check session first
|
||||
let user = await validateSession(event.cookies);
|
||||
let authMethod: 'cookie' | 'bearer' | 'none' = user ? 'cookie' : 'none';
|
||||
|
||||
// If no session, try Bearer token on API routes
|
||||
if (!user && event.url.pathname.startsWith('/api/')) {
|
||||
const authHeader = event.request.headers.get('authorization');
|
||||
if (authHeader && authHeader.startsWith('Bearer dh_') && authHeader.length <= 207) {
|
||||
const clientIp = getClientIp(event);
|
||||
|
||||
// Rate limit failed Bearer attempts
|
||||
if (isBearerRateLimited(clientIp)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Too many failed authentication attempts' }),
|
||||
{ status: 429, headers: { 'Content-Type': 'application/json', 'Retry-After': '300' } }
|
||||
);
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7); // strip "Bearer "
|
||||
user = await validateApiToken(token);
|
||||
|
||||
if (user) {
|
||||
authMethod = 'bearer';
|
||||
} else {
|
||||
recordBearerFailure(clientIp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auth is enabled - check session
|
||||
const user = await validateSession(event.cookies);
|
||||
event.locals.user = user;
|
||||
event.locals.authEnabled = true;
|
||||
|
||||
const ctx = { user, authEnabled: true, authMethod };
|
||||
|
||||
// Public paths don't require authentication
|
||||
if (isPublicPath(event.url.pathname)) {
|
||||
return compressResponse(event.request, await resolve(event));
|
||||
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
|
||||
}
|
||||
|
||||
// If not authenticated
|
||||
@@ -270,7 +354,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
// This enables the first admin user to be created during initial setup
|
||||
const noAdminSetupMode = !(await hasAdminUser());
|
||||
if (noAdminSetupMode && event.url.pathname === '/api/users' && event.request.method === 'POST') {
|
||||
return compressResponse(event.request, await resolve(event));
|
||||
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
|
||||
}
|
||||
|
||||
// API routes return 401
|
||||
@@ -289,7 +373,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
redirect(307, `/login?redirect=${redirectUrl}`);
|
||||
}
|
||||
|
||||
return compressResponse(event.request, await resolve(event));
|
||||
return requestContext.run(ctx, async () => compressResponse(event.request, await resolve(event)));
|
||||
} finally {
|
||||
rssAfterOp('http', httpBefore);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
onConfirm: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: Snippet<[{ open: boolean }]>;
|
||||
extraContent?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -35,7 +36,8 @@
|
||||
disabled = false,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
children
|
||||
children,
|
||||
extraContent
|
||||
}: Props = $props();
|
||||
|
||||
const triggerClass = $derived(unstyled
|
||||
@@ -103,11 +105,16 @@
|
||||
align={position === 'left' ? 'start' : 'end'}
|
||||
sideOffset={8}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs whitespace-nowrap">{action} {itemType} {#if displayName}<strong>{displayName}</strong>{/if}?</span>
|
||||
<Button size="sm" {variant} class="h-6 px-2 text-xs" onclick={handleConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs whitespace-nowrap">{action} {itemType} {#if displayName}<strong>{displayName}</strong>{/if}?</span>
|
||||
<Button size="sm" {variant} class="h-6 px-2 text-xs" onclick={handleConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
{#if extraContent}
|
||||
{@render extraContent()}
|
||||
{/if}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { Sun, Moon } from 'lucide-svelte';
|
||||
import { getTimeFormat } from '$lib/stores/settings';
|
||||
|
||||
interface Props {
|
||||
logs: string | null;
|
||||
darkMode?: boolean;
|
||||
timezone?: string;
|
||||
onToggleTheme?: () => void;
|
||||
}
|
||||
|
||||
let { logs, darkMode = true, onToggleTheme }: Props = $props();
|
||||
let { logs, darkMode = true, timezone, onToggleTheme }: Props = $props();
|
||||
|
||||
// Parse log lines with timestamp and content
|
||||
function parseLogLine(line: string): { timestamp: string; content: string; type: 'trivy' | 'grype' | 'error' | 'default' } {
|
||||
@@ -44,7 +46,15 @@
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: string): string {
|
||||
return timestamp.split('T')[1]?.replace('Z', '') || timestamp;
|
||||
const d = new Date(timestamp);
|
||||
if (isNaN(d.getTime())) return timestamp;
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: timezone || undefined,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: getTimeFormat() === '12h'
|
||||
}).format(d);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -329,18 +329,40 @@
|
||||
onExpandChange?.(key, nowExpanded);
|
||||
}
|
||||
|
||||
// Sort persistence
|
||||
const SORT_STORAGE_KEY = `dockhand-${gridId}-sort`;
|
||||
let sortInitialized = false;
|
||||
|
||||
// Restore saved sort on mount
|
||||
onMount(() => {
|
||||
if (!onSortChange) return;
|
||||
try {
|
||||
const saved = localStorage.getItem(SORT_STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as DataGridSortState;
|
||||
if (parsed.field && parsed.direction) {
|
||||
onSortChange(parsed);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
sortInitialized = true;
|
||||
});
|
||||
|
||||
// Persist sort state whenever it changes (after init)
|
||||
$effect(() => {
|
||||
if (!sortInitialized || !sortState) return;
|
||||
try { localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(sortState)); } catch {}
|
||||
});
|
||||
|
||||
// Sort helpers
|
||||
function toggleSort(field: string) {
|
||||
if (!onSortChange) return;
|
||||
|
||||
if (sortState?.field === field) {
|
||||
onSortChange({
|
||||
field,
|
||||
direction: sortState.direction === 'asc' ? 'desc' : 'asc'
|
||||
});
|
||||
} else {
|
||||
onSortChange({ field, direction: 'asc' });
|
||||
}
|
||||
const newState: DataGridSortState = sortState?.field === field
|
||||
? { field, direction: sortState.direction === 'asc' ? 'desc' : 'asc' }
|
||||
: { field, direction: 'asc' };
|
||||
|
||||
onSortChange(newState);
|
||||
}
|
||||
|
||||
// Virtual scroll state
|
||||
|
||||
@@ -1,4 +1,51 @@
|
||||
[
|
||||
{
|
||||
"version": "1.0.26",
|
||||
"date": "2026-04-19",
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "persist sort order across page navigation for all data grids (#861, #912)" },
|
||||
{ "type": "feature", "text": "show git repository URL and branch in git stack edit modal (#856)" },
|
||||
{ "type": "feature", "text": "show memory limit alongside usage in containers and stacks views (#893)" },
|
||||
{ "type": "feature", "text": "option to delete associated volumes when removing a stack (#655)" },
|
||||
{ "type": "feature", "text": "collapse consecutive port mappings into ranges in container list (#821)" },
|
||||
{ "type": "fix", "text": "bearer token authentication fails with enterprise license active" },
|
||||
{ "type": "fix", "text": "clicking stack name toggles stats accordion instead of just opening editor (#628)" },
|
||||
{ "type": "fix", "text": "scheduled image prune notifications missing environment name (#770)" },
|
||||
{ "type": "fix", "text": "Gotify, ntfy, Pushover, and webhook notifications missing environment name (#943)" },
|
||||
{ "type": "fix", "text": "MFA code field not recognized by Bitwarden and other password managers (#566)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.26"
|
||||
},
|
||||
{
|
||||
"version": "1.0.25",
|
||||
"date": "2026-04-18",
|
||||
"comingSoon": false,
|
||||
"changes": [
|
||||
{ "type": "feature", "text": "API token authentication — Bearer tokens for CI/CD pipelines and scripts" },
|
||||
{ "type": "feature", "text": "Telegram topic support — send notifications to supergroup topics (#855)" },
|
||||
{ "type": "fix", "text": "allow removing healthcheck, ports, and honor startAfterUpdate=false during container edit (#892)" },
|
||||
{ "type": "fix", "text": "validate stack names and prevent broken DB entries on invalid input (#876)" },
|
||||
{ "type": "fix", "text": "use per-environment timezone for schedule execution log timestamps (#882)" },
|
||||
{ "type": "fix", "text": "\"Pull image before update\" and \"Start after update\" settings ignored (#909)" },
|
||||
{ "type": "fix", "text": "image prune timeout on hawser-standard when pruning many images (#905)" },
|
||||
{ "type": "fix", "text": "bump Docker Compose to 5.1.3" },
|
||||
{ "type": "fix", "text": "mask secret environment variables in container inspect modal (#924)" },
|
||||
{ "type": "fix", "text": "viewer role can toggle, delete, and run schedules (#923)" },
|
||||
{ "type": "fix", "text": "settings show defaults instead of saved values after login until page refresh (#921)" },
|
||||
{ "type": "fix", "text": "settings toggle notifications show wrong state (#931)" },
|
||||
{ "type": "fix", "text": "stack memory tooltip shows inflated total on multi-container stacks (#936)" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.25"
|
||||
},
|
||||
{
|
||||
"version": "1.0.24",
|
||||
"date": "2026-04-03",
|
||||
"changes": [
|
||||
{ "type": "fix", "text": "browsing HTTP registries fails with SSL error (#868)" },
|
||||
{ "type": "fix", "text": "git stack deploy options (build, re-pull, force redeploy) not persisted in edit dialog" }
|
||||
],
|
||||
"imageTag": "fnsys/dockhand:v1.0.24"
|
||||
},
|
||||
{
|
||||
"version": "1.0.23",
|
||||
"date": "2026-04-03",
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* API Token Management
|
||||
*
|
||||
* Provides Bearer token authentication for CI/CD pipelines and scripts.
|
||||
* Tokens use `dh_` prefix, Argon2id hashing, and prefix-based lookup.
|
||||
*
|
||||
* Performance: An in-memory cache (SHA-256 key, 60s TTL) avoids running
|
||||
* Argon2id on every request. First request: ~100ms. Subsequent: ~0ms.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db, eq, and } from '$lib/server/db/drizzle';
|
||||
import { hashPassword, verifyPassword, type AuthenticatedUser } from './auth';
|
||||
import { secureRandomBytes } from './crypto-fallback';
|
||||
import { getUserRoles, userHasAdminRole, type Permissions } from './db';
|
||||
import { isEnterprise } from './license';
|
||||
import { tokenCache, ensureCleanupInterval, invalidateTokenCacheForUser, clearTokenCache } from './token-cache';
|
||||
|
||||
// Re-export cache functions so existing consumers don't need to change imports
|
||||
export { invalidateTokenCacheForUser, clearTokenCache } from './token-cache';
|
||||
|
||||
// Dynamic schema import (same pattern as db.ts)
|
||||
let apiTokensTable: any;
|
||||
|
||||
async function getApiTokensTable() {
|
||||
if (apiTokensTable) return apiTokensTable;
|
||||
const isPostgres = !!(process.env.DATABASE_URL && (
|
||||
process.env.DATABASE_URL.startsWith('postgres://') ||
|
||||
process.env.DATABASE_URL.startsWith('postgresql://')
|
||||
));
|
||||
const schema = isPostgres
|
||||
? await import('./db/schema/pg-schema.js')
|
||||
: await import('./db/schema/index.js');
|
||||
apiTokensTable = schema.apiTokens;
|
||||
return apiTokensTable;
|
||||
}
|
||||
|
||||
// Token format: dh_ + 32 bytes base64url = dh_ + 43 chars
|
||||
const TOKEN_PREFIX = 'dh_';
|
||||
const TOKEN_BYTES = 32;
|
||||
const PREFIX_LENGTH = 8; // chars after dh_ stored for identification
|
||||
const MAX_TOKEN_LENGTH = 200;
|
||||
const CACHE_TTL = 60_000; // 60 seconds
|
||||
|
||||
function cacheKey(rawToken: string): string {
|
||||
return createHash('sha256').update(rawToken).digest('hex');
|
||||
}
|
||||
|
||||
// Pre-computed dummy hash for timing protection on invalid prefixes
|
||||
let dummyHash: string | null = null;
|
||||
|
||||
async function getDummyHash(): Promise<string> {
|
||||
if (!dummyHash) {
|
||||
dummyHash = await hashPassword('dh_dummy_token_for_timing_protection');
|
||||
}
|
||||
return dummyHash;
|
||||
}
|
||||
|
||||
// Initialize dummy hash on import (fire and forget)
|
||||
void getDummyHash();
|
||||
|
||||
/**
|
||||
* Generate a new API token.
|
||||
* Returns the plaintext token (shown once) and the database record.
|
||||
*/
|
||||
export async function generateApiToken(
|
||||
userId: number,
|
||||
name: string,
|
||||
expiresAt?: string | null
|
||||
): Promise<{ token: string; id: number; tokenPrefix: string }> {
|
||||
const table = await getApiTokensTable();
|
||||
|
||||
// Generate random token
|
||||
const randomBytes = secureRandomBytes(TOKEN_BYTES);
|
||||
const rawToken = TOKEN_PREFIX + randomBytes.toString('base64url');
|
||||
const tokenPrefix = rawToken.substring(TOKEN_PREFIX.length, TOKEN_PREFIX.length + PREFIX_LENGTH);
|
||||
|
||||
// Hash for storage
|
||||
const tokenHash = await hashPassword(rawToken);
|
||||
|
||||
const result = await db.insert(table).values({
|
||||
userId,
|
||||
name,
|
||||
tokenHash,
|
||||
tokenPrefix,
|
||||
expiresAt: expiresAt || null
|
||||
}).returning();
|
||||
|
||||
return {
|
||||
token: rawToken,
|
||||
id: result[0].id,
|
||||
tokenPrefix
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a Bearer token and return the associated user.
|
||||
* Uses cache to avoid Argon2id on every request.
|
||||
*/
|
||||
export async function validateApiToken(rawToken: string): Promise<AuthenticatedUser | null> {
|
||||
// Input validation
|
||||
if (!rawToken || rawToken.length > MAX_TOKEN_LENGTH || !rawToken.startsWith(TOKEN_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
ensureCleanupInterval();
|
||||
const key = cacheKey(rawToken);
|
||||
const cached = tokenCache.get(key);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.user;
|
||||
}
|
||||
|
||||
const table = await getApiTokensTable();
|
||||
|
||||
// Extract prefix for lookup
|
||||
const prefix = rawToken.substring(TOKEN_PREFIX.length, TOKEN_PREFIX.length + PREFIX_LENGTH);
|
||||
|
||||
// Find tokens with matching prefix (deleted tokens are gone, no isActive filter needed)
|
||||
const candidates = await db
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(table.tokenPrefix, prefix));
|
||||
|
||||
if (candidates.length === 0) {
|
||||
// Timing protection: run Argon2id anyway
|
||||
await verifyPassword(rawToken, await getDummyHash());
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify against each candidate (usually just one)
|
||||
for (const candidate of candidates) {
|
||||
const valid = await verifyPassword(rawToken, candidate.tokenHash);
|
||||
if (!valid) continue;
|
||||
|
||||
// Check expiration AFTER hash verification to avoid timing oracle
|
||||
if (candidate.expiresAt && new Date(candidate.expiresAt) < new Date()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build AuthenticatedUser from the token's user
|
||||
const user = await buildUserFromToken(candidate);
|
||||
if (!user) continue;
|
||||
|
||||
// Update lastUsed (fire and forget — non-critical audit field)
|
||||
void db.update(table)
|
||||
.set({ lastUsed: new Date().toISOString() })
|
||||
.where(eq(table.id, candidate.id))
|
||||
.catch((err) => {
|
||||
if (typeof process !== 'undefined' && process.env.DB_VERBOSE_LOGGING === 'true') {
|
||||
console.debug('[api-tokens] lastUsed update failed:', err?.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Cache the result — cap TTL at token expiry time if sooner
|
||||
let cacheTtl = CACHE_TTL;
|
||||
if (candidate.expiresAt) {
|
||||
const timeUntilExpiry = new Date(candidate.expiresAt).getTime() - Date.now();
|
||||
if (timeUntilExpiry < cacheTtl) {
|
||||
cacheTtl = Math.max(0, timeUntilExpiry);
|
||||
}
|
||||
}
|
||||
tokenCache.set(key, { user, expiresAt: Date.now() + cacheTtl });
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an AuthenticatedUser from a token's database record.
|
||||
*/
|
||||
async function buildUserFromToken(tokenRecord: any): Promise<AuthenticatedUser | null> {
|
||||
// Import getUserWithoutPassword dynamically to avoid circular deps
|
||||
// This avoids keeping passwordHash in memory unnecessarily
|
||||
const { getUserWithoutPassword } = await import('./db');
|
||||
|
||||
const dbUser = await getUserWithoutPassword(tokenRecord.userId);
|
||||
if (!dbUser || !dbUser.isActive) return null;
|
||||
|
||||
const enterprise = await isEnterprise();
|
||||
let isAdmin = false;
|
||||
let permissions: Permissions;
|
||||
|
||||
if (!enterprise) {
|
||||
// Free edition: everyone is effectively admin
|
||||
isAdmin = true;
|
||||
const { getRoleByName } = await import('./db');
|
||||
const adminRole = await getRoleByName('Admin');
|
||||
permissions = adminRole?.permissions ?? {} as Permissions;
|
||||
} else {
|
||||
isAdmin = await userHasAdminRole(dbUser.id);
|
||||
const userRoleAssignments = await getUserRoles(dbUser.id);
|
||||
// Merge permissions from all roles
|
||||
permissions = {} as Permissions;
|
||||
for (const assignment of userRoleAssignments) {
|
||||
if (!assignment.role) continue;
|
||||
const rolePerms = typeof assignment.role.permissions === 'string'
|
||||
? JSON.parse(assignment.role.permissions)
|
||||
: assignment.role.permissions;
|
||||
if (!rolePerms) continue;
|
||||
for (const [key, actions] of Object.entries(rolePerms)) {
|
||||
if (!permissions[key as keyof Permissions]) {
|
||||
permissions[key as keyof Permissions] = [];
|
||||
}
|
||||
for (const action of actions as string[]) {
|
||||
if (!permissions[key as keyof Permissions].includes(action)) {
|
||||
permissions[key as keyof Permissions].push(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine provider from authProvider field
|
||||
let provider: 'local' | 'ldap' | 'oidc' = 'local';
|
||||
if (dbUser.authProvider?.startsWith('ldap')) provider = 'ldap';
|
||||
else if (dbUser.authProvider?.startsWith('oidc')) provider = 'oidc';
|
||||
|
||||
return {
|
||||
id: dbUser.id,
|
||||
username: dbUser.username,
|
||||
email: dbUser.email ?? undefined,
|
||||
displayName: dbUser.displayName ?? undefined,
|
||||
avatar: dbUser.avatar ?? undefined,
|
||||
isAdmin,
|
||||
provider,
|
||||
permissions
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tokens for a user (no hashes returned).
|
||||
*/
|
||||
export async function listUserTokens(userId: number) {
|
||||
const table = await getApiTokensTable();
|
||||
return db
|
||||
.select({
|
||||
id: table.id,
|
||||
name: table.name,
|
||||
tokenPrefix: table.tokenPrefix,
|
||||
lastUsed: table.lastUsed,
|
||||
expiresAt: table.expiresAt,
|
||||
createdAt: table.createdAt
|
||||
})
|
||||
.from(table)
|
||||
.where(eq(table.userId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (delete) a token. Owner or admin can revoke.
|
||||
*/
|
||||
export async function revokeApiToken(tokenId: number, requestingUserId: number, isAdmin: boolean): Promise<boolean> {
|
||||
const table = await getApiTokensTable();
|
||||
|
||||
// Find the token
|
||||
const [token] = await db.select().from(table).where(eq(table.id, tokenId));
|
||||
if (!token) return false;
|
||||
|
||||
// Check ownership or admin
|
||||
if (token.userId !== requestingUserId && !isAdmin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hard-delete
|
||||
await db.delete(table).where(eq(table.id, tokenId));
|
||||
|
||||
// Clear cache — we can't map prefix to SHA-256 cache keys, so clear all
|
||||
clearTokenCache();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { isEnterprise } from './license';
|
||||
import { logAuditEvent, type AuditAction, type AuditEntityType, type AuditLogCreateData } from './db';
|
||||
import { authorize } from './authorize';
|
||||
import { getRequestContext } from './request-context';
|
||||
|
||||
export interface AuditContext {
|
||||
userId?: number | null;
|
||||
@@ -21,7 +22,8 @@ export interface AuditContext {
|
||||
* Extract audit context from a request event
|
||||
*/
|
||||
export async function getAuditContext(event: RequestEvent): Promise<AuditContext> {
|
||||
const auth = await authorize(event.cookies);
|
||||
const ctx = getRequestContext();
|
||||
const user = ctx?.user ?? (await authorize(event.cookies)).user;
|
||||
|
||||
// Get IP address from various headers (proxied requests)
|
||||
const forwardedFor = event.request.headers.get('x-forwarded-for');
|
||||
@@ -40,8 +42,8 @@ export async function getAuditContext(event: RequestEvent): Promise<AuditContext
|
||||
const userAgent = event.request.headers.get('user-agent') || null;
|
||||
|
||||
return {
|
||||
userId: auth.user?.id ?? null,
|
||||
username: auth.user?.username ?? 'anonymous',
|
||||
userId: user?.id ?? null,
|
||||
username: user?.username ?? 'anonymous',
|
||||
ipAddress,
|
||||
userAgent
|
||||
};
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
import { Client as LdapClient } from 'ldapts';
|
||||
import { isEnterprise } from './license';
|
||||
import { secureRandomBytes } from './crypto-fallback';
|
||||
import { invalidateTokenCacheForUser } from './token-cache';
|
||||
|
||||
// Session cookie name
|
||||
const SESSION_COOKIE_NAME = 'dockhand_session';
|
||||
@@ -735,6 +736,9 @@ async function tryLdapAuth(
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cached token permissions after role sync
|
||||
invalidateTokenCacheForUser(user.id);
|
||||
|
||||
if (!user.isActive) {
|
||||
return { success: false, error: 'Account is disabled' };
|
||||
}
|
||||
@@ -1449,6 +1453,9 @@ export async function handleOidcCallback(
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cached token permissions after role sync
|
||||
invalidateTokenCacheForUser(user.id);
|
||||
|
||||
if (!user.isActive) {
|
||||
return { success: false, error: 'Account is disabled' };
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import type { Permissions } from './db';
|
||||
import { getUserAccessibleEnvironments, userCanAccessEnvironment, userHasAdminRole } from './db';
|
||||
import { validateSession, isAuthEnabled, checkPermission, type AuthenticatedUser } from './auth';
|
||||
import { isEnterprise } from './license';
|
||||
import { getRequestContext } from './request-context';
|
||||
|
||||
export interface AuthorizationContext {
|
||||
/** Whether authentication is enabled globally */
|
||||
@@ -113,7 +114,10 @@ export interface AuthorizationContext {
|
||||
export async function authorize(cookies: Cookies): Promise<AuthorizationContext> {
|
||||
const authEnabled = await isAuthEnabled();
|
||||
const enterprise = await isEnterprise();
|
||||
const user = authEnabled ? await validateSession(cookies) : null;
|
||||
|
||||
// Try request context first (set by hook — handles both cookie and Bearer)
|
||||
const reqCtx = getRequestContext();
|
||||
const user = reqCtx?.user ?? (authEnabled ? await validateSession(cookies) : null);
|
||||
|
||||
// Determine admin status:
|
||||
// - Free edition: all authenticated users are effectively admins (full access)
|
||||
@@ -155,8 +159,8 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
|
||||
// Must be authenticated
|
||||
if (!user) return false;
|
||||
|
||||
// Admins can access all environments
|
||||
if (user.isAdmin) return true;
|
||||
// Admins can access all environments (use fresh isAdmin, not cached user.isAdmin)
|
||||
if (isAdmin) return true;
|
||||
|
||||
// In free edition, all authenticated users have full access
|
||||
if (!enterprise) return true;
|
||||
@@ -172,8 +176,8 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
|
||||
// Must be authenticated
|
||||
if (!user) return [];
|
||||
|
||||
// Admins can access all environments
|
||||
if (user.isAdmin) return null;
|
||||
// Admins can access all environments (use fresh isAdmin, not cached user.isAdmin)
|
||||
if (isAdmin) return null;
|
||||
|
||||
// In free edition, all authenticated users have full access
|
||||
if (!enterprise) return null;
|
||||
@@ -189,8 +193,8 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
|
||||
// Must be authenticated
|
||||
if (!user) return false;
|
||||
|
||||
// Admins can always manage users
|
||||
if (user.isAdmin) return true;
|
||||
// Admins can always manage users (use fresh isAdmin, not cached user.isAdmin)
|
||||
if (isAdmin) return true;
|
||||
|
||||
// In free edition, all authenticated users have full access
|
||||
if (!enterprise) return true;
|
||||
|
||||
+50
-1
@@ -1185,6 +1185,37 @@ export async function getUser(id: number): Promise<UserData | null> {
|
||||
return results[0] as UserData || null;
|
||||
}
|
||||
|
||||
export interface SafeUserData {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string | null;
|
||||
displayName: string | null;
|
||||
avatar: string | null;
|
||||
authProvider: string | null;
|
||||
mfaEnabled: boolean;
|
||||
isActive: boolean;
|
||||
lastLogin: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export async function getUserWithoutPassword(id: number): Promise<SafeUserData | null> {
|
||||
const results = await db.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
email: users.email,
|
||||
displayName: users.displayName,
|
||||
avatar: users.avatar,
|
||||
authProvider: users.authProvider,
|
||||
mfaEnabled: users.mfaEnabled,
|
||||
isActive: users.isActive,
|
||||
lastLogin: users.lastLogin,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt
|
||||
}).from(users).where(eq(users.id, id));
|
||||
return results[0] as SafeUserData || null;
|
||||
}
|
||||
|
||||
export async function hasAdminUser(): Promise<boolean> {
|
||||
// Check if any user has the Admin role assigned
|
||||
const adminRole = await db.select().from(roles).where(eq(roles.name, 'Admin')).limit(1);
|
||||
@@ -2091,6 +2122,9 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
|
||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||
webhookEnabled: gitStacks.webhookEnabled,
|
||||
webhookSecret: gitStacks.webhookSecret,
|
||||
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||
repullImages: gitStacks.repullImages,
|
||||
forceRedeploy: gitStacks.forceRedeploy,
|
||||
lastSync: gitStacks.lastSync,
|
||||
lastCommit: gitStacks.lastCommit,
|
||||
syncStatus: gitStacks.syncStatus,
|
||||
@@ -2119,6 +2153,9 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
|
||||
autoUpdateCron: gitStacks.autoUpdateCron,
|
||||
webhookEnabled: gitStacks.webhookEnabled,
|
||||
webhookSecret: gitStacks.webhookSecret,
|
||||
buildOnDeploy: gitStacks.buildOnDeploy,
|
||||
repullImages: gitStacks.repullImages,
|
||||
forceRedeploy: gitStacks.forceRedeploy,
|
||||
lastSync: gitStacks.lastSync,
|
||||
lastCommit: gitStacks.lastCommit,
|
||||
syncStatus: gitStacks.syncStatus,
|
||||
@@ -3040,7 +3077,7 @@ export type AuditAction =
|
||||
export type AuditEntityType =
|
||||
| 'container' | 'image' | 'stack' | 'volume' | 'network'
|
||||
| 'user' | 'role' | 'settings' | 'environment' | 'registry' | 'git_repository' | 'git_credential'
|
||||
| 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack';
|
||||
| 'config_set' | 'notification' | 'oidc_provider' | 'ldap_config' | 'git_stack' | 'api_token';
|
||||
|
||||
export interface AuditLogData {
|
||||
id: number;
|
||||
@@ -4580,6 +4617,18 @@ export async function setStackEnvVars(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the set of secret key names for a stack.
|
||||
* Used to mask secret values in container inspect responses.
|
||||
*/
|
||||
export async function getSecretKeyNames(
|
||||
stackName: string,
|
||||
environmentId?: number | null
|
||||
): Promise<Set<string>> {
|
||||
const vars = await getStackEnvVars(stackName, environmentId, true);
|
||||
return new Set(vars.filter(v => v.isSecret).map(v => v.key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of environment variables for a stack.
|
||||
* @param stackName - Name of the stack
|
||||
|
||||
@@ -335,7 +335,8 @@ const REQUIRED_TABLES = [
|
||||
'audit_logs',
|
||||
'container_events',
|
||||
'schedule_executions',
|
||||
'user_preferences'
|
||||
'user_preferences',
|
||||
'api_tokens'
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -768,7 +769,7 @@ async function seedDatabase(): Promise<void> {
|
||||
license: ['manage'],
|
||||
audit_logs: ['view'],
|
||||
activity: ['view'],
|
||||
schedules: ['view']
|
||||
schedules: ['view', 'edit', 'run']
|
||||
});
|
||||
|
||||
const operatorPermissions = JSON.stringify({
|
||||
@@ -787,7 +788,7 @@ async function seedDatabase(): Promise<void> {
|
||||
license: [],
|
||||
audit_logs: [],
|
||||
activity: ['view'],
|
||||
schedules: ['view']
|
||||
schedules: ['view', 'edit', 'run']
|
||||
});
|
||||
|
||||
const viewerPermissions = JSON.stringify({
|
||||
@@ -898,6 +899,7 @@ export const userPreferences = schemaProxy.userPreferences;
|
||||
export const scheduleExecutions = schemaProxy.scheduleExecutions;
|
||||
export const stackEnvironmentVariables = schemaProxy.stackEnvironmentVariables;
|
||||
export const pendingContainerUpdates = schemaProxy.pendingContainerUpdates;
|
||||
export const apiTokens = schemaProxy.apiTokens;
|
||||
|
||||
// Re-export types from SQLite schema (they're compatible with PostgreSQL)
|
||||
export type {
|
||||
@@ -956,7 +958,9 @@ export type {
|
||||
StackEnvironmentVariable,
|
||||
NewStackEnvironmentVariable,
|
||||
PendingContainerUpdate,
|
||||
NewPendingContainerUpdate
|
||||
NewPendingContainerUpdate,
|
||||
ApiToken,
|
||||
NewApiToken
|
||||
} from './schema/index.js';
|
||||
|
||||
export { eq, and, or, desc, asc, like, sql, inArray, isNull, isNotNull } from 'drizzle-orm';
|
||||
|
||||
@@ -467,6 +467,25 @@ export const pendingContainerUpdates = sqliteTable('pending_container_updates',
|
||||
envContainerUnique: unique().on(table.environmentId, table.containerId)
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// API TOKENS TABLE
|
||||
// =============================================================================
|
||||
|
||||
export const apiTokens = sqliteTable('api_tokens', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
tokenHash: text('token_hash').notNull().unique(),
|
||||
tokenPrefix: text('token_prefix').notNull(),
|
||||
lastUsed: text('last_used'),
|
||||
expiresAt: text('expires_at'),
|
||||
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
|
||||
}, (table) => ({
|
||||
userIdIdx: index('api_tokens_user_id_idx').on(table.userId),
|
||||
tokenPrefixIdx: index('api_tokens_token_prefix_idx').on(table.tokenPrefix)
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// USER PREFERENCES TABLE (unified key-value store)
|
||||
// =============================================================================
|
||||
@@ -570,3 +589,6 @@ export type NewStackEnvironmentVariable = typeof stackEnvironmentVariables.$infe
|
||||
|
||||
export type PendingContainerUpdate = typeof pendingContainerUpdates.$inferSelect;
|
||||
export type NewPendingContainerUpdate = typeof pendingContainerUpdates.$inferInsert;
|
||||
|
||||
export type ApiToken = typeof apiTokens.$inferSelect;
|
||||
export type NewApiToken = typeof apiTokens.$inferInsert;
|
||||
|
||||
@@ -470,6 +470,25 @@ export const pendingContainerUpdates = pgTable('pending_container_updates', {
|
||||
envContainerUnique: unique().on(table.environmentId, table.containerId)
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// API TOKENS TABLE
|
||||
// =============================================================================
|
||||
|
||||
export const apiTokens = pgTable('api_tokens', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
tokenHash: text('token_hash').notNull().unique(),
|
||||
tokenPrefix: text('token_prefix').notNull(),
|
||||
lastUsed: timestamp('last_used', { mode: 'string' }),
|
||||
expiresAt: timestamp('expires_at', { mode: 'string' }),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
|
||||
}, (table) => ({
|
||||
userIdIdx: index('api_tokens_user_id_idx').on(table.userId),
|
||||
tokenPrefixIdx: index('api_tokens_token_prefix_idx').on(table.tokenPrefix)
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// USER PREFERENCES TABLE (unified key-value store)
|
||||
// =============================================================================
|
||||
|
||||
+29
-15
@@ -415,7 +415,8 @@ export function httpsAgentRequest(
|
||||
if (!streaming) {
|
||||
const isComposeOperation = path === '/_hawser/compose';
|
||||
const composeTimeoutMs = parseInt(process.env.COMPOSE_TIMEOUT || '900') * 1000;
|
||||
reqOptions.timeout = isComposeOperation ? composeTimeoutMs : 30000;
|
||||
const isPrune = path.endsWith('/prune');
|
||||
reqOptions.timeout = isComposeOperation ? composeTimeoutMs : isPrune ? 300000 : 30000;
|
||||
}
|
||||
|
||||
// Honor AbortSignal from caller (e.g., AbortSignal.timeout(5000) for ping)
|
||||
@@ -884,7 +885,7 @@ export async function dockerFetch(
|
||||
body,
|
||||
headers,
|
||||
streaming || false,
|
||||
(streaming || path === '/_hawser/compose') ? 300000 : 30000, // 5 min for streaming/compose, 30s for normal
|
||||
(streaming || path === '/_hawser/compose' || path.endsWith('/prune')) ? 300000 : 30000, // 5 min for streaming/compose/prune, 30s for normal
|
||||
isBinary,
|
||||
fetchOptions.signal ?? undefined
|
||||
);
|
||||
@@ -960,7 +961,8 @@ export async function dockerFetch(
|
||||
if (!streaming && !finalOptions.signal) {
|
||||
const isComposeOperation = path === '/_hawser/compose';
|
||||
const composeTimeoutMs = parseInt(process.env.COMPOSE_TIMEOUT || '900') * 1000;
|
||||
finalOptions.signal = AbortSignal.timeout(isComposeOperation ? composeTimeoutMs : 30000);
|
||||
const isPrune = path.endsWith('/prune');
|
||||
finalOptions.signal = AbortSignal.timeout(isComposeOperation ? composeTimeoutMs : isPrune ? 300000 : 30000);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1225,9 +1227,9 @@ export interface DeviceRequest {
|
||||
export interface CreateContainerOptions {
|
||||
name: string;
|
||||
image: string;
|
||||
ports?: { [key: string]: { HostIp?: string; HostPort: string } };
|
||||
ports?: { [key: string]: { HostIp?: string; HostPort: string } } | null;
|
||||
volumes?: { [key: string]: {} };
|
||||
volumeBinds?: string[];
|
||||
volumeBinds?: string[] | null;
|
||||
env?: string[];
|
||||
labels?: { [key: string]: string };
|
||||
cmd?: string[];
|
||||
@@ -1247,7 +1249,7 @@ export interface CreateContainerOptions {
|
||||
networkGwPriority?: number;
|
||||
user?: string | null;
|
||||
privileged?: boolean;
|
||||
healthcheck?: HealthcheckConfig;
|
||||
healthcheck?: HealthcheckConfig | null;
|
||||
memory?: number;
|
||||
memoryReservation?: number;
|
||||
memorySwap?: number;
|
||||
@@ -1338,7 +1340,10 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
containerConfig.User = options.user ?? '';
|
||||
}
|
||||
|
||||
if (options.healthcheck) {
|
||||
if (options.healthcheck === null) {
|
||||
// Explicitly disable healthcheck (user cleared it)
|
||||
containerConfig.Healthcheck = { Test: ["NONE"] };
|
||||
} else if (options.healthcheck) {
|
||||
containerConfig.Healthcheck = {};
|
||||
if (options.healthcheck.test && options.healthcheck.test.length > 0) {
|
||||
containerConfig.Healthcheck.Test = options.healthcheck.test;
|
||||
@@ -1357,7 +1362,11 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
}
|
||||
}
|
||||
|
||||
if (options.ports) {
|
||||
if (options.ports === null) {
|
||||
// Explicitly clear ports (user removed all mappings)
|
||||
containerConfig.ExposedPorts = {};
|
||||
containerConfig.HostConfig.PortBindings = {};
|
||||
} else if (options.ports) {
|
||||
containerConfig.ExposedPorts = {};
|
||||
containerConfig.HostConfig.PortBindings = {};
|
||||
|
||||
@@ -1367,7 +1376,10 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
}
|
||||
}
|
||||
|
||||
if (options.volumeBinds && options.volumeBinds.length > 0) {
|
||||
if (options.volumeBinds === null) {
|
||||
// Explicitly clear volume binds (user removed all)
|
||||
containerConfig.HostConfig.Binds = [];
|
||||
} else if (options.volumeBinds && options.volumeBinds.length > 0) {
|
||||
containerConfig.HostConfig.Binds = options.volumeBinds;
|
||||
}
|
||||
|
||||
@@ -2393,7 +2405,7 @@ export async function updateContainer(id: string, options: Partial<CreateContain
|
||||
}
|
||||
|
||||
// 5. Start if needed
|
||||
if (startAfterUpdate || wasRunning) {
|
||||
if (startAfterUpdate) {
|
||||
try {
|
||||
await newContainer.start();
|
||||
} catch (startError) {
|
||||
@@ -2625,7 +2637,9 @@ function parseImageReference(imageName: string): { registry: string; repo: strin
|
||||
* 'ghcr.io' -> { host: 'ghcr.io', path: '', fullRegistry: 'ghcr.io' }
|
||||
* 'registry.example.com:5000/myorg' -> { host: 'registry.example.com:5000', path: '/myorg', fullRegistry: 'registry.example.com:5000/myorg' }
|
||||
*/
|
||||
export function parseRegistryUrl(url: string): { host: string; path: string; fullRegistry: string } {
|
||||
export function parseRegistryUrl(url: string): { host: string; path: string; fullRegistry: string; protocol: string } {
|
||||
// Detect protocol (default to https)
|
||||
const protocol = url.startsWith('http://') ? 'http' : 'https';
|
||||
// Remove protocol
|
||||
const withoutProtocol = url.replace(/^https?:\/\//, '');
|
||||
// Remove trailing slash
|
||||
@@ -2633,11 +2647,11 @@ export function parseRegistryUrl(url: string): { host: string; path: string; ful
|
||||
// Split on first slash (after port if present)
|
||||
const slashIndex = trimmed.indexOf('/');
|
||||
if (slashIndex === -1) {
|
||||
return { host: trimmed, path: '', fullRegistry: trimmed };
|
||||
return { host: trimmed, path: '', fullRegistry: trimmed, protocol };
|
||||
}
|
||||
const host = trimmed.substring(0, slashIndex);
|
||||
const path = trimmed.substring(slashIndex); // includes leading /
|
||||
return { host, path, fullRegistry: trimmed };
|
||||
return { host, path, fullRegistry: trimmed, protocol };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2822,7 +2836,7 @@ export async function getRegistryAuthHeader(
|
||||
try {
|
||||
// Parse URL to extract host (V2 API is always at the host root)
|
||||
const parsed = parseRegistryUrl(registryUrl);
|
||||
const apiBaseUrl = `https://${parsed.host}`;
|
||||
const apiBaseUrl = `${parsed.protocol}://${parsed.host}`;
|
||||
|
||||
// Step 1: Challenge request to /v2/ (always at registry root, not under org path)
|
||||
const challengeResponse = await fetch(`${apiBaseUrl}/v2/`, {
|
||||
@@ -2931,7 +2945,7 @@ export async function getRegistryAuth(
|
||||
const parsed = parseRegistryUrl(registry.url);
|
||||
|
||||
// V2 API endpoints are always at the registry host root
|
||||
const baseUrl = `https://${parsed.host}`;
|
||||
const baseUrl = `${parsed.protocol}://${parsed.host}`;
|
||||
|
||||
// Get auth header using proper token flow
|
||||
const credentials = registry.username && registry.password
|
||||
|
||||
@@ -277,13 +277,13 @@ async function sendMattermost(appriseUrl: string, payload: NotificationPayload):
|
||||
|
||||
// Telegram
|
||||
async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// tgram://bot_token/chat_id
|
||||
const match = appriseUrl.match(/^tgram:\/\/([^/]+)\/(.+)/);
|
||||
// tgram://bot_token/chat_id:topic_id?
|
||||
const match = appriseUrl.match(/^tgram:\/\/([^/]+)\/([^:\/]+)(?::(\d+))?$/);
|
||||
if (!match) {
|
||||
return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id' };
|
||||
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] = match;
|
||||
const [, botToken, chatId, topicIdStr] = match;
|
||||
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
||||
|
||||
// Escape markdown special characters in title and message
|
||||
@@ -291,6 +291,8 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P
|
||||
const escapedMessage = escapeTelegramMarkdown(payload.message);
|
||||
const envTag = payload.environmentName ? ` \\[${escapeTelegramMarkdown(payload.environmentName)}\\]` : '';
|
||||
|
||||
const topicId = topicIdStr ? parseInt(topicIdStr, 10) : undefined;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
@@ -298,6 +300,7 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: `*${escapedTitle}*${envTag}\n${escapedMessage}`,
|
||||
...(topicId ? { message_thread_id: topicId } : {}),
|
||||
parse_mode: 'Markdown'
|
||||
})
|
||||
});
|
||||
@@ -331,12 +334,14 @@ async function sendGotify(appriseUrl: string, payload: NotificationPayload): Pro
|
||||
const token = lastSlash >= 0 ? pathPart.substring(lastSlash + 1) : pathPart;
|
||||
const url = `${protocol}://${hostname}${subpath ? '/' + subpath : ''}/message?token=${token}`;
|
||||
|
||||
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({
|
||||
title: payload.title,
|
||||
title: titleWithEnv,
|
||||
message: payload.message,
|
||||
priority: payload.type === 'error' ? 8 : payload.type === 'warning' ? 5 : 2
|
||||
})
|
||||
@@ -392,8 +397,9 @@ async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promi
|
||||
url = `https://ntfy.sh/${path}`;
|
||||
}
|
||||
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
const headers: Record<string, string> = {
|
||||
'Title': payload.title,
|
||||
'Title': titleWithEnv,
|
||||
'Priority': payload.type === 'error' ? '5' : payload.type === 'warning' ? '4' : '3',
|
||||
'Tags': payload.type || 'info'
|
||||
};
|
||||
@@ -430,6 +436,7 @@ async function sendPushover(appriseUrl: string, payload: NotificationPayload): P
|
||||
|
||||
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, {
|
||||
@@ -438,7 +445,7 @@ async function sendPushover(appriseUrl: string, payload: NotificationPayload): P
|
||||
body: JSON.stringify({
|
||||
token: apiToken,
|
||||
user: userKey,
|
||||
title: payload.title,
|
||||
title: titleWithEnv,
|
||||
message: payload.message,
|
||||
priority: payload.type === 'error' ? 1 : 0
|
||||
})
|
||||
@@ -468,6 +475,7 @@ async function sendGenericWebhook(appriseUrl: string, payload: NotificationPaylo
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
type: payload.type || 'info',
|
||||
environment: payload.environmentName || null,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Request-scoped context using AsyncLocalStorage.
|
||||
*
|
||||
* The hook sets the authenticated user (from cookie or Bearer token)
|
||||
* and wraps resolve() in requestContext.run(). Downstream code like
|
||||
* authorize() reads the pre-resolved user from here instead of
|
||||
* re-validating the session.
|
||||
*/
|
||||
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import type { AuthenticatedUser } from './auth';
|
||||
|
||||
export interface RequestContext {
|
||||
user: AuthenticatedUser | null;
|
||||
authEnabled: boolean;
|
||||
authMethod: 'cookie' | 'bearer' | 'none';
|
||||
}
|
||||
|
||||
export const requestContext = new AsyncLocalStorage<RequestContext>();
|
||||
|
||||
export function getRequestContext(): RequestContext | undefined {
|
||||
return requestContext.getStore();
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export async function runImagePrune(
|
||||
// Send success notification only when something was actually cleaned up
|
||||
if (imagesRemoved > 0) {
|
||||
await sendEventNotification('image_prune_success', {
|
||||
title: 'Image prune completed',
|
||||
title: `Image prune completed — ${env.name}`,
|
||||
message: `${imagesRemoved} unused images removed, ${formatBytes(spaceReclaimed)} disk space reclaimed`,
|
||||
type: 'success'
|
||||
}, envId);
|
||||
@@ -142,7 +142,7 @@ export async function runImagePrune(
|
||||
|
||||
// Send failure notification
|
||||
await sendEventNotification('image_prune_failed', {
|
||||
title: 'Image prune failed',
|
||||
title: `Image prune failed — ${env.name}`,
|
||||
message: `Failed to prune images: ${error.message}`,
|
||||
type: 'error'
|
||||
}, envId);
|
||||
|
||||
@@ -752,9 +752,10 @@ async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]', api
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract registry host from URL
|
||||
const url = new URL(reg.url);
|
||||
const registryHost = url.host;
|
||||
// Extract registry host from URL (parseRegistryUrl handles bare hostnames like 'ghcr.io')
|
||||
const { parseRegistryUrl } = await import('./docker.js');
|
||||
const { host } = parseRegistryUrl(reg.url);
|
||||
const registryHost = host;
|
||||
|
||||
console.log(`${logPrefix} Logging into registry: ${registryHost}`);
|
||||
|
||||
@@ -1981,7 +1982,8 @@ export async function downStack(
|
||||
export async function removeStack(
|
||||
stackName: string,
|
||||
envId?: number | null,
|
||||
force = false
|
||||
force = false,
|
||||
removeVolumes = false
|
||||
): Promise<StackOperationResult> {
|
||||
return withStackLock(stackName, async () => {
|
||||
// Get compose file (may not exist for external stacks)
|
||||
@@ -1999,6 +2001,7 @@ export async function removeStack(
|
||||
{
|
||||
stackName,
|
||||
envId,
|
||||
removeVolumes,
|
||||
workingDir: composeResult.stackDir,
|
||||
composePath: composeResult.composePath ?? undefined,
|
||||
envPath: composeResult.envPath ?? undefined
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* API Token Cache — LRU with TTL
|
||||
*
|
||||
* In-memory cache for validated API tokens. Without this, every Bearer
|
||||
* request would run Argon2id verification (~64MB RAM, ~100ms CPU per call).
|
||||
*
|
||||
* Cache key is SHA-256(fullToken) — safe to hold in memory (not reversible).
|
||||
* TTL is 60s by default, capped at the token's expiry time if sooner.
|
||||
*
|
||||
* Uses a bounded LRU (max 1024 entries) to cap memory usage. On overflow
|
||||
* the least-recently-used entry is evicted. Expired entries are also pruned
|
||||
* every 5 minutes.
|
||||
*
|
||||
* Separated from api-tokens.ts to avoid circular dependencies
|
||||
* (auth.ts ↔ api-tokens.ts).
|
||||
*/
|
||||
|
||||
export interface TokenCacheEntry {
|
||||
user: { id: number; [key: string]: any };
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const MAX_CACHE_SIZE = 1024;
|
||||
|
||||
/**
|
||||
* Simple LRU cache backed by a Map.
|
||||
* Map iteration order is insertion order, so we delete-and-re-set on every
|
||||
* access to move the entry to the "newest" position. The oldest entry
|
||||
* (Map.keys().next()) is evicted when the cache exceeds MAX_CACHE_SIZE.
|
||||
*/
|
||||
class LruTokenCache {
|
||||
private map = new Map<string, TokenCacheEntry>();
|
||||
|
||||
get(key: string): TokenCacheEntry | undefined {
|
||||
const entry = this.map.get(key);
|
||||
if (!entry) return undefined;
|
||||
// Move to end (most-recently-used)
|
||||
this.map.delete(key);
|
||||
this.map.set(key, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
set(key: string, entry: TokenCacheEntry): void {
|
||||
// If key already exists, delete first so re-insert moves it to end
|
||||
if (this.map.has(key)) {
|
||||
this.map.delete(key);
|
||||
}
|
||||
this.map.set(key, entry);
|
||||
// Evict oldest if over capacity
|
||||
if (this.map.size > MAX_CACHE_SIZE) {
|
||||
const oldest = this.map.keys().next().value!;
|
||||
this.map.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.map.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
}
|
||||
|
||||
entries(): IterableIterator<[string, TokenCacheEntry]> {
|
||||
return this.map.entries();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.map.size;
|
||||
}
|
||||
}
|
||||
|
||||
export const tokenCache = new LruTokenCache();
|
||||
|
||||
/**
|
||||
* Invalidate all cached tokens for a specific user.
|
||||
* Call when user permissions change, roles are updated, or user is deleted/deactivated.
|
||||
*/
|
||||
export function invalidateTokenCacheForUser(userId: number): void {
|
||||
for (const [key, entry] of tokenCache.entries()) {
|
||||
if (entry.user.id === userId) {
|
||||
tokenCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the entire token cache.
|
||||
* Called on revocation, role permission edits, and license state changes.
|
||||
*/
|
||||
export function clearTokenCache(): void {
|
||||
tokenCache.clear();
|
||||
}
|
||||
|
||||
// Periodic cleanup every 5 minutes
|
||||
let cleanupInterval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
export function ensureCleanupInterval(): void {
|
||||
if (cleanupInterval) return;
|
||||
cleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of tokenCache.entries()) {
|
||||
if (entry.expiresAt <= now) {
|
||||
tokenCache.delete(key);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
if (cleanupInterval.unref) cleanupInterval.unref();
|
||||
}
|
||||
@@ -349,7 +349,10 @@ function createSettingsStore() {
|
||||
});
|
||||
},
|
||||
// Manual refresh from database
|
||||
refresh: loadSettings
|
||||
refresh: () => {
|
||||
initialized = false;
|
||||
return loadSettings();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
export interface PortMapping {
|
||||
publicPort: number;
|
||||
privatePort: number;
|
||||
display: string;
|
||||
isRange?: boolean;
|
||||
}
|
||||
|
||||
interface PortInfo {
|
||||
PublicPort?: number;
|
||||
PrivatePort?: number;
|
||||
publicPort?: number;
|
||||
privatePort?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Docker port mappings, collapsing consecutive ranges.
|
||||
* Accepts both Docker API format (PublicPort/PrivatePort) and camelCase (publicPort/privatePort).
|
||||
* e.g. 8080:8080, 8081:8081, 8082:8082 → 8080-8082:8080-8082
|
||||
*/
|
||||
export function formatPorts(ports: PortInfo[] | undefined | null): PortMapping[] {
|
||||
if (!ports || ports.length === 0) return [];
|
||||
const seen = new Set<string>();
|
||||
const individual = ports
|
||||
.filter(p => (p.PublicPort || p.publicPort))
|
||||
.map(p => ({
|
||||
publicPort: p.PublicPort || p.publicPort!,
|
||||
privatePort: p.PrivatePort || p.privatePort!,
|
||||
display: `${p.PublicPort || p.publicPort}:${p.PrivatePort || p.privatePort}`
|
||||
}))
|
||||
.filter(p => {
|
||||
const key = p.display;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => a.publicPort - b.publicPort);
|
||||
|
||||
// Collapse consecutive port ranges
|
||||
if (individual.length <= 1) return individual;
|
||||
|
||||
const result: PortMapping[] = [];
|
||||
let rangeStart = individual[0];
|
||||
let rangeEnd = individual[0];
|
||||
|
||||
for (let i = 1; i < individual.length; i++) {
|
||||
const curr = individual[i];
|
||||
const offset = curr.publicPort - rangeStart.publicPort;
|
||||
const expectedPrivate = rangeStart.privatePort + offset;
|
||||
if (curr.publicPort === rangeEnd.publicPort + 1 && curr.privatePort === expectedPrivate) {
|
||||
rangeEnd = curr;
|
||||
} else {
|
||||
result.push(rangeStart.publicPort === rangeEnd.publicPort
|
||||
? rangeStart
|
||||
: { publicPort: rangeStart.publicPort, privatePort: rangeStart.privatePort, display: `${rangeStart.publicPort}-${rangeEnd.publicPort}:${rangeStart.privatePort}-${rangeEnd.privatePort}`, isRange: true });
|
||||
rangeStart = curr;
|
||||
rangeEnd = curr;
|
||||
}
|
||||
}
|
||||
result.push(rangeStart.publicPort === rangeEnd.publicPort
|
||||
? rangeStart
|
||||
: { publicPort: rangeStart.publicPort, privatePort: rangeStart.privatePort, display: `${rangeStart.publicPort}-${rangeEnd.publicPort}:${rangeStart.privatePort}-${rangeEnd.privatePort}`, isRange: true });
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { generateApiToken, listUserTokens } from '$lib/server/api-tokens';
|
||||
import { isAuthEnabled, verifyPassword } from '$lib/server/auth';
|
||||
import { getUser } from '$lib/server/db';
|
||||
import { audit } from '$lib/server/audit';
|
||||
import { getRequestContext } from '$lib/server/request-context';
|
||||
|
||||
// Password confirmation rate limiting (per userId)
|
||||
const pwFailCounts = new Map<number, { count: number; firstFail: number }>();
|
||||
const pwCooldowns = new Map<number, number>();
|
||||
const PW_FAIL_WINDOW = 60_000; // 1-minute sliding window
|
||||
const PW_FAIL_MAX = 5; // max failures per window
|
||||
const PW_COOLDOWN = 5 * 60 * 1000; // 5-minute cooldown
|
||||
|
||||
const MAX_TOKENS_PER_USER = 25;
|
||||
|
||||
// Periodic cleanup
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [id, until] of pwCooldowns) {
|
||||
if (now > until) pwCooldowns.delete(id);
|
||||
}
|
||||
for (const [id, entry] of pwFailCounts) {
|
||||
if (now - entry.firstFail > PW_FAIL_WINDOW) pwFailCounts.delete(id);
|
||||
}
|
||||
}, PW_COOLDOWN).unref?.();
|
||||
|
||||
function isPwRateLimited(userId: number): boolean {
|
||||
const until = pwCooldowns.get(userId);
|
||||
if (!until) return false;
|
||||
if (Date.now() > until) {
|
||||
pwCooldowns.delete(userId);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function recordPwFailure(userId: number): void {
|
||||
const now = Date.now();
|
||||
const entry = pwFailCounts.get(userId);
|
||||
if (!entry || now - entry.firstFail > PW_FAIL_WINDOW) {
|
||||
pwFailCounts.set(userId, { count: 1, firstFail: now });
|
||||
return;
|
||||
}
|
||||
entry.count++;
|
||||
if (entry.count >= PW_FAIL_MAX) {
|
||||
pwCooldowns.set(userId, now + PW_COOLDOWN);
|
||||
pwFailCounts.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/tokens - List the current user's API tokens
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const authEnabled = await isAuthEnabled();
|
||||
if (!authEnabled) {
|
||||
return json({ error: 'Authentication is not enabled' }, { status: 400 });
|
||||
}
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
if (!auth.isAuthenticated || !auth.user) {
|
||||
return json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tokens = await listUserTokens(auth.user.id);
|
||||
return json(tokens);
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/auth/tokens - Create a new API token
|
||||
*/
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { cookies, request } = event;
|
||||
|
||||
const authEnabled = await isAuthEnabled();
|
||||
if (!authEnabled) {
|
||||
return json({ error: 'Authentication is not enabled' }, { status: 400 });
|
||||
}
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
if (!auth.isAuthenticated || !auth.user) {
|
||||
return json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Token creation requires a cookie session — a Bearer token cannot create new tokens
|
||||
const reqCtx = getRequestContext();
|
||||
if (reqCtx?.authMethod === 'bearer') {
|
||||
return json({ error: 'Token creation requires a session login, not a Bearer token' }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
const { name, expiresAt, password } = body;
|
||||
|
||||
// Local users must confirm their password to create tokens
|
||||
// SSO/OIDC and LDAP users skip this (they authenticated via their IdP)
|
||||
if (auth.user.provider === 'local') {
|
||||
if (isPwRateLimited(auth.user.id)) {
|
||||
return json({ error: 'Too many failed password attempts. Try again later.' }, { status: 429 });
|
||||
}
|
||||
if (!password || typeof password !== 'string') {
|
||||
return json({ error: 'Password is required to create an API token' }, { status: 400 });
|
||||
}
|
||||
const dbUser = await getUser(auth.user.id);
|
||||
if (!dbUser) {
|
||||
return json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
const valid = await verifyPassword(password, dbUser.passwordHash);
|
||||
if (!valid) {
|
||||
recordPwFailure(auth.user.id);
|
||||
return json({ error: 'Invalid password' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// L2: Per-user token count limit
|
||||
const existingTokens = await listUserTokens(auth.user.id);
|
||||
if (existingTokens.length >= MAX_TOKENS_PER_USER) {
|
||||
return json({ error: `Maximum of ${MAX_TOKENS_PER_USER} API tokens per user` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate name
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
return json({ error: 'Token name is required' }, { status: 400 });
|
||||
}
|
||||
if (name.length > 255) {
|
||||
return json({ error: 'Token name must be 255 characters or less' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate expiration
|
||||
if (expiresAt) {
|
||||
const expiryDate = new Date(expiresAt);
|
||||
if (isNaN(expiryDate.getTime())) {
|
||||
return json({ error: 'Invalid expiration date' }, { status: 400 });
|
||||
}
|
||||
if (expiryDate <= new Date()) {
|
||||
return json({ error: 'Expiration date must be in the future' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await generateApiToken(
|
||||
auth.user.id,
|
||||
name.trim(),
|
||||
expiresAt || null
|
||||
);
|
||||
|
||||
await audit(event, 'create', 'api_token', {
|
||||
entityId: String(result.id),
|
||||
entityName: name.trim(),
|
||||
description: `API token "${name.trim()}" created`
|
||||
});
|
||||
|
||||
return json({
|
||||
id: result.id,
|
||||
token: result.token,
|
||||
tokenPrefix: result.tokenPrefix
|
||||
}, { status: 201, headers: { 'Cache-Control': 'no-store' } });
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { revokeApiToken } from '$lib/server/api-tokens';
|
||||
import { isAuthEnabled } from '$lib/server/auth';
|
||||
import { getRequestContext } from '$lib/server/request-context';
|
||||
import { audit } from '$lib/server/audit';
|
||||
|
||||
/**
|
||||
* DELETE /api/auth/tokens/[id] - Revoke an API token
|
||||
*/
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
const { cookies, params } = event;
|
||||
|
||||
const authEnabled = await isAuthEnabled();
|
||||
if (!authEnabled) {
|
||||
return json({ error: 'Authentication is not enabled' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Bearer tokens cannot manage tokens (prevent leaked token from revoking others)
|
||||
const reqCtx = getRequestContext();
|
||||
if (reqCtx?.authMethod === 'bearer') {
|
||||
return json({ error: 'Token management requires a cookie session' }, { status: 403 });
|
||||
}
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
if (!auth.isAuthenticated || !auth.user) {
|
||||
return json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tokenId = parseInt(params.id);
|
||||
if (isNaN(tokenId)) {
|
||||
return json({ error: 'Invalid token ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const success = await revokeApiToken(tokenId, auth.user.id, auth.isAdmin);
|
||||
if (!success) {
|
||||
return json({ error: 'Token not found or access denied' }, { status: 404 });
|
||||
}
|
||||
|
||||
await audit(event, 'delete', 'api_token', {
|
||||
entityId: params.id,
|
||||
description: `API token revoked`
|
||||
});
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
deleteAutoUpdateSchedule
|
||||
} from '$lib/server/db';
|
||||
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
try {
|
||||
@@ -38,7 +39,12 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, url, request }) => {
|
||||
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const containerName = decodeURIComponent(params.containerName);
|
||||
const envIdParam = url.searchParams.get('env');
|
||||
@@ -101,7 +107,12 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, url }) => {
|
||||
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const containerName = decodeURIComponent(params.containerName);
|
||||
const envIdParam = url.searchParams.get('env');
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
removeContainer,
|
||||
getContainerLogs
|
||||
} from '$lib/server/docker';
|
||||
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, removePendingContainerUpdate } from '$lib/server/db';
|
||||
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, getSecretKeyNames, removePendingContainerUpdate } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { unregisterSchedule } from '$lib/server/scheduler';
|
||||
@@ -33,6 +33,24 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
try {
|
||||
|
||||
const details = await inspectContainer(params.id, envIdNum);
|
||||
|
||||
// Mask secret env vars for containers belonging to a Compose stack
|
||||
const stackName = details.Config?.Labels?.['com.docker.compose.project'];
|
||||
if (stackName && Array.isArray(details.Config?.Env)) {
|
||||
const secretKeys = await getSecretKeyNames(stackName, envIdNum);
|
||||
if (secretKeys.size > 0) {
|
||||
details.Config.Env = details.Config.Env.map((entry: string) => {
|
||||
const eqIdx = entry.indexOf('=');
|
||||
if (eqIdx === -1) return entry;
|
||||
const key = entry.substring(0, eqIdx);
|
||||
if (secretKeys.has(key)) {
|
||||
return `${key}=***`;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return json(details);
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode === 404) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { inspectContainer } from '$lib/server/docker';
|
||||
import { getSecretKeyNames } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
|
||||
@@ -20,6 +21,24 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
try {
|
||||
const containerData = await inspectContainer(params.id, envIdNum);
|
||||
|
||||
// Mask secret env vars for containers belonging to a Compose stack
|
||||
const stackName = containerData.Config?.Labels?.['com.docker.compose.project'];
|
||||
if (stackName && Array.isArray(containerData.Config?.Env)) {
|
||||
const secretKeys = await getSecretKeyNames(stackName, envIdNum);
|
||||
if (secretKeys.size > 0) {
|
||||
containerData.Config.Env = containerData.Config.Env.map((entry: string) => {
|
||||
const eqIdx = entry.indexOf('=');
|
||||
if (eqIdx === -1) return entry;
|
||||
const key = entry.substring(0, eqIdx);
|
||||
if (secretKeys.has(key)) {
|
||||
return `${key}=***`;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return json(containerData);
|
||||
} catch (error) {
|
||||
console.error('Failed to inspect container:', error);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { pullImage, updateContainer, type CreateContainerOptions } from '$lib/server/docker';
|
||||
import { inspectContainer, pullImage, updateContainer, type CreateContainerOptions } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { removePendingContainerUpdate } from '$lib/server/db';
|
||||
@@ -25,6 +25,29 @@ export const POST: RequestHandler = async (event) => {
|
||||
const body = await request.json();
|
||||
const { startAfterUpdate, repullImage, ...options } = body;
|
||||
|
||||
// Resolve masked secret values (***) back to real values from the current container.
|
||||
// The GET endpoint masks secrets in Config.Env, so the edit modal sends *** for unchanged secrets.
|
||||
if (Array.isArray(options.env) && options.env.some((e: string) => e.endsWith('=***'))) {
|
||||
const currentData = await inspectContainer(params.id, envIdNum);
|
||||
const currentEnvMap = new Map<string, string>();
|
||||
for (const entry of currentData.Config?.Env || []) {
|
||||
const eqIdx = entry.indexOf('=');
|
||||
if (eqIdx !== -1) {
|
||||
currentEnvMap.set(entry.substring(0, eqIdx), entry.substring(eqIdx + 1));
|
||||
}
|
||||
}
|
||||
options.env = options.env.map((entry: string) => {
|
||||
const eqIdx = entry.indexOf('=');
|
||||
if (eqIdx === -1) return entry;
|
||||
const key = entry.substring(0, eqIdx);
|
||||
const value = entry.substring(eqIdx + 1);
|
||||
if (value === '***' && currentEnvMap.has(key)) {
|
||||
return `${key}=${currentEnvMap.get(key)}`;
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
if (repullImage) {
|
||||
console.log(`Pulling image...`);
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
|
||||
import { getEnvironments, getEnvironmentByName, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, getImagePruneSettings, type Environment } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditEnvironment } from '$lib/server/audit';
|
||||
import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
|
||||
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
|
||||
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
|
||||
import { cleanPem } from '$lib/utils/pem';
|
||||
@@ -130,6 +131,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
const adminRole = await getRoleByName('Admin');
|
||||
if (adminRole) {
|
||||
await assignUserRole(user.id, adminRole.id, env.id);
|
||||
invalidateTokenCacheForUser(user.id);
|
||||
}
|
||||
} catch (roleError) {
|
||||
// Log but don't fail - environment was created successfully
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getHostname
|
||||
} from '$lib/server/license';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { clearTokenCache } from '$lib/server/api-tokens';
|
||||
|
||||
// GET /api/license - Get current license status
|
||||
// Any authenticated user can view license status (needed to determine if RBAC applies)
|
||||
@@ -59,6 +60,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Permission model changes between free/enterprise — clear cached tokens
|
||||
clearTokenCache();
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
license: result.license
|
||||
@@ -81,6 +85,8 @@ export const DELETE: RequestHandler = async ({ cookies }) => {
|
||||
|
||||
try {
|
||||
await deactivateLicense();
|
||||
// Permission model changes between free/enterprise — clear cached tokens
|
||||
clearTokenCache();
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deactivating license:', error);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { getUser, updateUser as dbUpdateUser, deleteUserSessions, userHasAdminRole } from '$lib/server/db';
|
||||
import { validateSession, hashPassword, isAuthEnabled } from '$lib/server/auth';
|
||||
import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
|
||||
|
||||
// GET /api/profile - Get current user's profile
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
@@ -85,8 +86,9 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
|
||||
}
|
||||
|
||||
updateData.passwordHash = await hashPassword(data.newPassword);
|
||||
// Invalidate other sessions on password change
|
||||
deleteUserSessions(currentUser.id);
|
||||
// Invalidate other sessions and token cache on password change
|
||||
await deleteUserSessions(currentUser.id);
|
||||
invalidateTokenCacheForUser(currentUser.id);
|
||||
}
|
||||
|
||||
const user = await dbUpdateUser(currentUser.id, updateData);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditRole } from '$lib/server/audit';
|
||||
import { computeAuditDiff } from '$lib/utils/diff';
|
||||
import { clearTokenCache } from '$lib/server/api-tokens';
|
||||
|
||||
// GET /api/roles/[id] - Get a specific role
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
@@ -75,6 +76,9 @@ export const PUT: RequestHandler = async (event) => {
|
||||
return json({ error: 'Failed to update role' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Clear token cache — any cached user with this role has stale permissions
|
||||
clearTokenCache();
|
||||
|
||||
// Compute diff for audit
|
||||
const diff = computeAuditDiff(existingRole, role);
|
||||
|
||||
@@ -128,6 +132,9 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
return json({ error: 'Failed to delete role' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Clear token cache — users with this role may have stale cached permissions
|
||||
clearTokenCache();
|
||||
|
||||
// Audit log
|
||||
await auditRole(event, 'delete', id, role.name);
|
||||
|
||||
|
||||
@@ -13,8 +13,14 @@ import {
|
||||
deleteImagePruneSettings
|
||||
} from '$lib/server/db';
|
||||
import { unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params }) => {
|
||||
try {
|
||||
const { type, id } = params;
|
||||
const scheduleId = parseInt(id, 10);
|
||||
|
||||
@@ -11,8 +11,14 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck, triggerImagePrune } from '$lib/server/scheduler';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('schedules', 'run')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ params }) => {
|
||||
try {
|
||||
const { type, id } = params;
|
||||
const scheduleId = parseInt(id, 10);
|
||||
|
||||
@@ -7,8 +7,14 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getAutoUpdateSettingById, updateAutoUpdateSettingById, getGitStack, updateGitStack, getEnvUpdateCheckSettings, setEnvUpdateCheckSettings, getImagePruneSettings, setImagePruneSettings } from '$lib/server/db';
|
||||
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ params }) => {
|
||||
try {
|
||||
const { type, id } = params;
|
||||
const scheduleId = parseInt(id, 10);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getScheduleExecution, deleteScheduleExecution } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
try {
|
||||
@@ -28,7 +29,12 @@ export const GET: RequestHandler = async ({ params }) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params }) => {
|
||||
export const DELETE: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
|
||||
@@ -144,10 +144,13 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
|
||||
// ALWAYS save compose file first - deployStack expects it to exist
|
||||
await saveStackComposeFile(name, compose, true, envIdNum, {
|
||||
const saveResult = await saveStackComposeFile(name, compose, true, envIdNum, {
|
||||
composePath: composePath || undefined,
|
||||
envPath: envPath || undefined
|
||||
});
|
||||
if (!saveResult.success) {
|
||||
return json({ error: saveResult.error }, { status: 400 });
|
||||
}
|
||||
|
||||
// Save environment variables BEFORE deploying so they're available during start
|
||||
if (rawEnvContent || (envVars && Array.isArray(envVars) && envVars.length > 0)) {
|
||||
|
||||
@@ -9,6 +9,7 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const force = url.searchParams.get('force') === 'true';
|
||||
const volumes = url.searchParams.get('volumes') === 'true';
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
@@ -24,10 +25,10 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
|
||||
try {
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
const result = await removeStack(stackName, envIdNum, force);
|
||||
const result = await removeStack(stackName, envIdNum, force, volumes);
|
||||
|
||||
// Audit log
|
||||
await auditStack(event, 'delete', stackName, envIdNum, { force });
|
||||
await auditStack(event, 'delete', stackName, envIdNum, { force, volumes });
|
||||
|
||||
if (!result.success) {
|
||||
return json({ success: false, error: result.error }, { status: 400 });
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '$lib/server/db';
|
||||
import { hashPassword, createUserSession } from '$lib/server/auth';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
|
||||
import { auditUser } from '$lib/server/audit';
|
||||
|
||||
// GET /api/users - List all users
|
||||
@@ -108,6 +109,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
const adminRole = await getRoleByName('Admin');
|
||||
if (adminRole) {
|
||||
await assignUserRole(user.id, adminRole.id, null);
|
||||
invalidateTokenCacheForUser(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { hashPassword } from '$lib/server/auth';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditUser } from '$lib/server/audit';
|
||||
import { computeAuditDiff } from '$lib/utils/diff';
|
||||
import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
|
||||
|
||||
// GET /api/users/[id] - Get a specific user
|
||||
// Free for all - local users are needed for basic auth
|
||||
@@ -125,6 +126,7 @@ export const PUT: RequestHandler = async (event) => {
|
||||
if (isDeactivating) {
|
||||
updateData.isActive = false;
|
||||
await deleteUserSessions(userId);
|
||||
invalidateTokenCacheForUser(userId);
|
||||
}
|
||||
|
||||
// Disable authentication
|
||||
@@ -142,6 +144,7 @@ export const PUT: RequestHandler = async (event) => {
|
||||
if (adminRole) {
|
||||
await removeUserRole(userId, adminRole.id, null);
|
||||
}
|
||||
invalidateTokenCacheForUser(userId);
|
||||
}
|
||||
|
||||
return json({
|
||||
@@ -170,9 +173,10 @@ export const PUT: RequestHandler = async (event) => {
|
||||
}
|
||||
if (data.isActive !== undefined) {
|
||||
updateData.isActive = data.isActive;
|
||||
// If deactivating, invalidate all sessions
|
||||
// If deactivating, invalidate all sessions and token cache
|
||||
if (!data.isActive) {
|
||||
await deleteUserSessions(userId);
|
||||
invalidateTokenCacheForUser(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,8 +187,9 @@ export const PUT: RequestHandler = async (event) => {
|
||||
return json({ error: 'Password must be at least 8 characters' }, { status: 400 });
|
||||
}
|
||||
updateData.passwordHash = await hashPassword(data.password);
|
||||
// Invalidate all sessions on password change (except current)
|
||||
// Invalidate all sessions and token cache on password change
|
||||
await deleteUserSessions(userId);
|
||||
invalidateTokenCacheForUser(userId);
|
||||
}
|
||||
|
||||
const user = await dbUpdateUser(userId, updateData);
|
||||
@@ -198,8 +203,10 @@ export const PUT: RequestHandler = async (event) => {
|
||||
if (adminRole) {
|
||||
if (shouldPromote) {
|
||||
await assignUserRole(userId, adminRole.id, null);
|
||||
invalidateTokenCacheForUser(userId);
|
||||
} else if (shouldDemote) {
|
||||
await removeUserRole(userId, adminRole.id, null);
|
||||
invalidateTokenCacheForUser(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,6 +291,7 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
|
||||
// User confirmed - proceed with deletion and disable auth
|
||||
await deleteUserSessions(id);
|
||||
invalidateTokenCacheForUser(id);
|
||||
const deleted = await dbDeleteUser(id);
|
||||
if (!deleted) {
|
||||
return json({ error: 'Failed to delete user' }, { status: 500 });
|
||||
@@ -299,8 +307,9 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all sessions first
|
||||
// Delete all sessions and invalidate token cache
|
||||
await deleteUserSessions(id);
|
||||
invalidateTokenCacheForUser(id);
|
||||
|
||||
const deleted = await dbDeleteUser(id);
|
||||
if (!deleted) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getRole
|
||||
} from '$lib/server/db';
|
||||
import { auditUser } from '$lib/server/audit';
|
||||
import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
|
||||
|
||||
// GET /api/users/[id]/roles - Get roles assigned to a user
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
@@ -69,6 +70,7 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
|
||||
const userRole = await assignUserRole(userId, roleId, environmentId);
|
||||
invalidateTokenCacheForUser(userId);
|
||||
|
||||
// Audit log - role assigned
|
||||
const role = await getRole(roleId);
|
||||
@@ -117,6 +119,7 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
if (!deleted) {
|
||||
return json({ error: 'Role assignment not found' }, { status: 404 });
|
||||
}
|
||||
invalidateTokenCacheForUser(userId);
|
||||
|
||||
// Audit log - role removed
|
||||
if (user) {
|
||||
|
||||
@@ -1149,29 +1149,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
interface PortMapping {
|
||||
publicPort: number;
|
||||
privatePort: number;
|
||||
display: string;
|
||||
}
|
||||
|
||||
function formatPorts(ports: ContainerInfo['ports']): PortMapping[] {
|
||||
if (!ports || ports.length === 0) return [];
|
||||
const seen = new Set<string>();
|
||||
return ports
|
||||
.filter(p => p.PublicPort)
|
||||
.map(p => ({
|
||||
publicPort: p.PublicPort,
|
||||
privatePort: p.PrivatePort,
|
||||
display: `${p.PublicPort}:${p.PrivatePort}`
|
||||
}))
|
||||
.filter(p => {
|
||||
const key = p.display;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
import { formatPorts, type PortMapping } from '$lib/utils/port-format';
|
||||
|
||||
function extractHostFromUrl(urlString: string): string | null {
|
||||
if (!urlString) return null;
|
||||
@@ -1823,7 +1801,7 @@
|
||||
{@const memoryTooltip = stats.memoryCache > 0
|
||||
? `${formatBytes(stats.memoryUsage)} / ${formatBytes(stats.memoryLimit)} (Total: ${formatBytes(stats.memoryRaw)} | Cache: ${formatBytes(stats.memoryCache)})`
|
||||
: `${formatBytes(stats.memoryUsage)} / ${formatBytes(stats.memoryLimit)}`}
|
||||
<span class="text-xs font-mono {stats.memoryPercent > 80 ? 'text-red-500' : stats.memoryPercent > 50 ? 'text-yellow-500' : 'text-muted-foreground'}" title={memoryTooltip}>{formatBytes(stats.memoryUsage)}</span>
|
||||
<span class="text-xs font-mono {stats.memoryPercent > 80 ? 'text-red-500' : stats.memoryPercent > 50 ? 'text-yellow-500' : 'text-muted-foreground'}" title={memoryTooltip}>{formatBytes(stats.memoryUsage)}<span class="text-muted-foreground/50">/{formatBytes(stats.memoryLimit, 0)}</span></span>
|
||||
{:else if container.state === 'running'}
|
||||
<span class="text-xs text-muted-foreground/50">...</span>
|
||||
{:else}
|
||||
@@ -1883,7 +1861,7 @@
|
||||
{@const remainingCount = ports.length - 1}
|
||||
<div class="flex {compactPorts ? 'flex-nowrap' : 'flex-wrap'} gap-1">
|
||||
{#each displayPorts as port}
|
||||
{@const url = currentEnvDetails ? getPortUrl(port.publicPort) : null}
|
||||
{@const url = !port.isRange && currentEnvDetails ? getPortUrl(port.publicPort) : null}
|
||||
{#if url}
|
||||
<a
|
||||
href={url}
|
||||
|
||||
@@ -864,6 +864,8 @@
|
||||
retries: healthcheckRetries,
|
||||
startPeriod: healthcheckStartPeriod * 1e9
|
||||
};
|
||||
} else if (!healthcheckEnabled) {
|
||||
healthcheck = null;
|
||||
}
|
||||
|
||||
const devices = deviceMappings
|
||||
@@ -902,8 +904,8 @@
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
image: image.trim(),
|
||||
ports: Object.keys(ports).length > 0 ? ports : undefined,
|
||||
volumeBinds: volumeBinds.length > 0 ? volumeBinds : undefined,
|
||||
ports: Object.keys(ports).length > 0 ? ports : null,
|
||||
volumeBinds: volumeBinds.length > 0 ? volumeBinds : null,
|
||||
env: env.length > 0 ? env : undefined,
|
||||
labels: Object.keys(labelsObj).length > 0 ? labelsObj : undefined,
|
||||
cmd,
|
||||
@@ -1081,8 +1083,8 @@
|
||||
bind:restartPolicy
|
||||
bind:restartMaxRetries
|
||||
bind:networkMode
|
||||
startAfterCreate={startAfterUpdate}
|
||||
{repullImage}
|
||||
bind:startAfterCreate={startAfterUpdate}
|
||||
bind:repullImage
|
||||
bind:portMappings
|
||||
bind:volumeMappings
|
||||
bind:envVars
|
||||
|
||||
@@ -241,8 +241,8 @@
|
||||
{vuln.severity}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="py-1 px-2">
|
||||
<code class="text-xs">{vuln.package}</code>
|
||||
<td class="py-1 px-2 max-w-[300px]">
|
||||
<code class="text-xs block truncate" title={vuln.package}>{vuln.package}</code>
|
||||
</td>
|
||||
<td class="py-1 px-2">
|
||||
<code class="text-xs text-muted-foreground">{vuln.version}</code>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { Loader2, LogIn, Shield, AlertCircle, Network, User, KeyRound, TriangleAlert } from 'lucide-svelte';
|
||||
import { authStore } from '$lib/stores/auth';
|
||||
import { environments } from '$lib/stores/environment';
|
||||
import { appSettings } from '$lib/stores/settings';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { themeStore, applyTheme } from '$lib/stores/theme';
|
||||
|
||||
@@ -114,7 +115,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Success - refresh environments (they were cleared during pre-login fetch) then redirect
|
||||
// Success - refresh settings and environments (they were fetched before auth) then redirect
|
||||
await appSettings.refresh();
|
||||
await environments.refresh();
|
||||
goto(redirectUrl);
|
||||
} catch (e) {
|
||||
@@ -289,6 +291,7 @@
|
||||
<Label for="mfaToken">Authentication code</Label>
|
||||
<Input
|
||||
id="mfaToken"
|
||||
name="totp"
|
||||
type="text"
|
||||
placeholder="Enter code"
|
||||
bind:value={mfaToken}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
Key,
|
||||
KeyRound,
|
||||
RefreshCw,
|
||||
Check,
|
||||
Smartphone,
|
||||
@@ -22,7 +23,8 @@
|
||||
Camera,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
Palette
|
||||
Palette,
|
||||
Plus
|
||||
} from 'lucide-svelte';
|
||||
import { authStore } from '$lib/stores/auth';
|
||||
import { formatDateTime } from '$lib/stores/settings';
|
||||
@@ -32,6 +34,9 @@
|
||||
import ChangePasswordModal from './ChangePasswordModal.svelte';
|
||||
import MfaSetupModal from './MfaSetupModal.svelte';
|
||||
import DisableMfaModal from './DisableMfaModal.svelte';
|
||||
import ApiTokenModal from './ApiTokenModal.svelte';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
|
||||
import { themeStore } from '$lib/stores/theme';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
@@ -73,6 +78,49 @@
|
||||
let mfaError = $state('');
|
||||
let showDisableMfaModal = $state(false);
|
||||
|
||||
// API tokens state
|
||||
interface ApiToken {
|
||||
id: number;
|
||||
name: string;
|
||||
tokenPrefix: string;
|
||||
lastUsed: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
let apiTokens = $state<ApiToken[]>([]);
|
||||
let showApiTokenModal = $state(false);
|
||||
let tokensLoading = $state(false);
|
||||
|
||||
async function fetchApiTokens() {
|
||||
tokensLoading = true;
|
||||
try {
|
||||
const response = await fetch('/api/auth/tokens');
|
||||
if (response.ok) {
|
||||
apiTokens = await response.json();
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - tokens section will show empty
|
||||
} finally {
|
||||
tokensLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeToken(tokenId: number) {
|
||||
try {
|
||||
const response = await fetch(`/api/auth/tokens/${tokenId}`, { method: 'DELETE' });
|
||||
if (response.ok) {
|
||||
apiTokens = apiTokens.filter(t => t.id !== tokenId);
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
function isTokenExpired(expiresAt: string | null): boolean {
|
||||
if (!expiresAt) return false;
|
||||
return new Date(expiresAt) < new Date();
|
||||
}
|
||||
|
||||
// Avatar state
|
||||
let showAvatarCropper = $state(false);
|
||||
let avatarSaving = $state(false);
|
||||
@@ -291,6 +339,7 @@
|
||||
if (!profileFetched) {
|
||||
profileFetched = true;
|
||||
fetchProfile();
|
||||
fetchApiTokens();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -300,7 +349,7 @@
|
||||
<title>Profile - Dockhand</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto p-6 max-w-4xl">
|
||||
<div class="container mx-auto p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<PageHeader icon={User} title="Profile" showConnection={false}>
|
||||
<p class="text-muted-foreground text-sm">Manage your account settings</p>
|
||||
@@ -330,6 +379,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Row 1: Account info + Profile details -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
<!-- Account info card -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
@@ -431,14 +483,14 @@
|
||||
</Card.Root>
|
||||
|
||||
<!-- Profile details card -->
|
||||
<Card.Root>
|
||||
<Card.Root class="flex flex-col">
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<Mail class="w-5 h-5" />
|
||||
Profile details
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<Card.Content class="flex-1 flex flex-col space-y-4">
|
||||
{#if formError}
|
||||
<Alert.Root variant="destructive">
|
||||
<TriangleAlert class="h-4 w-4" />
|
||||
@@ -446,7 +498,7 @@
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-4 flex-1">
|
||||
<div class="space-y-2">
|
||||
<Label>Display name</Label>
|
||||
<Input
|
||||
@@ -477,8 +529,13 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Security + API tokens -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
<!-- Security card -->
|
||||
<Card.Root>
|
||||
<Card.Root class="flex flex-col">
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<Shield class="w-5 h-5" />
|
||||
@@ -582,6 +639,81 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- API tokens card -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2 justify-between">
|
||||
<span class="flex items-center gap-2">
|
||||
<KeyRound class="w-5 h-5" />
|
||||
API tokens
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onclick={() => showApiTokenModal = true}>
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
Generate token
|
||||
</Button>
|
||||
</Card.Title>
|
||||
<Card.Description>Create tokens for CI/CD pipelines and scripts</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if tokensLoading}
|
||||
<p class="text-sm text-muted-foreground">Loading tokens...</p>
|
||||
{:else if apiTokens.length === 0}
|
||||
<p class="text-sm text-muted-foreground">No API tokens created yet.</p>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head>Prefix</Table.Head>
|
||||
<Table.Head>Last used</Table.Head>
|
||||
<Table.Head>Expires</Table.Head>
|
||||
<Table.Head class="w-[80px]"></Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each apiTokens as token (token.id)}
|
||||
<Table.Row class={isTokenExpired(token.expiresAt) ? 'opacity-50' : ''}>
|
||||
<Table.Cell class="font-medium">{token.name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<code class="text-xs bg-muted px-1.5 py-0.5 rounded">dh_{token.tokenPrefix}...</code>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-sm text-muted-foreground">
|
||||
{token.lastUsed ? formatDateTime(token.lastUsed) : 'Never'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-sm">
|
||||
{#if isTokenExpired(token.expiresAt)}
|
||||
<Badge variant="destructive">Expired</Badge>
|
||||
{:else if token.expiresAt}
|
||||
{formatDateTime(token.expiresAt)}
|
||||
{:else}
|
||||
<span class="text-muted-foreground">Never</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<ConfirmPopover
|
||||
title="Revoke token"
|
||||
description="This token will stop working immediately."
|
||||
confirmText="Revoke"
|
||||
onConfirm={() => revokeToken(token.id)}
|
||||
>
|
||||
<Button variant="ghost" size="sm" class="text-destructive hover:text-destructive">
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Button>
|
||||
</ConfirmPopover>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Row 3: Appearance (left-aligned with Security) -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
<!-- Appearance card -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
@@ -595,6 +727,8 @@
|
||||
<ThemeSelector userId={profile.id} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -625,6 +759,13 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- API Token Modal -->
|
||||
<ApiTokenModal
|
||||
bind:open={showApiTokenModal}
|
||||
onCreated={fetchApiTokens}
|
||||
provider={profile?.provider ?? 'local'}
|
||||
/>
|
||||
|
||||
<!-- Avatar Cropper Modal -->
|
||||
<AvatarCropper
|
||||
show={showAvatarCropper}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { KeyRound, Copy, Check, TriangleAlert } from 'lucide-svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onCreated,
|
||||
provider = 'local'
|
||||
}: {
|
||||
open: boolean;
|
||||
onCreated: () => void;
|
||||
provider?: string;
|
||||
} = $props();
|
||||
|
||||
let name = $state('');
|
||||
let password = $state('');
|
||||
let expirationOption = $state('none');
|
||||
let customDate = $state('');
|
||||
let creating = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
const isLocalUser = $derived(provider === 'local');
|
||||
|
||||
// After creation
|
||||
let createdToken = $state('');
|
||||
let copied = $state(false);
|
||||
|
||||
function reset() {
|
||||
name = '';
|
||||
password = '';
|
||||
expirationOption = 'none';
|
||||
customDate = '';
|
||||
creating = false;
|
||||
error = '';
|
||||
createdToken = '';
|
||||
copied = false;
|
||||
}
|
||||
|
||||
function getExpiresAt(): string | null {
|
||||
const now = new Date();
|
||||
switch (expirationOption) {
|
||||
case '30d':
|
||||
return new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
case '90d':
|
||||
return new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000).toISOString();
|
||||
case '1y':
|
||||
return new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000).toISOString();
|
||||
case 'custom':
|
||||
return customDate ? new Date(customDate + 'T23:59:59').toISOString() : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function createToken() {
|
||||
if (!name.trim()) {
|
||||
error = 'Token name is required';
|
||||
return;
|
||||
}
|
||||
if (isLocalUser && !password) {
|
||||
error = 'Password is required';
|
||||
return;
|
||||
}
|
||||
|
||||
creating = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const payload: Record<string, any> = {
|
||||
name: name.trim(),
|
||||
expiresAt: getExpiresAt()
|
||||
};
|
||||
if (isLocalUser) {
|
||||
payload.password = password;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/auth/tokens', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
createdToken = data.token;
|
||||
} else {
|
||||
const data = await response.json();
|
||||
error = data.error || 'Failed to create token';
|
||||
}
|
||||
} catch {
|
||||
error = 'Failed to create token';
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdToken);
|
||||
copied = true;
|
||||
setTimeout(() => copied = false, 2000);
|
||||
} catch {
|
||||
// Fallback: select the input text
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (createdToken) {
|
||||
onCreated();
|
||||
}
|
||||
open = false;
|
||||
// Reset after animation
|
||||
setTimeout(reset, 200);
|
||||
}
|
||||
|
||||
// Get minimum date for custom picker (tomorrow)
|
||||
const minDate = $derived(() => {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
return tomorrow.toISOString().split('T')[0];
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(v) => { if (!v) handleClose(); }}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<KeyRound class="w-5 h-5" />
|
||||
{createdToken ? 'Token created' : 'Generate API token'}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if createdToken}
|
||||
<!-- Token created - show it once -->
|
||||
<div class="space-y-4">
|
||||
<Alert.Root variant="destructive">
|
||||
<TriangleAlert class="h-4 w-4" />
|
||||
<Alert.Description>
|
||||
Copy this token now. It will not be shown again.
|
||||
</Alert.Description>
|
||||
</Alert.Root>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
value={createdToken}
|
||||
readonly
|
||||
class="font-mono text-xs"
|
||||
/>
|
||||
<Button variant="outline" size="icon" onclick={copyToken}>
|
||||
{#if copied}
|
||||
<Check class="w-4 h-4 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="w-4 h-4" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button onclick={handleClose}>Done</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Token creation form -->
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="token-name">Name</Label>
|
||||
<Input
|
||||
id="token-name"
|
||||
bind:value={name}
|
||||
placeholder="e.g., CI/CD pipeline"
|
||||
maxlength={255}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if isLocalUser}
|
||||
<div class="space-y-2">
|
||||
<Label for="token-password">Password</Label>
|
||||
<Input
|
||||
id="token-password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="Confirm your password"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>Expiration</Label>
|
||||
<Select.Root type="single" bind:value={expirationOption}>
|
||||
<Select.Trigger class="w-full">
|
||||
{#if expirationOption === 'none'}No expiration
|
||||
{:else if expirationOption === '30d'}30 days
|
||||
{:else if expirationOption === '90d'}90 days
|
||||
{:else if expirationOption === '1y'}1 year
|
||||
{:else if expirationOption === 'custom'}Custom date
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="none">No expiration</Select.Item>
|
||||
<Select.Item value="30d">30 days</Select.Item>
|
||||
<Select.Item value="90d">90 days</Select.Item>
|
||||
<Select.Item value="1y">1 year</Select.Item>
|
||||
<Select.Item value="custom">Custom date</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
||||
{#if expirationOption === 'custom'}
|
||||
<Input
|
||||
type="date"
|
||||
bind:value={customDate}
|
||||
min={minDate()}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<Alert.Root variant="destructive">
|
||||
<TriangleAlert class="h-4 w-4" />
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="outline" onclick={handleClose}>Cancel</Button>
|
||||
<Button onclick={createToken} disabled={creating || !name.trim() || (isLocalUser && !password)}>
|
||||
{creating ? 'Creating...' : 'Generate token'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -188,8 +188,10 @@
|
||||
<Label>Verification code</Label>
|
||||
<Input
|
||||
bind:value={token}
|
||||
name="totp"
|
||||
placeholder="Enter 6-digit code"
|
||||
maxlength={6}
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Enter the code from your authenticator app to verify setup
|
||||
|
||||
@@ -41,13 +41,17 @@
|
||||
import { DataGrid } from '$lib/components/data-grid';
|
||||
import type { DataGridRowState } from '$lib/components/data-grid';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { formatDateTime, appSettings } from '$lib/stores/settings';
|
||||
import { formatDateTime, getTimeFormat, appSettings } from '$lib/stores/settings';
|
||||
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import ScannerSeverityPills from '$lib/components/ScannerSeverityPills.svelte';
|
||||
import VulnerabilityCriteriaBadge from '$lib/components/VulnerabilityCriteriaBadge.svelte';
|
||||
import UpdateSummaryStats from '$lib/components/UpdateSummaryStats.svelte';
|
||||
import ExecutionLogViewer from '$lib/components/ExecutionLogViewer.svelte';
|
||||
import { canAccess } from '$lib/stores/auth';
|
||||
|
||||
const canEditSchedules = $derived($canAccess('schedules', 'edit'));
|
||||
const canRunSchedules = $derived($canAccess('schedules', 'run'));
|
||||
import { vulnerabilityCriteriaIcons, vulnerabilityCriteriaLabels } from '$lib/utils/update-steps';
|
||||
import type { VulnerabilityCriteria } from '$lib/server/db';
|
||||
import cronstrue from 'cronstrue';
|
||||
@@ -183,7 +187,7 @@
|
||||
|
||||
// State
|
||||
let schedules = $state<Schedule[]>([]);
|
||||
let environments = $state<{ id: number; name: string; icon: string }[]>([]);
|
||||
let environments = $state<{ id: number; name: string; icon: string; timezone?: string }[]>([]);
|
||||
let loading = $state(true);
|
||||
let refreshing = $state(false);
|
||||
let searchQuery = $state('');
|
||||
@@ -204,6 +208,11 @@
|
||||
let selectedExecution = $state<ScheduleExecution | null>(null);
|
||||
let loadingExecutionDetail = $state(false);
|
||||
let logDarkMode = $state(true);
|
||||
let selectedExecutionTimezone = $derived(
|
||||
selectedExecution?.environmentId
|
||||
? environments.find(e => e.id === selectedExecution!.environmentId)?.timezone
|
||||
: undefined
|
||||
);
|
||||
|
||||
function toggleLogTheme() {
|
||||
logDarkMode = !logDarkMode;
|
||||
@@ -736,9 +745,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(iso: string | null): string {
|
||||
function formatTimestamp(iso: string | null, tz?: string): string {
|
||||
if (!iso) return '-';
|
||||
return formatDateTime(iso, true);
|
||||
if (!tz) return formatDateTime(iso, true);
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: tz,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: getTimeFormat() === '12h'
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | null): string {
|
||||
@@ -1287,27 +1308,31 @@
|
||||
<FileText class="w-3 h-3 text-muted-foreground hover:text-blue-500" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); toggleScheduleEnabled(schedule); }}
|
||||
title={schedule.enabled ? 'Pause schedule' : 'Resume schedule'}
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
{#if schedule.enabled}
|
||||
<Pause class="w-3 h-3 text-muted-foreground hover:text-amber-500" />
|
||||
{:else}
|
||||
<PlayCircle class="w-3 h-3 text-muted-foreground hover:text-green-500" />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); triggerSchedule(schedule); }}
|
||||
title="Run now"
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
|
||||
</button>
|
||||
{#if !schedule.isSystem}
|
||||
{#if canEditSchedules}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); toggleScheduleEnabled(schedule); }}
|
||||
title={schedule.enabled ? 'Pause schedule' : 'Resume schedule'}
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
{#if schedule.enabled}
|
||||
<Pause class="w-3 h-3 text-muted-foreground hover:text-amber-500" />
|
||||
{:else}
|
||||
<PlayCircle class="w-3 h-3 text-muted-foreground hover:text-green-500" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{#if canRunSchedules}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); triggerSchedule(schedule); }}
|
||||
title="Run now"
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if canEditSchedules && !schedule.isSystem}
|
||||
{@const scheduleKey = getScheduleKey(schedule)}
|
||||
<ConfirmPopover
|
||||
open={confirmDeleteId === scheduleKey}
|
||||
@@ -1335,7 +1360,7 @@
|
||||
<div class="p-4 pl-12 shadow-inner bg-muted isolate sticky left-0 max-w-[calc(100vw-18rem)]">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-xs font-medium">Execution history</h4>
|
||||
{#if executions.length > 0}
|
||||
{#if executions.length > 0 && canEditSchedules}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => deleteAllExecutions(schedule)}
|
||||
@@ -1412,14 +1437,16 @@
|
||||
>
|
||||
<FileText class="w-3 h-3 text-muted-foreground hover:text-blue-500" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => deleteExecution(schedule, exec.id)}
|
||||
title="Delete execution"
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Trash2 class="w-3 h-3 text-muted-foreground hover:text-red-500" />
|
||||
</button>
|
||||
{#if canEditSchedules}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => deleteExecution(schedule, exec.id)}
|
||||
title="Delete execution"
|
||||
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Trash2 class="w-3 h-3 text-muted-foreground hover:text-red-500" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1491,7 +1518,7 @@
|
||||
</Dialog.Title>
|
||||
{#if selectedExecution}
|
||||
<span class="text-xs text-muted-foreground shrink-0 pr-6 whitespace-nowrap inline-flex items-center gap-1">
|
||||
{formatTimestamp(selectedExecution.triggeredAt)} · <Timer class="w-3 h-3 -mt-px" /> {formatDuration(selectedExecution.duration)}
|
||||
{formatTimestamp(selectedExecution.triggeredAt, selectedExecutionTimezone)} · <Timer class="w-3 h-3 -mt-px" /> {formatDuration(selectedExecution.duration)}
|
||||
</span>
|
||||
{/if}
|
||||
</Dialog.Header>
|
||||
@@ -1628,6 +1655,7 @@
|
||||
<ExecutionLogViewer
|
||||
logs={selectedExecution.logs}
|
||||
darkMode={logDarkMode}
|
||||
timezone={selectedExecutionTimezone}
|
||||
onToggleTheme={toggleLogTheme}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -135,7 +135,9 @@
|
||||
{ key: 'view', label: 'View audit logs' }
|
||||
],
|
||||
schedules: [
|
||||
{ key: 'view', label: 'View schedules' }
|
||||
{ key: 'view', label: 'View schedules' },
|
||||
{ key: 'edit', label: 'Edit schedules' },
|
||||
{ key: 'run', label: 'Run schedules' }
|
||||
]
|
||||
};
|
||||
|
||||
@@ -242,6 +244,7 @@
|
||||
disconnect: Unplug,
|
||||
edit: Pencil,
|
||||
test: Play,
|
||||
run: Play,
|
||||
manage: Settings
|
||||
};
|
||||
|
||||
|
||||
@@ -94,13 +94,15 @@
|
||||
}
|
||||
|
||||
function handleScheduleCleanupEnabledChange() {
|
||||
appSettings.setScheduleCleanupEnabled(!scheduleCleanupEnabled);
|
||||
toast.success(scheduleCleanupEnabled ? 'Schedule cleanup disabled' : 'Schedule cleanup enabled');
|
||||
const newState = !scheduleCleanupEnabled;
|
||||
appSettings.setScheduleCleanupEnabled(newState);
|
||||
toast.success(newState ? 'Schedule cleanup enabled' : 'Schedule cleanup disabled');
|
||||
}
|
||||
|
||||
function handleEventCleanupEnabledChange() {
|
||||
appSettings.setEventCleanupEnabled(!eventCleanupEnabled);
|
||||
toast.success(eventCleanupEnabled ? 'Event cleanup disabled' : 'Event cleanup enabled');
|
||||
const newState = !eventCleanupEnabled;
|
||||
appSettings.setEventCleanupEnabled(newState);
|
||||
toast.success(newState ? 'Event cleanup enabled' : 'Event cleanup disabled');
|
||||
}
|
||||
|
||||
function handleGrypeImageBlur(e: Event) {
|
||||
@@ -199,9 +201,9 @@
|
||||
<Label>Show stopped containers</Label>
|
||||
<TogglePill
|
||||
checked={showStoppedContainers}
|
||||
onchange={() => {
|
||||
appSettings.setShowStoppedContainers(!showStoppedContainers);
|
||||
toast.success(showStoppedContainers ? 'Stopped containers hidden' : 'Stopped containers shown');
|
||||
onchange={(checked) => {
|
||||
appSettings.setShowStoppedContainers(checked);
|
||||
toast.success(checked ? 'Stopped containers shown' : 'Stopped containers hidden');
|
||||
}}
|
||||
disabled={!$canAccess('settings', 'edit')}
|
||||
/>
|
||||
@@ -213,9 +215,9 @@
|
||||
<Label>Highlight available updates</Label>
|
||||
<TogglePill
|
||||
checked={highlightUpdates}
|
||||
onchange={() => {
|
||||
appSettings.setHighlightUpdates(!highlightUpdates);
|
||||
toast.success(highlightUpdates ? 'Update highlighting disabled' : 'Update highlighting enabled');
|
||||
onchange={(checked) => {
|
||||
appSettings.setHighlightUpdates(checked);
|
||||
toast.success(checked ? 'Update highlighting enabled' : 'Update highlighting disabled');
|
||||
}}
|
||||
disabled={!$canAccess('settings', 'edit')}
|
||||
/>
|
||||
@@ -227,9 +229,9 @@
|
||||
<Label>Compact port display</Label>
|
||||
<TogglePill
|
||||
checked={compactPorts}
|
||||
onchange={() => {
|
||||
appSettings.setCompactPorts(!compactPorts);
|
||||
toast.success(compactPorts ? 'Showing all ports' : 'Compact port display enabled');
|
||||
onchange={(checked) => {
|
||||
appSettings.setCompactPorts(checked);
|
||||
toast.success(checked ? 'Compact port display enabled' : 'Showing all ports');
|
||||
}}
|
||||
disabled={!$canAccess('settings', 'edit')}
|
||||
/>
|
||||
@@ -337,9 +339,9 @@
|
||||
<Label>Confirm destructive actions</Label>
|
||||
<TogglePill
|
||||
checked={confirmDestructive}
|
||||
onchange={() => {
|
||||
appSettings.setConfirmDestructive(!confirmDestructive);
|
||||
toast.success(confirmDestructive ? 'Confirmations disabled' : 'Confirmations enabled');
|
||||
onchange={(checked) => {
|
||||
appSettings.setConfirmDestructive(checked);
|
||||
toast.success(checked ? 'Confirmations enabled' : 'Confirmations disabled');
|
||||
}}
|
||||
disabled={!$canAccess('settings', 'edit')}
|
||||
/>
|
||||
@@ -405,9 +407,9 @@
|
||||
<Label>Format log timestamps</Label>
|
||||
<TogglePill
|
||||
checked={formatLogTimestamps}
|
||||
onchange={() => {
|
||||
appSettings.setFormatLogTimestamps(!formatLogTimestamps);
|
||||
toast.success(formatLogTimestamps ? 'Log timestamp formatting disabled' : 'Log timestamp formatting enabled');
|
||||
onchange={(checked) => {
|
||||
appSettings.setFormatLogTimestamps(checked);
|
||||
toast.success(checked ? 'Log timestamp formatting enabled' : 'Log timestamp formatting disabled');
|
||||
}}
|
||||
disabled={!$canAccess('settings', 'edit')}
|
||||
/>
|
||||
|
||||
@@ -420,6 +420,7 @@ discord://webhook_id/webhook_token
|
||||
slack://token_a/token_b/token_c
|
||||
mmost://hostname/webhook-token
|
||||
tgram://bot_token/chat_id
|
||||
tgram://bot_token/chat_id:topic_id
|
||||
ntfy://my-topic
|
||||
pushover://user_key/api_token
|
||||
jsons://hostname/webhook/path"
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
|
||||
import { Play, Square, Trash2, Plus, ArrowBigDown, Search, Pencil, ExternalLink, GitBranch, RefreshCw, Loader2, FileCode, FileText, FileOutput, Box, RotateCcw, ScrollText, Terminal, Eye, Network, HardDrive, Heart, HeartPulse, HeartOff, ChevronsUpDown, ChevronsDownUp, Rocket, AlertTriangle, X, Layers, Pause, CircleDashed, Skull, FolderOpen, Variable, Clock, RotateCw, Import, Ship, Cable, LayoutPanelLeft, Rows3, GripVertical } from 'lucide-svelte';
|
||||
import { formatPorts } from '$lib/utils/port-format';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
|
||||
import type { ComposeStackInfo, ContainerStats } from '$lib/types';
|
||||
@@ -260,7 +262,7 @@
|
||||
if (stats) {
|
||||
cpuPercent += stats.cpuPercent;
|
||||
memoryUsage += stats.memoryUsage;
|
||||
memoryLimit += stats.memoryLimit;
|
||||
memoryLimit = Math.max(memoryLimit, stats.memoryLimit);
|
||||
networkRx += stats.networkRx;
|
||||
networkTx += stats.networkTx;
|
||||
blockRead += stats.blockRead;
|
||||
@@ -418,6 +420,7 @@
|
||||
let confirmDeleteName = $state<string | null>(null);
|
||||
let confirmStopName = $state<string | null>(null);
|
||||
let confirmDownName = $state<string | null>(null);
|
||||
let deleteVolumes = $state(false);
|
||||
|
||||
// Stack operation loading state
|
||||
let stackActionLoading = $state<string | null>(null);
|
||||
@@ -968,15 +971,18 @@
|
||||
|
||||
async function removeStack(name: string) {
|
||||
operationError = null;
|
||||
const withVolumes = deleteVolumes;
|
||||
deleteVolumes = false;
|
||||
try {
|
||||
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}?force=true`, envId), { method: 'DELETE' });
|
||||
const params = `force=true${withVolumes ? '&volumes=true' : ''}`;
|
||||
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}?${params}`, envId), { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
const errorMsg = data.error || 'Failed to remove stack';
|
||||
showErrorDialog(`Failed to remove ${name}`, errorMsg);
|
||||
return;
|
||||
}
|
||||
toast.success(`Removed ${name}`);
|
||||
toast.success(`Removed ${name}${withVolumes ? ' (volumes deleted)' : ''}`);
|
||||
await fetchStacks();
|
||||
} catch (error) {
|
||||
console.error('Failed to remove stack:', error);
|
||||
@@ -1505,7 +1511,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="font-medium text-xs hover:text-primary hover:underline cursor-pointer text-left"
|
||||
onclick={() => editStack(stack.name)}
|
||||
onclick={(e) => { e.stopPropagation(); editStack(stack.name); }}
|
||||
>
|
||||
{stack.name}
|
||||
</button>
|
||||
@@ -1648,7 +1654,7 @@
|
||||
{@const stats = getStackStats(stack)}
|
||||
<div class="text-right">
|
||||
{#if stats}
|
||||
<span class="text-xs font-mono text-muted-foreground" title="{formatBytes(stats.memoryUsage)} / {formatBytes(stats.memoryLimit)}">{formatBytes(stats.memoryUsage)}</span>
|
||||
<span class="text-xs font-mono text-muted-foreground" title="{formatBytes(stats.memoryUsage)} / {formatBytes(stats.memoryLimit)}">{formatBytes(stats.memoryUsage)}<span class="text-muted-foreground/50">/{formatBytes(stats.memoryLimit, 0)}</span></span>
|
||||
{:else if stack.status === 'running' || stack.status === 'partial' || stack.status === 'restarting'}
|
||||
<span class="text-xs text-muted-foreground/50">...</span>
|
||||
{:else}
|
||||
@@ -1753,7 +1759,7 @@
|
||||
{#if source.sourceType === 'git' && source.gitStack}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openGitModal(source.gitStack)}
|
||||
onclick={(e) => { e.stopPropagation(); openGitModal(source.gitStack); }}
|
||||
title="Edit git stack"
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
@@ -1763,7 +1769,7 @@
|
||||
<!-- Internal stacks (including those needing file location) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => editStack(stack.name)}
|
||||
onclick={(e) => { e.stopPropagation(); editStack(stack.name); }}
|
||||
title="Edit"
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
@@ -1774,7 +1780,7 @@
|
||||
{#if stack.containers && stack.containers.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => viewStackLogs(stack)}
|
||||
onclick={(e) => { e.stopPropagation(); viewStackLogs(stack); }}
|
||||
title="View logs"
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
@@ -1852,7 +1858,7 @@
|
||||
{#if $canAccess('stacks', 'start')}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startStack(stack.name)}
|
||||
onclick={(e) => { e.stopPropagation(); startStack(stack.name); }}
|
||||
title="Start"
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
@@ -1884,8 +1890,14 @@
|
||||
itemName={stack.name}
|
||||
title="Remove"
|
||||
onConfirm={() => removeStack(stack.name)}
|
||||
onOpenChange={(open) => confirmDeleteName = open ? stack.name : null}
|
||||
onOpenChange={(open) => { confirmDeleteName = open ? stack.name : null; if (!open) deleteVolumes = false; }}
|
||||
>
|
||||
{#snippet extraContent()}
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<Checkbox bind:checked={deleteVolumes} />
|
||||
<span class="text-xs text-muted-foreground">Also delete volumes</span>
|
||||
</label>
|
||||
{/snippet}
|
||||
{#snippet children({ open })}
|
||||
<Trash2 class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
|
||||
{/snippet}
|
||||
@@ -2003,11 +2015,11 @@
|
||||
{/key}
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-1.5 mb-2 text-2xs">
|
||||
<!-- Clickable ports (dedupe by publicPort for IPv4/IPv6) -->
|
||||
<!-- Clickable ports with range collapsing -->
|
||||
{#if container.ports.length > 0}
|
||||
{@const uniquePorts = container.ports.filter((p, i, arr) => p.publicPort && arr.findIndex(x => x.publicPort === p.publicPort) === i)}
|
||||
{#each uniquePorts as port}
|
||||
{@const url = getPortUrl(port.publicPort)}
|
||||
{@const mappedPorts = formatPorts(container.ports)}
|
||||
{#each mappedPorts as port}
|
||||
{@const url = !port.isRange ? getPortUrl(port.publicPort) : null}
|
||||
{#if url}
|
||||
<a
|
||||
href={url}
|
||||
@@ -2017,12 +2029,12 @@
|
||||
class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors"
|
||||
title="Open {url} in new tab"
|
||||
>
|
||||
<code>:{port.publicPort}</code>
|
||||
<code>{port.display}</code>
|
||||
<ExternalLink class="w-2.5 h-2.5" />
|
||||
</a>
|
||||
{:else}
|
||||
<span class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
<code>:{port.publicPort}</code>
|
||||
<code>{port.display}</code>
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download, Hammer, ArrowDownToLine, Zap } from 'lucide-svelte';
|
||||
@@ -748,6 +749,19 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if gitStack && selectedRepo}
|
||||
<div class="space-y-2">
|
||||
<Label>Repository</Label>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 rounded-md px-3 py-2">
|
||||
<FolderGit2 class="w-3.5 h-3.5 shrink-0" />
|
||||
<span class="truncate" title={selectedRepo.url}>{selectedRepo.url}</span>
|
||||
{#if selectedRepo.branch}
|
||||
<Badge variant="outline" class="text-2xs py-0 px-1.5 shrink-0">{selectedRepo.branch}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="compose-path">Compose file path</Label>
|
||||
<Input id="compose-path" bind:value={formComposePath} placeholder="compose.yaml" />
|
||||
|
||||
@@ -867,6 +867,9 @@ services:
|
||||
if (!newStackName.trim()) {
|
||||
errors.stackName = 'Stack name is required';
|
||||
hasErrors = true;
|
||||
} else if (!/^[a-z0-9][a-z0-9_-]*$/.test(newStackName.trim())) {
|
||||
errors.stackName = 'Must be lowercase, start with a letter or number, and only contain letters, numbers, hyphens, and underscores';
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
const content = composeContent || defaultCompose;
|
||||
@@ -903,6 +906,7 @@ services:
|
||||
// Prepare env vars for creating - syncs variables and rawContent
|
||||
const prepared = envVarsPanelRef?.prepareForSave() || { rawContent: '', variables: [] };
|
||||
|
||||
let response: Response | undefined;
|
||||
try {
|
||||
// Build request body
|
||||
const requestBody: Record<string, unknown> = {
|
||||
@@ -930,7 +934,7 @@ services:
|
||||
}
|
||||
|
||||
// Create the stack
|
||||
const response = await fetch(appendEnvParam('/api/stacks', envId), {
|
||||
response = await fetch(appendEnvParam('/api/stacks', envId), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody)
|
||||
@@ -955,9 +959,10 @@ services:
|
||||
message: e.message || 'An error occurred while creating the stack',
|
||||
details: e.details
|
||||
};
|
||||
// If start=true, files were saved and stack is in DB — transition to edit mode
|
||||
// so the user can fix and redeploy without leaving the modal
|
||||
if (start) {
|
||||
// Only transition to edit mode if the stack was actually persisted (response was ok
|
||||
// but deploy failed). A 400 from validation means nothing was saved — stay in create
|
||||
// mode so the name field remains visible and the user can fix the error.
|
||||
if (start && response?.ok) {
|
||||
mode = 'edit';
|
||||
stackName = newStackName.trim();
|
||||
onSuccess(); // refresh stack list so the new stack appears
|
||||
|
||||
Reference in New Issue
Block a user