feat: security hardening and v1.0 update modal

- Implemented HMAC-signed session cookies for enhanced security
- Added robust input validation for usage, preferences, and mood APIs
- Added VersionUpdateModal to announce v1.0 features
- Integrated update modal into Dashboard
This commit is contained in:
Avery Felts 2026-02-01 01:55:53 -07:00
parent 42841f665c
commit 4e8fe2a91c
6 changed files with 246 additions and 4 deletions

View File

@ -101,10 +101,18 @@ export async function POST(request: NextRequest) {
const body = await request.json() as { mood: 'amazing' | 'good' | 'neutral' | 'bad' | 'terrible'; score: number; comment?: string; date?: string };
const { mood, score, comment, date } = body;
if (!mood) {
if (!mood || !['amazing', 'good', 'neutral', 'bad', 'terrible'].includes(mood)) {
return NextResponse.json({ error: 'Invalid mood' }, { status: 400 });
}
if (comment && (typeof comment !== 'string' || comment.length > 500)) {
return NextResponse.json({ error: 'Comment too long' }, { status: 400 });
}
if (date && !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return NextResponse.json({ error: 'Invalid date format' }, { status: 400 });
}
// Validate score is between 0 and 100
const finalScore = Math.max(0, Math.min(100, score ?? 50));

View File

@ -71,6 +71,26 @@ export async function POST(request: NextRequest) {
lastWeedUsageTime?: string;
};
// Validation
if (body.substance && !['nicotine', 'weed'].includes(body.substance)) {
return NextResponse.json({ error: 'Invalid substance' }, { status: 400 });
}
if (body.trackingStartDate && !/^\d{4}-\d{2}-\d{2}$/.test(body.trackingStartDate)) {
return NextResponse.json({ error: 'Invalid trackingStartDate format' }, { status: 400 });
}
if (body.dailyGoal !== undefined && (typeof body.dailyGoal !== 'number' || body.dailyGoal < 0)) {
return NextResponse.json({ error: 'Invalid dailyGoal' }, { status: 400 });
}
if (body.userName && (typeof body.userName !== 'string' || body.userName.length > 100)) {
return NextResponse.json({ error: 'Invalid userName' }, { status: 400 });
}
if (body.userAge !== undefined && (typeof body.userAge !== 'number' || body.userAge < 0 || body.userAge > 120)) {
return NextResponse.json({ error: 'Invalid userAge' }, { status: 400 });
}
if (body.religion && !['christian', 'secular'].includes(body.religion)) {
return NextResponse.json({ error: 'Invalid religion' }, { status: 400 });
}
// If quitState is provided in body, save it to quitPlanJson
const quitPlanJson = body.quitState
? JSON.stringify(body.quitState)

View File

@ -43,6 +43,17 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Validation
if (typeof count !== 'number' || count < 0 || !Number.isInteger(count)) {
return NextResponse.json({ error: 'Invalid count' }, { status: 400 });
}
if (!['nicotine', 'weed'].includes(substance)) {
return NextResponse.json({ error: 'Invalid substance' }, { status: 400 });
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return NextResponse.json({ error: 'Invalid date format' }, { status: 400 });
}
// Add to existing count
const entry = await upsertUsageEntryD1(session.user.id, date, count, substance, true);
@ -75,6 +86,17 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
// Validation
if (typeof count !== 'number' || count < 0 || !Number.isInteger(count)) {
return NextResponse.json({ error: 'Invalid count' }, { status: 400 });
}
if (!['nicotine', 'weed'].includes(substance)) {
return NextResponse.json({ error: 'Invalid substance' }, { status: 400 });
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return NextResponse.json({ error: 'Invalid date format' }, { status: 400 });
}
// Set the exact count (replace, not add)
const entry = await upsertUsageEntryD1(session.user.id, date, count, substance, false);

View File

@ -35,6 +35,7 @@ import { SavingsTrackerCard } from './SavingsTrackerCard';
import { MoodTracker } from './MoodTracker';
import { ScrollWheelLogger } from './ScrollWheelLogger';
import { UsageLoggerDropUp } from './UsageLoggerDropUp';
import { VersionUpdateModal } from './VersionUpdateModal';
import { Button } from '@/components/ui/button';
import { PlusCircle, ChevronLeft, ChevronRight, X } from 'lucide-react';
import { useTheme } from '@/lib/theme-context';
@ -468,6 +469,8 @@ export function Dashboard({ user }: DashboardProps) {
/>
)}
<VersionUpdateModal />
{showCelebration && newBadge && (
<CelebrationAnimation
badge={newBadge}

View File

@ -0,0 +1,136 @@
'use client';
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Sparkles, Shield, Bell, Smartphone, Monitor, Trophy, Rocket, Scale, Heart } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTheme } from '@/lib/theme-context';
export function VersionUpdateModal() {
const [isOpen, setIsOpen] = useState(false);
const { theme } = useTheme();
useEffect(() => {
// Check local storage for the flag
const hasSeenUpdate = localStorage.getItem('seen_version_1.0_update');
if (!hasSeenUpdate) {
setIsOpen(true);
}
}, []);
const handleClose = () => {
// Set the flag in local storage
localStorage.setItem('seen_version_1.0_update', 'true');
setIsOpen(false);
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className={cn(
"sm:max-w-xl max-h-[85vh] overflow-y-auto border-0 shadow-2xl p-0 gap-0 rounded-3xl",
theme === 'light' ? 'bg-white' : 'bg-[#1a1b26]'
)}>
{/* Header Section with Gradient Background */}
<div className="relative overflow-hidden bg-gradient-to-br from-indigo-600 to-purple-700 p-8 text-white">
<div className="absolute top-0 right-0 -mr-16 -mt-16 w-64 h-64 bg-white/10 rounded-full blur-3xl" />
<div className="relative z-10 flex flex-col items-center text-center space-y-4">
<div className="p-3 bg-white/20 backdrop-blur-md rounded-2xl shadow-inner">
<Rocket className="w-10 h-10 text-white" />
</div>
<div className="space-y-1">
<DialogTitle className="text-3xl font-bold tracking-tight">Version 1.0 is Live!</DialogTitle>
<DialogDescription className="text-white/80 text-base font-medium">
The biggest update to QuitTraq is finally here.
</DialogDescription>
</div>
</div>
</div>
{/* Content Section */}
<div className="p-6 space-y-6">
<h3 className="text-sm font-bold uppercase tracking-widest opacity-50 px-2">What's New</h3>
<div className="grid gap-4">
{/* Security */}
<div className={cn("flex gap-4 p-4 rounded-2xl border", theme === 'light' ? 'bg-slate-50 border-slate-100' : 'bg-white/5 border-white/5')}>
<div className="p-2.5 bg-emerald-500/10 rounded-xl h-fit">
<Shield className="w-5 h-5 text-emerald-500" />
</div>
<div className="space-y-1">
<h4 className="font-bold text-sm">Major Security Overhaul</h4>
<p className="text-xs opacity-70 leading-relaxed">
implemented complete security audit and fixes. This is why you had to login againwe've secured your session data with industry-standard encryption.
</p>
</div>
</div>
{/* Notifications & Input */}
<div className="grid sm:grid-cols-2 gap-4">
<div className={cn("p-4 rounded-2xl border space-y-2", theme === 'light' ? 'bg-slate-50 border-slate-100' : 'bg-white/5 border-white/5')}>
<Bell className="w-5 h-5 text-amber-500 mb-1" />
<h4 className="font-bold text-sm">Smart Notifications</h4>
<p className="text-[10px] opacity-70 leading-relaxed">
Updated messaging system that adapts based on the time of day to keep you motivated.
</p>
</div>
<div className={cn("p-4 rounded-2xl border space-y-2", theme === 'light' ? 'bg-slate-50 border-slate-100' : 'bg-white/5 border-white/5')}>
<Smartphone className="w-5 h-5 text-blue-500 mb-1" />
<h4 className="font-bold text-sm">PWA & UI Polish</h4>
<p className="text-[10px] opacity-70 leading-relaxed">
New login screen, landing page, and optimization fixes for a smoother app-like experience.
</p>
</div>
</div>
{/* Tracking Improvements */}
<div className={cn("flex gap-4 p-4 rounded-2xl border", theme === 'light' ? 'bg-slate-50 border-slate-100' : 'bg-white/5 border-white/5')}>
<div className="p-2.5 bg-indigo-500/10 rounded-xl h-fit">
<Scale className="w-5 h-5 text-indigo-500" />
</div>
<div className="space-y-1">
<h4 className="font-bold text-sm">Refined Tracking</h4>
<p className="text-xs opacity-70 leading-relaxed">
Fixed input logging on desktop, improved the independent track buttons for weed/nicotine, and fixed the scroll system.
</p>
</div>
</div>
{/* Goals & Moods */}
<div className="grid sm:grid-cols-2 gap-4">
<div className={cn("p-4 rounded-2xl border space-y-2", theme === 'light' ? 'bg-slate-50 border-slate-100' : 'bg-white/5 border-white/5')}>
<Trophy className="w-5 h-5 text-yellow-500 mb-1" />
<h4 className="font-bold text-sm">Independent Goals</h4>
<p className="text-[10px] opacity-70 leading-relaxed">
Set separate quit plans for nicotine and marijuana. Fixed achievement unlocking celebrations.
</p>
</div>
<div className={cn("p-4 rounded-2xl border space-y-2", theme === 'light' ? 'bg-slate-50 border-slate-100' : 'bg-white/5 border-white/5')}>
<Heart className="w-5 h-5 text-rose-500 mb-1" />
<h4 className="font-bold text-sm">Mood Tracker 2.0</h4>
<p className="text-[10px] opacity-70 leading-relaxed">
Friendlier messages, more mood options, daily average charts, and weekly score tracking.
</p>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className={cn("p-6 pt-2", theme === 'light' ? 'bg-slate-50/50' : 'bg-black/20')}>
<Button
className="w-full h-12 rounded-xl text-base font-bold bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white shadow-lg active:scale-95 transition-all"
onClick={handleClose}
>
Let's Go!
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -16,6 +16,43 @@ export interface Session {
}
const SESSION_COOKIE_NAME = 'quit_smoking_session';
const SESSION_SECRET = process.env.SESSION_SECRET || 'fallback-secret-for-dev-only';
// Helper to sign the session
async function sign(data: string, secret: string): Promise<string> {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const dataData = encoder.encode(data);
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, dataData);
return btoa(String.fromCharCode(...new Uint8Array(signature)));
}
// Helper to verify the session
async function verify(data: string, signature: string, secret: string): Promise<boolean> {
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const dataData = encoder.encode(data);
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const sigBytes = Uint8Array.from(atob(signature), (c) => c.charCodeAt(0));
return await crypto.subtle.verify('HMAC', key, sigBytes, dataData);
}
export async function getSession(): Promise<Session | null> {
const cookieStore = await cookies();
@ -26,8 +63,20 @@ export async function getSession(): Promise<Session | null> {
}
try {
return JSON.parse(sessionCookie.value);
} catch {
const [payloadBase64, signature] = sessionCookie.value.split('.');
if (!payloadBase64 || !signature) return null;
const payload = atob(payloadBase64);
const isValid = await verify(payload, signature, SESSION_SECRET);
if (!isValid) {
console.warn('Invalid session signature detected');
return null;
}
return JSON.parse(payload);
} catch (error) {
console.error('Error parsing session:', error);
return null;
}
}
@ -36,7 +85,11 @@ export async function setSession(session: Session): Promise<void> {
const cookieStore = await cookies();
const maxAge = session.stayLoggedIn ? 60 * 60 * 24 * 30 : 60 * 60 * 24; // 30 days if stay logged in, else 1 day
cookieStore.set(SESSION_COOKIE_NAME, JSON.stringify(session), {
const payload = JSON.stringify(session);
const payloadBase64 = btoa(payload);
const signature = await sign(payload, SESSION_SECRET);
cookieStore.set(SESSION_COOKIE_NAME, `${payloadBase64}.${signature}`, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',