Merge Tag 'v0.2.31'

This commit is contained in:
2026-03-09 12:32:35 +01:00
22 changed files with 627 additions and 135 deletions

View File

@@ -1,5 +1,19 @@
# Changelog # Changelog
## Version 0.2.31
**This Release contains important security patches, please update as soon as possible**
### Fixed
* Security: Updated Next.js from 15.5.7 to 15.5.10
* Security: Hardened avatar route against path traversal attacks
* Security: Sanitized user data in client-facing payloads
* Security: Hardened debug actions and avatar validation
* Fixed missing English translations for DrawingModal
Thank you @1ARdotNO for the security audit!
## Version 0.2.30 ## Version 0.2.30
### Fixed ### Fixed

88
app/actions/data.test.ts Normal file
View File

@@ -0,0 +1,88 @@
import { describe, expect, test } from 'bun:test'
import { sanitizeUserData } from '@/lib/user-sanitizer'
import { UserData } from '@/lib/types'
describe('sanitizeUserData', () => {
test('removes password field from every user', () => {
const input: UserData = {
users: [
{
id: 'u1',
username: 'admin',
password: 'abcd1234:ef567890',
isAdmin: true,
},
{
id: 'u2',
username: 'no-pass',
isAdmin: false,
},
],
}
const output = sanitizeUserData(input)
expect(output.users).toHaveLength(2)
expect(output.users[0]).not.toHaveProperty('password')
expect(output.users[1]).not.toHaveProperty('password')
})
test('adds hasPassword metadata based on stored password', () => {
const input: UserData = {
users: [
{
id: 'u1',
username: 'with-hash',
password: 'abcd1234:ef567890',
isAdmin: false,
},
{
id: 'u2',
username: 'empty-pass',
password: '',
isAdmin: false,
},
{
id: 'u3',
username: 'no-pass',
isAdmin: false,
},
],
}
const output = sanitizeUserData(input)
expect(output.users[0].hasPassword).toBe(true)
expect(output.users[1].hasPassword).toBe(false)
expect(output.users[2].hasPassword).toBe(false)
})
test('preserves other user properties', () => {
const input: UserData = {
users: [
{
id: 'u1',
username: 'user',
password: 'hash',
avatarPath: '/data/avatars/u1.png',
isAdmin: false,
permissions: [
{
habit: { write: true, interact: true },
wishlist: { write: true, interact: true },
coins: { write: true, interact: true },
},
],
},
],
}
const output = sanitizeUserData(input)
expect(output.users[0].id).toBe('u1')
expect(output.users[0].username).toBe('user')
expect(output.users[0].avatarPath).toBe('/data/avatars/u1.png')
expect(output.users[0].isAdmin).toBe(false)
expect(output.users[0].permissions?.[0].habit.write).toBe(true)
})
})

View File

@@ -1,5 +1,6 @@
'use server' 'use server'
import { ALLOWED_AVATAR_EXTENSIONS, ALLOWED_AVATAR_MIME_TYPES } from '@/lib/avatar';
import { getCurrentUser, saltAndHashPassword, verifyPassword } from "@/lib/server-helpers"; import { getCurrentUser, saltAndHashPassword, verifyPassword } from "@/lib/server-helpers";
import { import {
CoinsData, CoinsData,
@@ -13,6 +14,8 @@ import {
getDefaultWishlistData, getDefaultWishlistData,
HabitsData, HabitsData,
Permission, Permission,
PublicUser,
PublicUserData,
ServerSettings, ServerSettings,
Settings, Settings,
TransactionType, TransactionType,
@@ -21,8 +24,10 @@ import {
WishlistData, WishlistData,
WishlistItemType WishlistItemType
} from '@/lib/types'; } from '@/lib/types';
import { sanitizeUserData } from '@/lib/user-sanitizer';
import { d2t, generateCryptoHash, getNow, prepareDataForHashing } from '@/lib/utils'; import { d2t, generateCryptoHash, getNow, prepareDataForHashing } from '@/lib/utils';
import { signInSchema } from '@/lib/zod'; import { signInSchema } from '@/lib/zod';
import { randomUUID } from "crypto";
import fs from 'fs/promises'; import fs from 'fs/promises';
import _ from 'lodash'; import _ from 'lodash';
import path from 'path'; import path from 'path';
@@ -32,6 +37,20 @@ import path from 'path';
type ResourceType = 'habit' | 'wishlist' | 'coins' type ResourceType = 'habit' | 'wishlist' | 'coins'
type ActionType = 'write' | 'interact' type ActionType = 'write' | 'interact'
async function verifyPermission(
resource: ResourceType,
action: ActionType
): Promise<void> {
// const user = await getCurrentUser()
// if (!user) throw new PermissionError('User not authenticated')
// if (user.isAdmin) return // Admins bypass permission checks
// if (!checkPermission(user.permissions, resource, action)) {
// throw new PermissionError(`User does not have ${action} permission for ${resource}`)
// }
return
}
function getDefaultData<T>(type: DataType): T { function getDefaultData<T>(type: DataType): T {
return DATA_DEFAULTS[type]() as T; return DATA_DEFAULTS[type]() as T;
@@ -48,22 +67,28 @@ async function ensureDataDir() {
// --- Backup Debug Action --- // --- Backup Debug Action ---
export async function triggerManualBackup(): Promise<{ success: boolean; message: string }> { export async function triggerManualBackup(): Promise<{ success: boolean; message: string }> {
// Optional: Add extra permission check if needed for debug actions if (process.env.NODE_ENV !== 'development') {
// const user = await getCurrentUser(); return { success: false, message: 'Permission denied.' }
// if (!user?.isAdmin) { }
// return { success: false, message: "Permission denied." };
// }
console.log("Manual backup trigger requested..."); const user = await getCurrentUser()
if (!user?.isAdmin) {
return { success: false, message: 'Permission denied.' }
}
console.log('Manual backup trigger requested...')
try { try {
// Import runBackup locally to avoid potential circular dependencies if moved // Import runBackup locally to avoid potential circular dependencies if moved
const { runBackup } = await import('@/lib/backup'); const { runBackup } = await import('@/lib/backup')
await runBackup(); await runBackup()
console.log("Manual backup trigger completed successfully."); console.log('Manual backup trigger completed successfully.')
return { success: true, message: "Backup process completed successfully." }; return { success: true, message: 'Backup process completed successfully.' }
} catch (error) { } catch (error) {
console.error("Manual backup trigger failed:", error); console.error('Manual backup trigger failed:', error)
return { success: false, message: `Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}` }; return {
success: false,
message: `Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
}
} }
} }
@@ -136,7 +161,7 @@ async function calculateServerFreshnessToken(): Promise<string | null> {
// Wishlist specific functions // Wishlist specific functions
export async function loadWishlistData(): Promise<WishlistData> { export async function loadWishlistData(): Promise<WishlistData> {
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return getDefaultWishlistData<WishlistData>() if (!user) return getDefaultWishlistData()
const data = await loadData<WishlistData>('wishlist') const data = await loadData<WishlistData>('wishlist')
return { return {
@@ -173,7 +198,7 @@ export async function saveWishlistItems(data: WishlistData): Promise<void> {
// Habits specific functions // Habits specific functions
export async function loadHabitsData(): Promise<HabitsData> { export async function loadHabitsData(): Promise<HabitsData> {
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return getDefaultHabitsData<HabitsData>() if (!user) return getDefaultHabitsData()
const data = await loadData<HabitsData>('habits') const data = await loadData<HabitsData>('habits')
return { return {
habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id)) habits: data.habits.filter(x => user.isAdmin || x.userIds?.includes(user.id))
@@ -208,14 +233,14 @@ export async function saveHabitsData(data: HabitsData): Promise<void> {
export async function loadCoinsData(): Promise<CoinsData> { export async function loadCoinsData(): Promise<CoinsData> {
try { try {
const user = await getCurrentUser() const user = await getCurrentUser()
if (!user) return getDefaultCoinsData<CoinsData>() if (!user) return getDefaultCoinsData()
const data = await loadData<CoinsData>('coins') const data = await loadData<CoinsData>('coins')
return { return {
...data, ...data,
transactions: user.isAdmin ? data.transactions : data.transactions.filter(x => x.userId === user.id) transactions: user.isAdmin ? data.transactions : data.transactions.filter(x => x.userId === user.id)
} }
} catch { } catch {
return getDefaultCoinsData<CoinsData>() return getDefaultCoinsData()
} }
} }
@@ -278,7 +303,7 @@ export async function addCoins({
} }
export async function loadSettings(): Promise<Settings> { export async function loadSettings(): Promise<Settings> {
const defaultSettings = getDefaultSettings<Settings>() const defaultSettings = getDefaultSettings()
try { try {
const user = await getCurrentUser() const user = await getCurrentUser()
@@ -339,13 +364,22 @@ export async function uploadAvatar(formData: FormData): Promise<string> {
throw new Error('File size must be less than 5MB') throw new Error('File size must be less than 5MB')
} }
const mimeType = file.type.toLowerCase()
if (!ALLOWED_AVATAR_MIME_TYPES.has(mimeType)) {
throw new Error('Unsupported avatar MIME type')
}
const ext = path.extname(file.name).toLowerCase()
if (!ALLOWED_AVATAR_EXTENSIONS.has(ext)) {
throw new Error('Unsupported avatar file extension')
}
// Create avatars directory if it doesn't exist // Create avatars directory if it doesn't exist
const avatarsDir = path.join(process.cwd(), 'data', 'avatars') const avatarsDir = path.join(process.cwd(), 'data', 'avatars')
await fs.mkdir(avatarsDir, { recursive: true }) await fs.mkdir(avatarsDir, { recursive: true })
// Generate unique filename // Generate unique filename
const ext = file.name.split('.').pop() const filename = `${Date.now()}-${randomUUID()}${ext}`
const filename = `${Date.now()}.${ext}`
const filePath = path.join(avatarsDir, filename) const filePath = path.join(avatarsDir, filename)
// Save file // Save file
@@ -366,14 +400,19 @@ export async function getChangelog(): Promise<string> {
} }
// user logic // user logic
export async function loadUsersData(): Promise<UserData> { async function loadUsersData(): Promise<UserData> {
try { try {
return await loadData<UserData>('auth') return await loadData<UserData>('auth')
} catch { } catch {
return getDefaultUsersData<UserData>() return getDefaultUsersData()
} }
} }
export async function loadUsersPublicData(): Promise<PublicUserData> {
const data = await loadUsersData()
return sanitizeUserData(data)
}
export async function saveUsersData(data: UserData): Promise<void> { export async function saveUsersData(data: UserData): Promise<void> {
return saveData('auth', data) return saveData('auth', data)
} }
@@ -391,7 +430,7 @@ export async function getUser(username: string, plainTextPassword?: string): Pro
return user return user
} }
export async function createUser(formData: FormData): Promise<User> { export async function createUser(formData: FormData): Promise<PublicUser> {
const username = formData.get('username') as string; const username = formData.get('username') as string;
let password = formData.get('password') as string | undefined; let password = formData.get('password') as string | undefined;
const avatarPath = formData.get('avatarPath') as string; const avatarPath = formData.get('avatarPath') as string;
@@ -428,10 +467,10 @@ export async function createUser(formData: FormData): Promise<User> {
}; };
await saveUsersData(newData); await saveUsersData(newData);
return newUser; return sanitizeUserData({ users: [newUser] }).users[0]
} }
export async function updateUser(userId: string, updates: Partial<Omit<User, 'id' | 'password'>>): Promise<User> { export async function updateUser(userId: string, updates: Partial<Omit<User, 'id' | 'password'>>): Promise<PublicUser> {
const data = await loadUsersData() const data = await loadUsersData()
const userIndex = data.users.findIndex(user => user.id === userId) const userIndex = data.users.findIndex(user => user.id === userId)
@@ -463,7 +502,7 @@ export async function updateUser(userId: string, updates: Partial<Omit<User, 'id
} }
await saveUsersData(newData) await saveUsersData(newData)
return updatedUser return sanitizeUserData({ users: [updatedUser] }).users[0]
} }
export async function updateUserPassword(userId: string, newPassword?: string): Promise<void> { export async function updateUserPassword(userId: string, newPassword?: string): Promise<void> {

View File

@@ -0,0 +1,197 @@
import { afterEach, beforeAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'
import path from 'path'
const mockReadFile = mock()
const mockRealpath = mock()
const mockLstat = mock()
mock.module('fs/promises', () => ({
default: {
readFile: mockReadFile,
realpath: mockRealpath,
lstat: mockLstat,
},
readFile: mockReadFile,
realpath: mockRealpath,
lstat: mockLstat,
}))
let GET: typeof import('./route').GET
beforeAll(async () => {
;({ GET } = await import('./route'))
})
afterEach(() => {
mock.restore()
})
describe('GET /api/avatars/[...path]', () => {
beforeEach(() => {
mockReadFile.mockReset()
mockRealpath.mockReset()
mockLstat.mockReset()
spyOn(process, 'cwd').mockReturnValue('/app')
mockRealpath.mockImplementation(async (value: string) => value)
mockLstat.mockResolvedValue({ isSymbolicLink: () => false })
})
test('returns avatar image for valid file path', async () => {
mockReadFile.mockResolvedValue(Buffer.from('avatar-binary'))
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
params: Promise.resolve({ path: ['avatar.png'] }),
})
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('image/png')
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
expect(mockReadFile).toHaveBeenCalledWith(path.resolve('/app', 'data', 'avatars', 'avatar.png'))
})
test('allows nested valid avatar paths', async () => {
mockReadFile.mockResolvedValue(Buffer.from('avatar-binary'))
const response = await GET(new Request('http://localhost:3000/api/avatars/user-1/avatar.png'), {
params: Promise.resolve({ path: ['user-1', 'avatar.png'] }),
})
expect(response.status).toBe(200)
expect(mockReadFile).toHaveBeenCalledWith(path.resolve('/app', 'data', 'avatars', 'user-1', 'avatar.png'))
})
test('supports uppercase extensions', async () => {
mockReadFile.mockResolvedValue(Buffer.from('avatar-binary'))
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.PNG'), {
params: Promise.resolve({ path: ['avatar.PNG'] }),
})
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('image/png')
})
test('rejects traversal segments', async () => {
const response = await GET(new Request('http://localhost:3000/api/avatars/../auth.json'), {
params: Promise.resolve({ path: ['..', 'auth.json'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects encoded traversal payloads', async () => {
const response = await GET(new Request('http://localhost:3000/api/avatars/%2e%2e%2fauth.json'), {
params: Promise.resolve({ path: ['%2e%2e%2fauth.json'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects encoded backslash traversal payloads', async () => {
const response = await GET(new Request('http://localhost:3000/api/avatars/..%5cauth.png'), {
params: Promise.resolve({ path: ['..%5cauth.png'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects null byte payloads', async () => {
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png%00'), {
params: Promise.resolve({ path: ['avatar.png%00'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects dot-only segments', async () => {
const response = await GET(new Request('http://localhost:3000/api/avatars/./avatar.png'), {
params: Promise.resolve({ path: ['.', 'avatar.png'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects malformed encoded segments', async () => {
const response = await GET(new Request('http://localhost:3000/api/avatars/%E0%A4%A'), {
params: Promise.resolve({ path: ['%E0%A4%A'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects unsupported file extensions', async () => {
const response = await GET(new Request('http://localhost:3000/api/avatars/config.json'), {
params: Promise.resolve({ path: ['config.json'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Unsupported file type' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects symlinked avatar files', async () => {
mockLstat.mockResolvedValue({ isSymbolicLink: () => true })
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
params: Promise.resolve({ path: ['avatar.png'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('rejects files whose real path escapes avatars directory', async () => {
mockRealpath.mockImplementation(async (value: string) => {
if (value === path.resolve('/app', 'data', 'avatars')) {
return value
}
return path.resolve('/app', 'data', 'auth.png')
})
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
params: Promise.resolve({ path: ['avatar.png'] }),
})
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
expect(mockReadFile).not.toHaveBeenCalled()
})
test('returns 404 when file is missing', async () => {
mockLstat.mockRejectedValue({ code: 'ENOENT' })
const response = await GET(new Request('http://localhost:3000/api/avatars/missing.png'), {
params: Promise.resolve({ path: ['missing.png'] }),
})
expect(response.status).toBe(404)
expect(await response.json()).toEqual({ error: 'File not found' })
})
test('returns 500 for non-ENOENT read errors', async () => {
mockReadFile.mockRejectedValue({ code: 'EACCES' })
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
params: Promise.resolve({ path: ['avatar.png'] }),
})
expect(response.status).toBe(500)
expect(await response.json()).toEqual({ error: 'Internal server error' })
})
})

View File

@@ -1,26 +1,109 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import fs from 'fs/promises' import fs from 'fs/promises'
import path from 'path' import path from 'path'
import { ALLOWED_AVATAR_EXTENSIONS, AVATAR_CONTENT_TYPE } from '@/lib/avatar'
function sanitizePathSegments(pathSegments?: string[]): string[] | null {
if (!pathSegments || pathSegments.length === 0) {
return null
}
const safeSegments: string[] = []
for (const rawSegment of pathSegments) {
let segment = rawSegment
try {
segment = decodeURIComponent(rawSegment)
} catch {
return null
}
if (!segment || segment === '.' || segment === '..') {
return null
}
if (segment.includes('/') || segment.includes('\\') || segment.includes('\0')) {
return null
}
safeSegments.push(segment)
}
return safeSegments
}
function isPathInsideBase(basePath: string, targetPath: string): boolean {
return targetPath === basePath || targetPath.startsWith(`${basePath}${path.sep}`)
}
function getErrorCode(error: unknown): string | null {
if (typeof error !== 'object' || error === null || !('code' in error)) {
return null
}
const { code } = error as { code?: unknown }
return typeof code === 'string' ? code : null
}
export async function GET( export async function GET(
request: Request, _request: Request,
{ params }: { params: Promise<{ path: string[] }> } { params }: { params: Promise<{ path: string[] }> }
) { ) {
const { path: pathSegments } = await Promise.resolve(params)
const safeSegments = sanitizePathSegments(pathSegments)
if (!safeSegments) {
return NextResponse.json({ error: 'Invalid avatar path' }, { status: 400 })
}
const avatarsDir = path.resolve(process.cwd(), 'data', 'avatars')
const filePath = path.resolve(avatarsDir, ...safeSegments)
if (!isPathInsideBase(avatarsDir, filePath)) {
return NextResponse.json({ error: 'Invalid avatar path' }, { status: 400 })
}
const ext = path.extname(filePath).toLowerCase()
if (!ALLOWED_AVATAR_EXTENSIONS.has(ext)) {
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 })
}
try { try {
const { path: pathSegments } = await Promise.resolve(params) const realAvatarsDir = await fs.realpath(avatarsDir)
const filePath = path.join(process.cwd(), 'data', 'avatars', ...(pathSegments || [])) const fileStats = await fs.lstat(filePath)
const file = await fs.readFile(filePath)
const ext = path.extname(filePath).slice(1) if (fileStats.isSymbolicLink()) {
return NextResponse.json({ error: 'Invalid avatar path' }, { status: 400 })
}
const realFilePath = await fs.realpath(filePath)
if (!isPathInsideBase(realAvatarsDir, realFilePath)) {
return NextResponse.json({ error: 'Invalid avatar path' }, { status: 400 })
}
const file = await fs.readFile(realFilePath)
return new NextResponse(file, { return new NextResponse(file, {
headers: { headers: {
'Content-Type': `image/${ext}`, 'Content-Type': AVATAR_CONTENT_TYPE[ext] ?? 'application/octet-stream',
'X-Content-Type-Options': 'nosniff',
}, },
}) })
} catch (error) { } catch (error) {
if (getErrorCode(error) === 'ENOENT') {
return NextResponse.json(
{ error: 'File not found' },
{ status: 404 }
)
}
console.error('Error reading avatar file:', error)
return NextResponse.json( return NextResponse.json(
{ error: 'File not found' }, { error: 'Internal server error' },
{ status: 404 } { status: 500 }
) )
} }
} }

View File

@@ -1,7 +1,11 @@
import { notFound } from 'next/navigation';
import { ReactNode } from "react"; import { ReactNode } from "react";
export default function Debug({children}: {children: ReactNode}) { export default function Debug({children}: {children: ReactNode}) {
if (process.env.NODE_ENV !== 'development') return <></> if (process.env.NODE_ENV !== 'development') {
notFound()
}
return ( return (
<div className="debug"> <div className="debug">
{children} {children}

View File

@@ -9,7 +9,7 @@ import { NextIntlClientProvider } from 'next-intl'
import { getLocale, getMessages } from 'next-intl/server' import { getLocale, getMessages } from 'next-intl/server'
import { DM_Sans } from 'next/font/google' import { DM_Sans } from 'next/font/google'
import { Suspense } from 'react' import { Suspense } from 'react'
import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersData, loadWishlistData } from './actions/data' import { loadCoinsData, loadHabitsData, loadServerSettings, loadSettings, loadUsersPublicData, loadWishlistData } from './actions/data'
import './globals.css' import './globals.css'
// Clean and contemporary // Clean and contemporary
@@ -40,7 +40,7 @@ export default async function RootLayout({
loadHabitsData(), loadHabitsData(),
loadCoinsData(), loadCoinsData(),
loadWishlistData(), loadWishlistData(),
loadUsersData(), loadUsersPublicData(),
loadServerSettings(), loadServerSettings(),
]) ])

View File

@@ -14,7 +14,7 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { updateLastNotificationReadTimestamp } from '@/app/actions/data'; import { updateLastNotificationReadTimestamp } from '@/app/actions/data';
import { d2t, getNow, t2d } from '@/lib/utils'; import { d2t, getNow, t2d } from '@/lib/utils';
import { User, CoinTransaction } from '@/lib/types'; import { CoinTransaction } from '@/lib/types';
export default function NotificationBell() { export default function NotificationBell() {
const t = useTranslations('NotificationBell'); const t = useTranslations('NotificationBell');
@@ -121,7 +121,7 @@ export default function NotificationBell() {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="p-0 w-80 md:w-96"> <DropdownMenuContent align="end" className="p-0 w-80 md:w-96">
<NotificationDropdown <NotificationDropdown
currentUser={currentUser as User | null} // Cast needed as as currentUser can be undefined currentUser={currentUser ?? null}
unreadNotifications={unreadNotifications} unreadNotifications={unreadNotifications}
displayedReadNotifications={displayedReadNotifications} displayedReadNotifications={displayedReadNotifications}
habitsData={habitsData} // Pass necessary data down habitsData={habitsData} // Pass necessary data down

View File

@@ -8,19 +8,19 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import { CoinTransaction, HabitsData, User, UserData, WishlistData } from '@/lib/types'; import { CoinTransaction, HabitsData, PublicUser, PublicUserData, WishlistData } from '@/lib/types';
import { t2d } from '@/lib/utils'; import { t2d } from '@/lib/utils';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Link from 'next/link'; import Link from 'next/link';
interface NotificationDropdownProps { interface NotificationDropdownProps {
currentUser: User | null; currentUser: PublicUser | null;
unreadNotifications: CoinTransaction[]; unreadNotifications: CoinTransaction[];
displayedReadNotifications: CoinTransaction[]; displayedReadNotifications: CoinTransaction[];
habitsData: HabitsData; habitsData: HabitsData;
wishlistData: WishlistData; wishlistData: WishlistData;
usersData: UserData; usersData: PublicUserData;
} }
// Helper function to get the name of the related item // Helper function to get the name of the related item
@@ -47,7 +47,7 @@ export default function NotificationDropdown({
const t = useTranslations('NotificationDropdown'); const t = useTranslations('NotificationDropdown');
// Helper function to generate notification message, now using t // Helper function to generate notification message, now using t
const getNotificationMessage = (tx: CoinTransaction, triggeringUser?: User, relatedItemName?: string): string => { const getNotificationMessage = (tx: CoinTransaction, triggeringUser?: PublicUser, relatedItemName?: string): string => {
const username = triggeringUser?.username || t('defaultUsername'); const username = triggeringUser?.username || t('defaultUsername');
const itemName = relatedItemName || t('defaultItemName'); const itemName = relatedItemName || t('defaultItemName');
switch (tx.type) { switch (tx.type) {

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { User } from '@/lib/types'; import { SafeUser } from '@/lib/types';
import { User as UserIcon } from 'lucide-react'; import { User as UserIcon } from 'lucide-react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState } from 'react'; import { useState } from 'react';
@@ -11,7 +11,7 @@ import { Input } from './ui/input';
import { Label } from './ui/label'; import { Label } from './ui/label';
interface PasswordEntryFormProps { interface PasswordEntryFormProps {
user: User; user: SafeUser;
onCancel: () => void; onCancel: () => void;
onSubmit: (password: string) => Promise<void>; onSubmit: (password: string) => Promise<void>;
error?: string; error?: string;
@@ -24,7 +24,7 @@ export default function PasswordEntryForm({
error error
}: PasswordEntryFormProps) { }: PasswordEntryFormProps) {
const t = useTranslations('PasswordEntryForm'); const t = useTranslations('PasswordEntryForm');
const hasPassword = !!user.password; const hasPassword = user.hasPassword ?? false;
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {

View File

@@ -59,7 +59,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
const [avatarPath, setAvatarPath] = useState(user?.avatarPath) const [avatarPath, setAvatarPath] = useState(user?.avatarPath)
const [username, setUsername] = useState(user?.username || ''); const [username, setUsername] = useState(user?.username || '');
const [password, setPassword] = useState<string | undefined>(''); const [password, setPassword] = useState<string | undefined>('');
const [disablePassword, setDisablePassword] = useState(user?.password === '' || serverSettings.isDemo); const [disablePassword, setDisablePassword] = useState(user ? !user.hasPassword : serverSettings.isDemo);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [avatarFile, setAvatarFile] = useState<File | null>(null); const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [isAdmin, setIsAdmin] = useState(user?.isAdmin || false); const [isAdmin, setIsAdmin] = useState(user?.isAdmin || false);
@@ -173,7 +173,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
avatarPath, avatarPath,
permissions, permissions,
isAdmin, isAdmin,
password: disablePassword ? '' : (password || u.password) // use the correct password to update atom hasPassword: disablePassword ? false : (password ? true : !!u.hasPassword)
} : u } : u
), ),
})); }));

View File

@@ -3,7 +3,7 @@
import { signIn } from '@/app/actions/user'; import { signIn } from '@/app/actions/user';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { currentUserAtom, usersAtom } from '@/lib/atoms'; import { currentUserAtom, usersAtom } from '@/lib/atoms';
import { SafeUser, User } from '@/lib/types'; import { SafeUser } from '@/lib/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Description } from '@radix-ui/react-dialog'; import { Description } from '@radix-ui/react-dialog';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
@@ -15,8 +15,6 @@ import UserForm from './UserForm';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
function UserCard({ function UserCard({
user, user,
onSelect, onSelect,
@@ -24,7 +22,7 @@ function UserCard({
showEdit, showEdit,
isCurrentUser, isCurrentUser,
}: { }: {
user: User, user: SafeUser,
onSelect: () => void, onSelect: () => void,
onEdit: () => void, onEdit: () => void,
showEdit: boolean, showEdit: boolean,
@@ -99,7 +97,7 @@ function UserSelectionView({
onEditUser, onEditUser,
onCreateUser, onCreateUser,
}: { }: {
users: User[], users: SafeUser[],
currentUserFromHook?: SafeUser, currentUserFromHook?: SafeUser,
onUserSelect: (userId: string) => void, onUserSelect: (userId: string) => void,
onEditUser: (userId: string) => void, onEditUser: (userId: string) => void,

View File

@@ -13,22 +13,16 @@ import { atomFamily, atomWithStorage } from "jotai/utils";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { import {
BrowserSettings, BrowserSettings,
CoinsData,
CompletionCache, CompletionCache,
getDefaultCoinsData, getDefaultCoinsData,
getDefaultHabitsData, getDefaultHabitsData,
getDefaultPublicUsersData,
getDefaultServerSettings, getDefaultServerSettings,
getDefaultSettings, getDefaultSettings,
getDefaultUsersData,
getDefaultWishlistData, getDefaultWishlistData,
Habit, Habit,
HabitsData,
PomodoroAtom, PomodoroAtom,
ServerSettings, UserId
Settings,
UserData,
UserId,
WishlistData
} from "./types"; } from "./types";
export const browserSettingsAtom = atomWithStorage('browserSettings', { export const browserSettingsAtom = atomWithStorage('browserSettings', {
@@ -37,13 +31,13 @@ export const browserSettingsAtom = atomWithStorage('browserSettings', {
expandedWishlist: false expandedWishlist: false
} as BrowserSettings) } as BrowserSettings)
export const usersAtom = atom(getDefaultUsersData<UserData>()) export const usersAtom = atom(getDefaultPublicUsersData())
export const currentUserIdAtom = atom<UserId | undefined>(undefined); export const currentUserIdAtom = atom<UserId | undefined>(undefined);
export const settingsAtom = atom(getDefaultSettings<Settings>()); export const settingsAtom = atom(getDefaultSettings());
export const habitsAtom = atom(getDefaultHabitsData<HabitsData>()); export const habitsAtom = atom(getDefaultHabitsData());
export const coinsAtom = atom(getDefaultCoinsData<CoinsData>()); export const coinsAtom = atom(getDefaultCoinsData());
export const wishlistAtom = atom(getDefaultWishlistData<WishlistData>()); export const wishlistAtom = atom(getDefaultWishlistData());
export const serverSettingsAtom = atom(getDefaultServerSettings<ServerSettings>()); export const serverSettingsAtom = atom(getDefaultServerSettings());
export const userSelectAtom = atom<boolean>(false) export const userSelectAtom = atom<boolean>(false)
export const aboutOpenAtom = atom<boolean>(false) export const aboutOpenAtom = atom<boolean>(false)

25
lib/avatar.ts Normal file
View File

@@ -0,0 +1,25 @@
export const ALLOWED_AVATAR_EXTENSIONS = new Set([
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.avif',
])
export const ALLOWED_AVATAR_MIME_TYPES = new Set([
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'image/avif',
])
export const AVATAR_CONTENT_TYPE: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.avif': 'image/avif',
}

View File

@@ -1,8 +1,19 @@
import { auth } from '@/auth' import { auth } from '@/auth'
import 'server-only' import 'server-only'
import { User, UserId } from './types' import { User, UserData, UserId, getDefaultUsersData } from './types'
import { loadUsersData } from '@/app/actions/data'
import { randomBytes, scryptSync } from 'crypto' import { randomBytes, scryptSync } from 'crypto'
import fs from 'fs/promises'
import path from 'path'
async function loadUsersDataFromStore(): Promise<UserData> {
try {
const filePath = path.join(process.cwd(), 'data', 'auth.json')
const data = await fs.readFile(filePath, 'utf8')
return JSON.parse(data) as UserData
} catch {
return getDefaultUsersData()
}
}
export async function getCurrentUserId(): Promise<UserId | undefined> { export async function getCurrentUserId(): Promise<UserId | undefined> {
const session = await auth() const session = await auth()
@@ -15,7 +26,7 @@ export async function getCurrentUser(): Promise<User | undefined> {
if (!currentUserId) { if (!currentUserId) {
return undefined return undefined
} }
const usersData = await loadUsersData() const usersData = await loadUsersDataFromStore()
return usersData.users.find((u) => u.id === currentUserId) return usersData.users.find((u) => u.id === currentUserId)
} }
export function saltAndHashPassword(password: string, salt?: string): string { export function saltAndHashPassword(password: string, salt?: string): string {

View File

@@ -27,6 +27,7 @@ export type SafeUser = SessionUser & {
avatarPath?: string avatarPath?: string
permissions?: Permission[] permissions?: Permission[]
isAdmin?: boolean isAdmin?: boolean
hasPassword?: boolean
} }
export type User = SafeUser & { export type User = SafeUser & {
@@ -34,6 +35,10 @@ export type User = SafeUser & {
lastNotificationReadTimestamp?: string // UTC ISO date string lastNotificationReadTimestamp?: string // UTC ISO date string
} }
export type PublicUser = Omit<User, 'password'> & {
hasPassword: boolean
}
export type Habit = { export type Habit = {
id: string id: string
name: string name: string
@@ -81,6 +86,10 @@ export interface UserData {
users: User[] users: User[]
} }
export interface PublicUserData {
users: PublicUser[]
}
export interface HabitsData { export interface HabitsData {
habits: Habit[]; habits: Habit[];
} }
@@ -98,7 +107,7 @@ export interface WishlistData {
} }
// Default value functions // Default value functions
export function getDefaultUsersData<UserData>(): UserData { export function getDefaultUsersData(): UserData {
return { return {
users: [ users: [
{ {
@@ -112,23 +121,30 @@ export function getDefaultUsersData<UserData>(): UserData {
} as UserData; } as UserData;
}; };
export function getDefaultHabitsData<HabitsData>(): HabitsData { export const getDefaultPublicUsersData = (): PublicUserData => ({
return { habits: [] } as HabitsData; users: getDefaultUsersData().users.map(({ password, ...user }) => ({
} ...user,
hasPassword: !!password,
})),
});
export const getDefaultHabitsData = (): HabitsData => ({
habits: []
});
export function getDefaultTasksData<TasksData>(): TasksData { export function getDefaultTasksData<TasksData>(): TasksData {
return { tasks: [] } as TasksData; return { tasks: [] } as TasksData;
}; };
export function getDefaultCoinsData<CoinsData>(): CoinsData { export function getDefaultCoinsData(): CoinsData {
return { balance: 0, transactions: [] } as CoinsData; return { balance: 0, transactions: [] } as CoinsData;
}; };
export function getDefaultWishlistData<WishlistData>(): WishlistData { export function getDefaultWishlistData(): WishlistData {
return { items: [] } as WishlistData; return { items: [] } as WishlistData;
} }
export function getDefaultSettings<Settings>(): Settings { export function getDefaultSettings(): Settings {
return { return {
ui: { ui: {
useNumberFormatting: true, useNumberFormatting: true,
@@ -144,12 +160,12 @@ export function getDefaultSettings<Settings>(): Settings {
} as Settings; } as Settings;
}; };
export function getDefaultServerSettings<ServerSettings>(): ServerSettings { export function getDefaultServerSettings(): ServerSettings {
return { isDemo: false } as ServerSettings; return { isDemo: false } as ServerSettings;
} }
// Map of data types to their default values // Map of data types to their default values
export const DATA_DEFAULTS: { [key: string]: <T>() => T } = { export const DATA_DEFAULTS = {
wishlist: getDefaultWishlistData, wishlist: getDefaultWishlistData,
habits: getDefaultHabitsData, habits: getDefaultHabitsData,
coins: getDefaultCoinsData, coins: getDefaultCoinsData,
@@ -195,7 +211,7 @@ export interface JotaiHydrateInitialValues {
coins: CoinsData; coins: CoinsData;
habits: HabitsData; habits: HabitsData;
wishlist: WishlistData; wishlist: WishlistData;
users: UserData; users: PublicUserData;
serverSettings: ServerSettings; serverSettings: ServerSettings;
} }

10
lib/user-sanitizer.ts Normal file
View File

@@ -0,0 +1,10 @@
import { PublicUserData, UserData } from './types'
export function sanitizeUserData(data: UserData): PublicUserData {
return {
users: data.users.map(({ password, ...user }) => ({
...user,
hasPassword: !!password,
})),
}
}

View File

@@ -942,11 +942,11 @@ describe('convertMachineReadableFrequencyToHumanReadable', () => {
}) })
describe('freshness utilities', () => { describe('freshness utilities', () => {
const mockSettings: Settings = getDefaultSettings<Settings>(); const mockSettings: Settings = getDefaultSettings();
const mockHabits: HabitsData = getDefaultHabitsData<HabitsData>(); const mockHabits: HabitsData = getDefaultHabitsData();
const mockCoins: CoinsData = getDefaultCoinsData<CoinsData>(); const mockCoins: CoinsData = getDefaultCoinsData();
const mockWishlist: WishlistData = getDefaultWishlistData<WishlistData>(); const mockWishlist: WishlistData = getDefaultWishlistData();
const mockUsers: UserData = getDefaultUsersData<UserData>(); const mockUsers: UserData = getDefaultUsersData();
// Add a user to mockUsers for more realistic testing // Add a user to mockUsers for more realistic testing
mockUsers.users.push({ mockUsers.users.push({
@@ -991,11 +991,11 @@ describe('freshness utilities', () => {
}); });
test('should handle empty data consistently', () => { test('should handle empty data consistently', () => {
const emptySettings = getDefaultSettings<Settings>(); const emptySettings = getDefaultSettings();
const emptyHabits = getDefaultHabitsData<HabitsData>(); const emptyHabits = getDefaultHabitsData();
const emptyCoins = getDefaultCoinsData<CoinsData>(); const emptyCoins = getDefaultCoinsData();
const emptyWishlist = getDefaultWishlistData<WishlistData>(); const emptyWishlist = getDefaultWishlistData();
const emptyUsers = getDefaultUsersData<UserData>(); const emptyUsers = getDefaultUsersData();
const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers); const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers); const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);

View File

@@ -1,5 +1,5 @@
import { toast } from "@/hooks/use-toast" import { toast } from "@/hooks/use-toast"
import { CoinsData, CoinTransaction, Freq, Habit, HabitsData, ParsedFrequencyResult, ParsedResultType, PomodoroAtom, SafeUser, Settings, User, UserData, WishlistData } from '@/lib/types' import { CoinsData, CoinTransaction, Freq, Habit, HabitsData, ParsedFrequencyResult, ParsedResultType, PomodoroAtom, PublicUserData, SafeUser, Settings, User, UserData, WishlistData } from '@/lib/types'
import * as chrono from 'chrono-node' import * as chrono from 'chrono-node'
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx"
import { DateTime, DateTimeFormatOptions } from "luxon" import { DateTime, DateTimeFormatOptions } from "luxon"
@@ -462,7 +462,7 @@ export function prepareDataForHashing(
habits: HabitsData, habits: HabitsData,
coins: CoinsData, coins: CoinsData,
wishlist: WishlistData, wishlist: WishlistData,
users: UserData users: UserData | PublicUserData
): string { ): string {
return JSON.stringify({ return JSON.stringify({
settings, settings,

13
middleware.ts Normal file
View File

@@ -0,0 +1,13 @@
import { NextResponse, type NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
if (process.env.NODE_ENV !== 'development' && request.nextUrl.pathname.startsWith('/debug')) {
return new NextResponse('Not Found', { status: 404 })
}
return NextResponse.next()
}
export const config = {
matcher: ['/debug/:path*'],
}

84
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "habittrove", "name": "habittrove",
"version": "0.2.30", "version": "0.2.31",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "habittrove", "name": "habittrove",
"version": "0.2.30", "version": "0.2.31",
"dependencies": { "dependencies": {
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
@@ -41,7 +41,7 @@
"linkify-react": "^4.2.0", "linkify-react": "^4.2.0",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"luxon": "^3.5.0", "luxon": "^3.5.0",
"next": "^v15.5.7", "next": "^15.5.10",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"next-intl": "^4.1.0", "next-intl": "^4.1.0",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
@@ -969,9 +969,9 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "15.5.7", "version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.12.tgz",
"integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==", "integrity": "sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
@@ -992,9 +992,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "15.5.7", "version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.12.tgz",
"integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", "integrity": "sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1008,9 +1008,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "15.5.7", "version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.12.tgz",
"integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", "integrity": "sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1024,9 +1024,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.5.7", "version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.12.tgz",
"integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", "integrity": "sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1040,9 +1040,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "15.5.7", "version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.12.tgz",
"integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", "integrity": "sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1056,9 +1056,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "15.5.7", "version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.12.tgz",
"integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", "integrity": "sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1072,9 +1072,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "15.5.7", "version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.12.tgz",
"integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", "integrity": "sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1088,9 +1088,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.5.7", "version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.12.tgz",
"integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", "integrity": "sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1104,9 +1104,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "15.5.7", "version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.12.tgz",
"integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", "integrity": "sha512-Z1Dh6lhFkxvBDH1FoW6OU/L6prYwPSlwjLiZkExIAh8fbP6iI/M7iGTQAJPYJ9YFlWobCZ1PHbchFhFYb2ADkw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -7767,13 +7767,13 @@
"dev": true "dev": true
}, },
"node_modules/next": { "node_modules/next": {
"version": "15.5.7", "version": "15.5.12",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz", "resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz",
"integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==", "integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@next/env": "15.5.7", "@next/env": "15.5.12",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31", "postcss": "8.4.31",
@@ -7786,14 +7786,14 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0" "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "15.5.7", "@next/swc-darwin-arm64": "15.5.12",
"@next/swc-darwin-x64": "15.5.7", "@next/swc-darwin-x64": "15.5.12",
"@next/swc-linux-arm64-gnu": "15.5.7", "@next/swc-linux-arm64-gnu": "15.5.12",
"@next/swc-linux-arm64-musl": "15.5.7", "@next/swc-linux-arm64-musl": "15.5.12",
"@next/swc-linux-x64-gnu": "15.5.7", "@next/swc-linux-x64-gnu": "15.5.12",
"@next/swc-linux-x64-musl": "15.5.7", "@next/swc-linux-x64-musl": "15.5.12",
"@next/swc-win32-arm64-msvc": "15.5.7", "@next/swc-win32-arm64-msvc": "15.5.12",
"@next/swc-win32-x64-msvc": "15.5.7", "@next/swc-win32-x64-msvc": "15.5.12",
"sharp": "^0.34.3" "sharp": "^0.34.3"
}, },
"peerDependencies": { "peerDependencies": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "habittrove", "name": "habittrove",
"version": "0.2.30", "version": "0.2.31",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
@@ -48,7 +48,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"luxon": "^3.5.0", "luxon": "^3.5.0",
"next": "^v15.5.7", "next": "^15.5.10",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"next-intl": "^4.1.0", "next-intl": "^4.1.0",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",