Compare commits

..

128 Commits

Author SHA1 Message Date
jarek 22e0429094 V1.0.26 2026-04-19 13:06:27 +02:00
jarek 1a34f73ae3 port ranges also in stacks display 2026-04-19 10:28:47 +02:00
jarek aaaf252d4c Bearer token authentication fails with enterprise license active 2026-04-19 10:12:59 +02:00
jarek 1bf5dec60f persist sort order across page navigation for all data grids (#861, #912) 2026-04-19 09:48:59 +02:00
jarek d7a458f158 show git repository URL and branch in git stack edit modal (#856) 2026-04-19 09:40:33 +02:00
jarek a7990e2167 persist sort order across page navigation for all data grids (#861, #912) 2026-04-19 09:40:20 +02:00
jarek 8bb95d0a1b MFA code field not recognized by Bitwarden and other password managers (#566) 2026-04-19 09:40:01 +02:00
jarek b8f06426e3 option to delete associated volumes when removing a stack (#655) 2026-04-19 09:39:42 +02:00
jarek 0af1ee6eb2 Gotify, ntfy, Pushover, and webhook notifications missing environment name (#943) 2026-04-19 09:39:03 +02:00
jarek ac19a67cce Bearer token authentication fails with enterprise license active 2026-04-19 09:35:32 +02:00
jarek 380fcc34ec scheduled image prune notifications missing environment name (#770) 2026-04-19 09:31:38 +02:00
jarek 32e44c746b MFA code field not recognized by Bitwarden and other password managers (#566) 2026-04-19 09:31:20 +02:00
jarek 84e0a0bf14 collapse consecutive port mappings into ranges in container list (#821) 2026-04-19 09:30:42 +02:00
jarek c44f244b1d v1.0.25 2026-04-18 08:40:17 +02:00
jarek afee09866d v1.0.25 2026-04-18 08:34:20 +02:00
yokkkoso c210ef0a8e feat: support telegram topics in supergroups 2026-04-08 06:12:03 +02:00
jarek 7fe4b25563 Dockerfile for baseline builds 2026-04-05 06:39:53 +02:00
jarek 7f26c0a585 v1.0.24 2026-04-03 18:32:46 +02:00
jarek 2027c9d44c v1.0.23 2026-04-03 13:53:12 +02:00
jarek 0bb10cabb9 v1.0.23 2026-04-03 11:51:42 +02:00
jarek e9a9f0ca25 shims for the baseline build 2026-03-26 08:23:12 +01:00
jarek 17dafec9de v1.0.22 2026-03-21 14:46:17 +01:00
jarek b55e1e5aad v1.0.22 2026-03-21 14:29:30 +01:00
Jarek Krochmalski aefa5e7925 Update SECURITY.md 2026-03-15 09:37:22 +01:00
Jarek Krochmalski 63c576e059 Create SECURITY.md 2026-03-15 06:13:45 +01:00
Dennis Braun a6016afdaa fix: propagate DOCKER_API_VERSION to updater sidecar
The dockhand-updater image ships Docker CLI 29.2.1 (API 1.53), which
fails on hosts running older Docker daemons (e.g. Synology DSM with
Docker 24.0.2 / API 1.43). Every docker command in update.sh returns
"client version 1.53 is too new".

Query the daemon's API version via /version and pass it as
DOCKER_API_VERSION to the updater container env. If the env var is
already set on the main container, forward that instead.

Fixes #759
2026-03-13 18:38:02 +01:00
jarek 0b3658793a v1.0.21 2026-03-13 09:29:02 +01:00
jarek 05d771d9ba v1.0.21 2026-03-13 08:31:38 +01:00
jarek 55f3101a19 v1.0.21 2026-03-13 08:22:46 +01:00
Jarek Krochmalski 790ce092ee 1.0.20 2026-03-06 20:03:10 +01:00
Jarek Krochmalski 7729d7e326 1.0.20 2026-03-03 13:02:00 +01:00
Jarek Krochmalski bcd10c1407 1.0.20 2026-03-03 12:18:17 +01:00
Jarek Krochmalski a04040e1e9 1.0.20 2026-03-03 10:29:01 +01:00
Jarek Krochmalski c26fa2d10f 1.0.20 2026-03-03 10:17:41 +01:00
Matt Boris d51bfb0d60 fix: cap docker API version (fixes #679) 2026-03-03 07:12:56 +01:00
jarek 5527d19198 v1.0.20 2026-03-02 13:10:03 +01:00
jarek 2829e7c0e9 v1.0.20 2026-03-02 10:54:30 +01:00
jarek 1066ce9eb1 v1.0.20 2026-03-02 10:41:42 +01:00
jarek bc00bbfe5c v1.0.19 2026-03-02 09:12:33 +01:00
jarek 9c451aedf9 v1.0.19 2026-03-02 07:59:58 +01:00
jarek 4b430340db 1.0.18 2026-02-16 16:19:55 +01:00
jarek 0372737f3d 1.0.18 2026-02-16 15:43:05 +01:00
jarek 33bdc39b49 1.0.18 2026-02-16 13:37:19 +01:00
jarek 1baedd134d 1.0.18 2026-02-16 13:17:09 +01:00
jarek ae3aea2296 1.0.18 2026-02-16 12:59:42 +01:00
jarek 3a7b856047 1.0.18 2026-02-16 12:46:28 +01:00
jarek ae42baa67c 1.0.18 2026-02-16 09:08:12 +01:00
jarek 83a5a557b0 1.0.18 2026-02-16 09:05:51 +01:00
jarek c43bdbcee6 1.0.18 2026-02-16 08:53:25 +01:00
jarek f8dcb84c41 1.0.18 2026-02-16 08:47:29 +01:00
jarek 8ee4fe4d68 1.0.18 2026-02-16 08:46:56 +01:00
jarek d83ca684d7 cleaner logos 2026-02-16 08:16:51 +01:00
jarek e5becfd87f 1.0.18 updater 2026-02-16 08:16:37 +01:00
Aaron Bird d12196f53a feat: add Mattermost notification support
Add mmost:// and mmosts:// (secure) Apprise URL support for Mattermost
incoming webhooks. Supports optional botname override and custom paths.

- Add sendMattermost() function following existing notification patterns
- Update NotificationModal with Mattermost in examples and description
2026-02-13 09:35:39 +01:00
Florian Hoss ef26d38fce Add Bearer token auth support to sendNtfy
Enhanced the sendNtfy function to support Bearer token authentication in addition to Basic auth. Now, URLs in the format token@host/topic will use Bearer tokens, improving flexibility for different notification server setups.
2026-02-13 09:28:17 +01:00
jarek 133c9f1e8f 1.0.17 2026-02-09 20:50:41 +01:00
jarek cb8be12f1a 1.0.16 2026-02-09 14:48:48 +01:00
jarek 48b9bde8ae 1.0.16 2026-02-09 10:15:21 +01:00
jarek 1cb47eaa9c 1.0.15 2026-02-08 10:27:56 +01:00
jarek 265bbc65df 1.0.15 2026-02-08 10:21:18 +01:00
jarek 188ba1967d 1.0.15 2026-02-08 09:59:06 +01:00
jarek 9d2266dffe release job 2026-02-07 09:59:30 +01:00
Jarek Krochmalski 4ab6abf924 Delete static/logo_dark.webp 2026-02-06 17:06:27 +01:00
Jarek Krochmalski af9cb55729 Delete static/logo_light.webp 2026-02-06 17:06:10 +01:00
Jarek Krochmalski d7a553cd8d Delete static/logo.png 2026-02-06 17:05:46 +01:00
TimElschner e5fec4df71 Add missing static assets (favicons, logos, webmanifest)
The static/ directory containing favicons, apple-touch-icons, logos,
robots.txt and site.webmanifest was not included in the repository,
even though app.html references these files. This causes missing
icons and broken manifest when building from source.
2026-02-06 16:54:29 +01:00
Matt Boris 071571eca9 chore(gitignore): add local dev auth files 2026-02-06 16:48:29 +01:00
Matt Boris 1229ecc1d9 chore(login): autofocus on the username field 2026-02-06 16:48:29 +01:00
Matt Boris 03992ae227 chore(mfa): autofocus on the mfa code field on login 2026-02-06 16:48:29 +01:00
Jarek Krochmalski 48e9a3f5ec Update bug-report.yml 2026-02-06 08:13:26 +01:00
Jarek Krochmalski d7eaa5ef70 Update bug-report.yml 2026-02-06 08:10:08 +01:00
Jarek Krochmalski 5b1b7ecb71 Update bug-report.yml 2026-02-06 08:09:38 +01:00
Jarek Krochmalski de1cad422e Update bug-report.yml 2026-02-06 08:08:35 +01:00
Jarek Krochmalski 86448e5b20 Update bug-report.yml 2026-02-06 08:07:32 +01:00
shamoon 8be07ea8dc Add bug report, FR templates, config 2026-02-06 08:04:44 +01:00
shamoon ffde535390 Add basic PR template 2026-02-06 08:04:44 +01:00
shamoon cf0e9ab50d Add basic CONTRIBUTING.md 2026-02-06 08:04:44 +01:00
shamoon 95f263c3a6 Ignore node_modules, .svelte-kit, and bun.lock 2026-02-06 08:04:44 +01:00
shamoon 83063d757a Only show on update 2026-02-03 09:25:01 +01:00
shamoon 6b49d13236 Add option to pull image before container update 2026-02-03 09:25:01 +01:00
jarek 610548ed66 1.0.14 2026-01-31 09:35:19 +01:00
jarek 8f3a7eb435 1.0.13 2026-01-28 07:34:12 +01:00
Jarek Krochmalski a88d3d5788 Update README.md 2026-01-24 06:13:41 +01:00
Jarek Krochmalski ac84b20bb0 Update README.md 2026-01-24 06:03:27 +01:00
jarek 0b62c5e3bd #96 2026-01-23 14:39:16 +01:00
Viktoras 241b04247e Honor DATA_DIR env var in sqlite operations related to hawser connections 2026-01-23 14:29:24 +01:00
jarek bbdb9841fd 1.0.12 2026-01-22 16:46:42 +01:00
jarek 1d1e85f1fa 1.0.12 2026-01-22 16:46:17 +01:00
jarek 86a06d9de0 1.0.12 2026-01-22 16:23:26 +01:00
jarek a3cc26d958 1.0.11 2026-01-20 15:39:08 +01:00
FlintyLemming fe48d63164 feat: add SYS_RAWIO to container capabilities list 2026-01-19 19:01:51 +01:00
Jarek Krochmalski 21aa4a9854 Create ai-opt-out 2026-01-19 13:00:00 +01:00
Jarek Krochmalski 27baab1a86 Update README.md 2026-01-19 12:50:10 +01:00
Jarek Krochmalski 33a7add751 Update README.md 2026-01-19 12:48:48 +01:00
Jarek Krochmalski 7abda79214 Update README.md 2026-01-19 12:44:05 +01:00
Jarek Krochmalski 9905b17f3d Update package.json 2026-01-19 08:16:41 +01:00
jarek 6483cea6c6 1.0.10 2026-01-18 09:56:38 +01:00
jarek c185d00dc3 1.0.9 2026-01-17 15:06:14 +01:00
jarek 62636426bf 1.0.8 2026-01-14 08:18:20 +01:00
sieren 027aee434c Mobile: Only show total of stacks
The detailed display of stacks (following x/x/x/x) is too wide
for mobile display.
So for mobile display only, we limit this information to the total number
of stacks.
2026-01-12 14:25:53 +01:00
sieren f2657a3d4d Improve Environment Layout on Mobile
Do not use the grid layout on mobile but show each tile
in a scrollable list instead.
2026-01-12 14:25:53 +01:00
Jarek Krochmalski 851e56bc57 1.0.7 2026-01-11 09:01:42 +01:00
Jarek Krochmalski c15355e159 1.0.7 2026-01-11 07:17:25 +01:00
Jarek Krochmalski 7643807717 1.0.7 2026-01-11 07:16:18 +01:00
jarek bd7b832394 1.0.6 2026-01-03 14:56:20 +01:00
jarek 66e723052d missing scripts 2026-01-03 13:21:38 +01:00
jarek 80c000c601 1.0.5 2026-01-03 09:10:38 +01:00
jarek f2102003e3 1.0.5 2026-01-02 15:39:51 +01:00
jarek a1e07b1a10 1.0.5 2026-01-02 15:29:56 +01:00
Jarek Krochmalski b89470e965 Update README.md 2026-01-02 13:38:16 +01:00
Jarek Krochmalski 942c8d440b Update README.md 2026-01-02 13:36:32 +01:00
jarek 607d340b71 1.0.5 2026-01-02 12:24:43 +01:00
jarek 659d074d00 1.0.5 2026-01-01 16:32:08 +01:00
jarek 07a5f03aa9 1.0.5 2026-01-01 16:05:10 +01:00
jarek 242f8df49d 1.0.5 2026-01-01 16:00:34 +01:00
jarek 5475112806 compose example 2025-12-29 15:29:33 +01:00
Jarek Krochmalski db9981f2b0 bmac 2025-12-29 13:46:13 +01:00
Jarek Krochmalski c7b9ae7243 bmac 2025-12-29 13:39:36 +01:00
Jarek Krochmalski a0bc234c8a Update README.md 2025-12-29 13:37:26 +01:00
Jarek Krochmalski 0ef9982aff Update LICENSE.txt 2025-12-29 09:27:27 +01:00
jarek 5194b3a993 ignore 2025-12-29 09:08:29 +01:00
jarek 62ab0a3065 drizzle config 2025-12-29 09:08:06 +01:00
Jarek Krochmalski 9c85535a9b cleanup .DS_Store 2025-12-29 08:55:55 +01:00
jarek 9bf4b74e2e proper src structure, dockerfile, entrypoint 2025-12-29 08:40:56 +01:00
jarek 73c9f580a1 proper src structure, dockerfile, entrypoint 2025-12-29 08:40:11 +01:00
Jarek Krochmalski e5828c7d31 Update README.md 2025-12-29 06:47:46 +01:00
Jarek Krochmalski 8afdea8795 Update README.md 2025-12-29 06:47:22 +01:00
Jarek Krochmalski ba8d6ce068 Update README.md 2025-12-28 21:40:06 +01:00
66 changed files with 8025 additions and 221 deletions
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
v1.0.23
v1.0.26
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/Finsys/dockhand/collector
go 1.25
go 1.25.9
+21
View File
@@ -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");
+2 -2
View File
@@ -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
+7
View File
@@ -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
}
]
}
+16
View File
@@ -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
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+13 -6
View File
@@ -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>
+12 -2
View File
@@ -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>
+30 -8
View File
@@ -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
+47
View File
@@ -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",
+274
View File
@@ -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;
}
+5 -3
View File
@@ -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
};
+7
View File
@@ -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' };
}
+11 -7
View File
@@ -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
View File
@@ -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
+8 -4
View File
@@ -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';
+22
View File
@@ -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;
+19
View File
@@ -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
View File
@@ -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
+15 -7
View File
@@ -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()
})
});
+23
View File
@@ -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);
+7 -4
View File
@@ -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
+109
View File
@@ -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();
}
+4 -1
View File
@@ -349,7 +349,10 @@ function createSettingsStore() {
});
},
// Manual refresh from database
refresh: loadSettings
refresh: () => {
initialized = false;
return loadSettings();
}
};
}
+64
View File
@@ -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;
}
+164
View File
@@ -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');
+19 -1
View File
@@ -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 {
+2
View File
@@ -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
+6
View File
@@ -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);
+4 -2
View File
@@ -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);
+7
View File
@@ -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)) {
+4 -1
View File
@@ -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)) {
+3 -2
View File
@@ -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 });
+2
View File
@@ -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);
}
}
+12 -3
View File
@@ -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) {
+3 -25
View File
@@ -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
+2 -2
View File
@@ -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>
+4 -1
View File
@@ -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}
+147 -6
View File
@@ -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}
+237
View File
@@ -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>
+2
View File
@@ -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
+63 -35
View File
@@ -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
};
+21 -19
View File
@@ -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"
+28 -16
View File
@@ -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}
+14
View File
@@ -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" />
+9 -4
View File
@@ -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