diff --git a/docker-compose.example.yml b/docker-compose.example.yml new file mode 100644 index 0000000..67581b9 --- /dev/null +++ b/docker-compose.example.yml @@ -0,0 +1,34 @@ +services: + quietthanks: + build: . + container_name: quietthanks + restart: unless-stopped + ports: + - "6124:3000" + volumes: + - ./data:/app/data + environment: + - DATABASE_PATH=/app/data/quietthanks.db + - NEXT_PUBLIC_VAPID_PUBLIC_KEY=${NEXT_PUBLIC_VAPID_PUBLIC_KEY} + - VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY} + - VAPID_EMAIL=${VAPID_EMAIL} + - ENCRYPTION_KEY=${ENCRYPTION_KEY} + - TZ=${TZ} + + scheduler: + image: alpine + restart: unless-stopped + depends_on: + quietthanks: + condition: service_started + environment: + - TZ=${TZ} + entrypoint: /bin/sh + command: > + -c "apk add --no-cache curl && + while true; do + echo 'Checking for notifications...' && + curl -s -X POST http://quietthanks:3000/api/notifications/send && + echo '' && + sleep 60; + done" diff --git a/docker-compose.yml b/docker-compose.yml index ceb6981..67581b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,10 +9,11 @@ services: - ./data:/app/data environment: - DATABASE_PATH=/app/data/quietthanks.db - - NEXT_PUBLIC_VAPID_PUBLIC_KEY=BIKukAq5-KPwJAMpksxD7UNL8XfF-oJOI0CLGGZQAY93igZgf1PYa9MVvS8GaBv-vv9ckcXPCEKdzWDCtOyQpKg - - VAPID_PRIVATE_KEY=IBkQ14BLKFCg2PmGOWheC7xfYHS5J49vXS8duHCeDBw - - VAPID_EMAIL=mailto:admin@example.com - - TZ=Australia/Perth + - NEXT_PUBLIC_VAPID_PUBLIC_KEY=${NEXT_PUBLIC_VAPID_PUBLIC_KEY} + - VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY} + - VAPID_EMAIL=${VAPID_EMAIL} + - ENCRYPTION_KEY=${ENCRYPTION_KEY} + - TZ=${TZ} scheduler: image: alpine @@ -21,7 +22,7 @@ services: quietthanks: condition: service_started environment: - - TZ=Australia/Perth + - TZ=${TZ} entrypoint: /bin/sh command: > -c "apk add --no-cache curl && diff --git a/src/app/api/reflections/route.ts b/src/app/api/reflections/route.ts index 9ebdd89..01facc8 100644 --- a/src/app/api/reflections/route.ts +++ b/src/app/api/reflections/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { db, schema } from "@/lib/db"; import { getSession } from "@/lib/auth"; import { eq, and, gte, lte, desc } from "drizzle-orm"; +import { decrypt } from "@/lib/crypto"; // POST /api/reflections - Generate an LLM reflection for a time period export async function POST(request: NextRequest) { @@ -31,7 +32,8 @@ export async function POST(request: NextRequest) { ); } - const { llmProvider, llmApiKey, llmModel } = users[0]; + const { llmProvider, llmModel } = users[0]; + const llmApiKey = decrypt(users[0].llmApiKey); // Calculate date range const now = new Date(); diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 8a8464c..938956c 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { db, schema } from "@/lib/db"; import { getSession } from "@/lib/auth"; import { eq } from "drizzle-orm"; +import { encrypt } from "@/lib/crypto"; // GET /api/settings - Get current user settings export async function GET() { @@ -70,7 +71,8 @@ export async function PATCH(request: NextRequest) { } if (typeof body.llmApiKey === "string") { - updates.llmApiKey = body.llmApiKey || null; + // Encrypt the API key before storing + updates.llmApiKey = body.llmApiKey ? encrypt(body.llmApiKey) : null; } if (typeof body.llmModel === "string") { diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..b70f857 --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,55 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; + +const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || ''; +const ALGORITHM = 'aes-256-gcm'; + +export function encrypt(text: string): string { + if (!ENCRYPTION_KEY) { + console.warn('ENCRYPTION_KEY not set, storing in plain text'); + return text; + } + + try { + const iv = randomBytes(16); + const key = Buffer.from(ENCRYPTION_KEY, 'hex'); + const cipher = createCipheriv(ALGORITHM, key, iv); + + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + const authTag = cipher.getAuthTag().toString('hex'); + + // Format: iv:authTag:encrypted + return `${iv.toString('hex')}:${authTag}:${encrypted}`; + } catch (error) { + console.error('Encryption failed:', error); + return text; + } +} + +export function decrypt(text: string): string { + if (!ENCRYPTION_KEY || !text) return text; + + const parts = text.split(':'); + // If not in encrypted format (legacy plain text), return as is + if (parts.length !== 3) return text; + + try { + const [ivHex, authTagHex, encryptedHex] = parts; + const key = Buffer.from(ENCRYPTION_KEY, 'hex'); + const iv = Buffer.from(ivHex, 'hex'); + const authTag = Buffer.from(authTagHex, 'hex'); + + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encryptedHex, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; + } catch (error) { + // If decryption fails, assume it might be plain text or corrupted + console.warn('Decryption failed, returning original text'); + return text; + } +}