This commit is contained in:
michael committed 2026-09-16 05:30:23 +00:00
1 parent 312dd3b3ad
commit 4f4fb0baf2
11 files changed
+158 -15

No files matched your search

+1
View File
@@ -2,6 +2,7 @@ TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
APP_PORT=3910 APP_PORT=3910
DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker
SECRET_ENCRYPTION_KEY=change-me-secret-encryption-key
DB_POOL_SIZE=10 DB_POOL_SIZE=10
COOKIE_SECURE=false COOKIE_SECURE=false
+1
View File
@@ -4,6 +4,7 @@ APP_PORT=3910
POSTGRES_DB=tickettracker POSTGRES_DB=tickettracker
POSTGRES_USER=tickettracker POSTGRES_USER=tickettracker
POSTGRES_PASSWORD=change-me-db-password POSTGRES_PASSWORD=change-me-db-password
SECRET_ENCRYPTION_KEY=change-me-secret-encryption-key
DB_POOL_SIZE=10 DB_POOL_SIZE=10
COOKIE_SECURE=false COOKIE_SECURE=false
+1
View File
@@ -1,4 +1,5 @@
DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker
SECRET_ENCRYPTION_KEY=change-me-secret-encryption-key
APP_PORT=3910 APP_PORT=3910
TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
COOKIE_SECURE=false COOKIE_SECURE=false
+16 -4
View File
@@ -8,6 +8,7 @@ import type pg from "pg";
import { migrate } from "./migrations.js"; import { migrate } from "./migrations.js";
import { pool, query, withTransaction } from "./db.js"; import { pool, query, withTransaction } from "./db.js";
import { clearAuthSession, createAuthSession, currentUser, hashPassword, requireAdmin, requireAuth, requireUser, verifyPassword } from "./auth.js"; import { clearAuthSession, createAuthSession, currentUser, hashPassword, requireAdmin, requireAuth, requireUser, verifyPassword } from "./auth.js";
import { decryptSecret, encryptSecret } from "./secrets.js";
import { import {
databaseExportTables as backupTables, databaseExportTables as backupTables,
exportDatabaseSql, exportDatabaseSql,
@@ -399,10 +400,13 @@ type ZammadSyncResult = {
async function getAppSetting(key: string) { async function getAppSetting(key: string) {
const result = await query<{ value: string }>("SELECT value FROM app_settings WHERE key = $1;", [key]); const result = await query<{ value: string }>("SELECT value FROM app_settings WHERE key = $1;", [key]);
return result.rows[0]?.value ?? null; const value = result.rows[0]?.value ?? null;
return key === zammadApiKeySettingKey ? decryptSecret(value) : value;
} }
async function setAppSetting(key: string, value: string) { async function setAppSetting(key: string, value: string) {
const storedValue = key === zammadApiKeySettingKey ? encryptSecret(value) : value;
await query( await query(
` `
INSERT INTO app_settings (key, value, updated_at) INSERT INTO app_settings (key, value, updated_at)
@@ -410,7 +414,7 @@ async function setAppSetting(key: string, value: string) {
ON CONFLICT (key) ON CONFLICT (key)
DO UPDATE SET value = EXCLUDED.value, updated_at = now(); DO UPDATE SET value = EXCLUDED.value, updated_at = now();
`, `,
[key, value] [key, storedValue]
); );
} }
@@ -520,7 +524,7 @@ async function getZammadCredentialsForUser(userId: string) {
throw badRequest("Zammad URL muss zuerst im Adminbereich gespeichert werden"); throw badRequest("Zammad URL muss zuerst im Adminbereich gespeichert werden");
} }
const apiToken = userResult.rows[0]?.zammad_api_token ?? null; const apiToken = decryptSecret(userResult.rows[0]?.zammad_api_token);
if (!apiToken) { if (!apiToken) {
throw badRequest("Zammad-Token muss zuerst im Profil hinterlegt werden"); throw badRequest("Zammad-Token muss zuerst im Profil hinterlegt werden");
@@ -4074,7 +4078,15 @@ app.patch("/api/auth/me", async (req, res) => {
WHERE id = $4 WHERE id = $4
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token; RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token;
`, `,
[username, displayName, newPassword ? await hashPassword(newPassword) : null, user.id, updatesZammadToken, zammadApiToken, zammadUserId] [
username,
displayName,
newPassword ? await hashPassword(newPassword) : null,
user.id,
updatesZammadToken,
zammadApiToken ? encryptSecret(zammadApiToken) : null,
zammadUserId
]
); );
if (result.rowCount === 0) { if (result.rowCount === 0) {
+26
View File
@@ -1,4 +1,7 @@
import { query } from "./db.js"; import { query } from "./db.js";
import { encryptSecret, isEncryptedSecret } from "./secrets.js";
const zammadApiKeySettingKey = "zammad.api_key";
async function findMigrationAdmin() { async function findMigrationAdmin() {
const adminResult = await query<{ id: string }>("SELECT id FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1;"); const adminResult = await query<{ id: string }>("SELECT id FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1;");
@@ -39,6 +42,27 @@ async function ensureClosureOwnership(table: string, column: string, primaryKeyC
await query(`CREATE INDEX IF NOT EXISTS idx_${table}_${column}_user ON ${table}(${column}, user_id);`); await query(`CREATE INDEX IF NOT EXISTS idx_${table}_${column}_user ON ${table}(${column}, user_id);`);
} }
async function encryptStoredApiSecrets() {
const userTokenResult = await query<{ id: string; zammad_api_token: string | null }>(`
SELECT id::text AS id, zammad_api_token
FROM users
WHERE zammad_api_token IS NOT NULL;
`);
for (const row of userTokenResult.rows) {
if (row.zammad_api_token && !isEncryptedSecret(row.zammad_api_token)) {
await query("UPDATE users SET zammad_api_token = $1, updated_at = now() WHERE id = $2;", [encryptSecret(row.zammad_api_token), row.id]);
}
}
const zammadApiKeyResult = await query<{ value: string }>("SELECT value FROM app_settings WHERE key = $1;", [zammadApiKeySettingKey]);
const zammadApiKey = zammadApiKeyResult.rows[0]?.value ?? null;
if (zammadApiKey && !isEncryptedSecret(zammadApiKey)) {
await query("UPDATE app_settings SET value = $1, updated_at = now() WHERE key = $2;", [encryptSecret(zammadApiKey), zammadApiKeySettingKey]);
}
}
export async function migrate() { export async function migrate() {
await query(` await query(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
@@ -91,6 +115,8 @@ export async function migrate() {
); );
`); `);
await encryptStoredApiSecrets();
await query(` await query(`
CREATE TABLE IF NOT EXISTS user_preferences ( CREATE TABLE IF NOT EXISTS user_preferences (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+72
View File
@@ -0,0 +1,72 @@
import crypto from "node:crypto";
const encryptedSecretPrefix = "ttenc:v1:";
let warnedAboutFallbackKey = false;
let cachedKey: Buffer | null = null;
function getEncryptionMaterial() {
const configuredKey = process.env.SECRET_ENCRYPTION_KEY ?? process.env.TICKETTRACKER_SECRET_KEY;
if (configuredKey) {
return configuredKey;
}
if (!process.env.DATABASE_URL) {
throw new Error("SECRET_ENCRYPTION_KEY is required to encrypt database secrets");
}
if (!warnedAboutFallbackKey) {
console.warn("SECRET_ENCRYPTION_KEY is not set; deriving database secret encryption from DATABASE_URL.");
warnedAboutFallbackKey = true;
}
return process.env.DATABASE_URL;
}
function getEncryptionKey() {
if (!cachedKey) {
cachedKey = crypto.createHash("sha256").update(getEncryptionMaterial()).digest();
}
return cachedKey;
}
export function isEncryptedSecret(value: string | null | undefined) {
return typeof value === "string" && value.startsWith(encryptedSecretPrefix);
}
export function encryptSecret(value: string) {
if (isEncryptedSecret(value)) {
return value;
}
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv("aes-256-gcm", getEncryptionKey(), iv);
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return `${encryptedSecretPrefix}${iv.toString("base64url")}.${tag.toString("base64url")}.${encrypted.toString("base64url")}`;
}
export function decryptSecret(value: string | null | undefined) {
if (!value) {
return null;
}
if (!isEncryptedSecret(value)) {
return value;
}
const parts = value.slice(encryptedSecretPrefix.length).split(".");
if (parts.length !== 3) {
throw new Error("Encrypted database secret has an invalid format");
}
const [ivText, tagText, encryptedText] = parts;
const decipher = crypto.createDecipheriv("aes-256-gcm", getEncryptionKey(), Buffer.from(ivText, "base64url"));
decipher.setAuthTag(Buffer.from(tagText, "base64url"));
return Buffer.concat([decipher.update(Buffer.from(encryptedText, "base64url")), decipher.final()]).toString("utf8");
}
+26
View File
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import { decryptSecret, encryptSecret, isEncryptedSecret } from "../src/secrets.js";
test("database secrets are encrypted and can be decrypted", () => {
process.env.SECRET_ENCRYPTION_KEY = "test-secret-key";
const encrypted = encryptSecret("zammad-token-with-specials-!$%&/()=?");
assert.equal(isEncryptedSecret(encrypted), true);
assert.notEqual(encrypted, "zammad-token-with-specials-!$%&/()=?");
assert.equal(decryptSecret(encrypted), "zammad-token-with-specials-!$%&/()=?");
});
test("plain legacy secrets are read as-is until migration encrypts them", () => {
assert.equal(decryptSecret("legacy-token"), "legacy-token");
assert.equal(decryptSecret(null), null);
});
test("already encrypted secrets are not encrypted twice", () => {
process.env.SECRET_ENCRYPTION_KEY = "test-secret-key";
const encrypted = encryptSecret("zammad-token");
assert.equal(encryptSecret(encrypted), encrypted);
});
+1
View File
@@ -26,6 +26,7 @@ services:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-tickettracker}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-tickettracker} DATABASE_URL: postgresql://${POSTGRES_USER:-tickettracker}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-tickettracker}
SECRET_ENCRYPTION_KEY: ${SECRET_ENCRYPTION_KEY:-}
PORT: 3000 PORT: 3000
BACKEND_PORT: 3001 BACKEND_PORT: 3001
API_PROXY_TARGET: http://127.0.0.1:3001 API_PROXY_TARGET: http://127.0.0.1:3001
+1
View File
@@ -7,6 +7,7 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL}
SECRET_ENCRYPTION_KEY: ${SECRET_ENCRYPTION_KEY:-}
PORT: 3000 PORT: 3000
BACKEND_PORT: 3001 BACKEND_PORT: 3001
API_PROXY_TARGET: http://127.0.0.1:3001 API_PROXY_TARGET: http://127.0.0.1:3001
+1 -1
View File
@@ -1245,7 +1245,7 @@ export function App() {
<SidebarMenuButton asChild> <SidebarMenuButton asChild>
<a href={isAdmin ? "/admin/users" : "/timer"} onClick={navHandler(isAdmin ? "/admin/users" : "/timer")}> <a href={isAdmin ? "/admin/users" : "/timer"} onClick={navHandler(isAdmin ? "/admin/users" : "/timer")}>
<Command /> <Command />
<span className="font-semibold text-base">TicketTracker</span> <span className="font-semibold text-base">TicketTracker </span><span className="font-medium text-muted-foreground">v1.0.0-rc1</span>
</a> </a>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
+12 -10
View File
@@ -92,18 +92,18 @@ function groupOpenSessions(sessions: StatisticsOpenSession[]) {
function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup; month: string; onNavigate: (to: string) => void }) { function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup; month: string; onNavigate: (to: string) => void }) {
return ( return (
<div className="flex min-h-36 flex-col justify-between gap-3 rounded-md border bg-background p-3"> <div className="rounded-md border bg-background px-3 py-2">
<div className="min-w-0"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{group.ticketNumber}</span> <span className="font-medium">{group.ticketNumber}</span>
<CopyTicketButton ticketNumber={group.ticketNumber} /> <CopyTicketButton ticketNumber={group.ticketNumber} />
<Badge variant="warning">{group.openCount} offen</Badge> <Badge variant="warning">{group.openCount} offen</Badge>
</div> </div>
<p className="truncate text-sm text-muted-foreground">{group.organizationName}</p> <p className="truncate text-sm text-muted-foreground">{group.organizationName}</p>
<p className={`text-sm font-medium ${trackedTextClass}`}>{formatMinutes(group.totalMinutes)} offen zu bewerten</p> <p className={`text-xs font-medium ${trackedTextClass}`}>{formatMinutes(group.totalMinutes)} offen zu bewerten</p>
</div> </div>
<div className="flex justify-end"> <Button className="h-8 shrink-0" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${month}`)}>
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${month}`)}>
Ticket öffnen Ticket öffnen
<ExternalLink className="size-4" /> <ExternalLink className="size-4" />
</Button> </Button>
@@ -126,8 +126,9 @@ function CrmDayItem({
const isAcknowledged = Boolean(item.acknowledged); const isAcknowledged = Boolean(item.acknowledged);
return ( return (
<div className={`flex min-h-36 flex-col justify-between gap-3 rounded-md border bg-background p-3 ${isAcknowledged ? "opacity-75" : ""}`}> <div className={`rounded-md border bg-background px-3 py-2 ${isAcknowledged ? "opacity-75" : ""}`}>
<div className="min-w-0"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{item.ticket_number}</span> <span className="font-medium">{item.ticket_number}</span>
<CopyTicketButton ticketNumber={item.ticket_number} /> <CopyTicketButton ticketNumber={item.ticket_number} />
@@ -141,16 +142,17 @@ function CrmDayItem({
{typeof item.delta_minutes === "number" ? <span className="text-muted-foreground">Differenz {formatTeamspaceDelta(item.delta_minutes)}</span> : null} {typeof item.delta_minutes === "number" ? <span className="text-muted-foreground">Differenz {formatTeamspaceDelta(item.delta_minutes)}</span> : null}
</p> </p>
</div> </div>
<div className="flex flex-wrap justify-end gap-2"> <div className="flex shrink-0 flex-wrap justify-end gap-2">
<Button variant={isAcknowledged ? "secondary" : "ghost"} size="sm" disabled={busy} onClick={() => onSetAcknowledgement(item, !isAcknowledged)}> <Button className="h-8" variant={isAcknowledged ? "secondary" : "ghost"} size="sm" disabled={busy} onClick={() => onSetAcknowledgement(item, !isAcknowledged)}>
{isAcknowledged ? "Zurücknehmen" : "Zur Kenntnis"} {isAcknowledged ? "Zurücknehmen" : "Zur Kenntnis"}
</Button> </Button>
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${item.ticket_id}?month=${item.day.slice(0, 7)}`)}> <Button className="h-8" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${item.ticket_id}?month=${item.day.slice(0, 7)}`)}>
Tag öffnen Tag öffnen
<ExternalLink className="size-4" /> <ExternalLink className="size-4" />
</Button> </Button>
</div> </div>
</div> </div>
</div>
); );
} }