Compare commits

...

4 Commits

Author SHA1 Message Date
f177d6448d fix: add note to readme on how to fix permission errors 2026-03-09 13:12:09 +01:00
08dbca81b2 Merge Tag 'v0.2.31' 2026-03-09 12:33:06 +01:00
dohsimpson
5144d6106d fix: update package-lock.json for npm ci compatibility 2026-03-07 09:55:21 -05:00
Doh
62b5ea41b3 Release/v0.2.31 (#188) 2026-03-07 09:53:36 -05:00
23 changed files with 650 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

View File

@@ -181,3 +181,26 @@ This project is licensed under the GNU Affero General Public License v3.0 - see
## Support ## Support
If you encounter any issues or have questions, please file an issue on the GitHub repository. If you encounter any issues or have questions, please file an issue on the GitHub repository.
## Issues
### Missing Permissions
Especially when updating from older versions, it may be that the permissions used in the newer versions have never been set. This causes numerous `missing permissions` errors to appear. The solution is to update the `auth.json` in the `data` directory for each user to include the following json:
```json
"permissions": [{
"habit": {
"write": true,
"interact": true
},
"wishlist": {
"write": true,
"interact": true
},
"coins": {
"write": true,
"interact": true
}
}
```

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",