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:
parent
42841f665c
commit
4e8fe2a91c
@ -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 body = await request.json() as { mood: 'amazing' | 'good' | 'neutral' | 'bad' | 'terrible'; score: number; comment?: string; date?: string };
|
||||||
const { mood, score, comment, date } = body;
|
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 });
|
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
|
// Validate score is between 0 and 100
|
||||||
const finalScore = Math.max(0, Math.min(100, score ?? 50));
|
const finalScore = Math.max(0, Math.min(100, score ?? 50));
|
||||||
|
|
||||||
|
|||||||
@ -71,6 +71,26 @@ export async function POST(request: NextRequest) {
|
|||||||
lastWeedUsageTime?: string;
|
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
|
// If quitState is provided in body, save it to quitPlanJson
|
||||||
const quitPlanJson = body.quitState
|
const quitPlanJson = body.quitState
|
||||||
? JSON.stringify(body.quitState)
|
? JSON.stringify(body.quitState)
|
||||||
|
|||||||
@ -43,6 +43,17 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
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
|
// Add to existing count
|
||||||
const entry = await upsertUsageEntryD1(session.user.id, date, count, substance, true);
|
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 });
|
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)
|
// Set the exact count (replace, not add)
|
||||||
const entry = await upsertUsageEntryD1(session.user.id, date, count, substance, false);
|
const entry = await upsertUsageEntryD1(session.user.id, date, count, substance, false);
|
||||||
|
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import { SavingsTrackerCard } from './SavingsTrackerCard';
|
|||||||
import { MoodTracker } from './MoodTracker';
|
import { MoodTracker } from './MoodTracker';
|
||||||
import { ScrollWheelLogger } from './ScrollWheelLogger';
|
import { ScrollWheelLogger } from './ScrollWheelLogger';
|
||||||
import { UsageLoggerDropUp } from './UsageLoggerDropUp';
|
import { UsageLoggerDropUp } from './UsageLoggerDropUp';
|
||||||
|
import { VersionUpdateModal } from './VersionUpdateModal';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PlusCircle, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
import { PlusCircle, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||||
import { useTheme } from '@/lib/theme-context';
|
import { useTheme } from '@/lib/theme-context';
|
||||||
@ -468,6 +469,8 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<VersionUpdateModal />
|
||||||
|
|
||||||
{showCelebration && newBadge && (
|
{showCelebration && newBadge && (
|
||||||
<CelebrationAnimation
|
<CelebrationAnimation
|
||||||
badge={newBadge}
|
badge={newBadge}
|
||||||
|
|||||||
136
src/components/VersionUpdateModal.tsx
Normal file
136
src/components/VersionUpdateModal.tsx
Normal 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 again—we'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -16,6 +16,43 @@ export interface Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME = 'quit_smoking_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> {
|
export async function getSession(): Promise<Session | null> {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
@ -26,8 +63,20 @@ export async function getSession(): Promise<Session | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(sessionCookie.value);
|
const [payloadBase64, signature] = sessionCookie.value.split('.');
|
||||||
} catch {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -36,7 +85,11 @@ export async function setSession(session: Session): Promise<void> {
|
|||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const maxAge = session.stayLoggedIn ? 60 * 60 * 24 * 30 : 60 * 60 * 24; // 30 days if stay logged in, else 1 day
|
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,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user