mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-03-09 20:09:50 +01:00
Release/v0.2.31 (#188)
This commit is contained in:
14
CHANGELOG.md
14
CHANGELOG.md
@@ -1,5 +1,19 @@
|
||||
# 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
|
||||
|
||||
### Fixed
|
||||
|
||||
88
app/actions/data.test.ts
Normal file
88
app/actions/data.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
UserData,
|
||||
getDefaultUsersData,
|
||||
User,
|
||||
PublicUser,
|
||||
PublicUserData,
|
||||
getDefaultWishlistData,
|
||||
getDefaultHabitsData,
|
||||
getDefaultCoinsData,
|
||||
@@ -29,13 +31,14 @@ import { signInSchema } from '@/lib/zod';
|
||||
import _ from 'lodash';
|
||||
import { getCurrentUser } from '@/lib/server-helpers'
|
||||
import { prepareDataForHashing, generateCryptoHash } from '@/lib/utils';
|
||||
import { sanitizeUserData } from '@/lib/user-sanitizer'
|
||||
import { ALLOWED_AVATAR_EXTENSIONS, ALLOWED_AVATAR_MIME_TYPES } from '@/lib/avatar'
|
||||
|
||||
|
||||
|
||||
type ResourceType = 'habit' | 'wishlist' | 'coins'
|
||||
type ActionType = 'write' | 'interact'
|
||||
|
||||
|
||||
async function verifyPermission(
|
||||
resource: ResourceType,
|
||||
action: ActionType
|
||||
@@ -66,22 +69,28 @@ async function ensureDataDir() {
|
||||
|
||||
// --- Backup Debug Action ---
|
||||
export async function triggerManualBackup(): Promise<{ success: boolean; message: string }> {
|
||||
// Optional: Add extra permission check if needed for debug actions
|
||||
// const user = await getCurrentUser();
|
||||
// if (!user?.isAdmin) {
|
||||
// return { success: false, message: "Permission denied." };
|
||||
// }
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
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 {
|
||||
// Import runBackup locally to avoid potential circular dependencies if moved
|
||||
const { runBackup } = await import('@/lib/backup');
|
||||
await runBackup();
|
||||
console.log("Manual backup trigger completed successfully.");
|
||||
return { success: true, message: "Backup process completed successfully." };
|
||||
const { runBackup } = await import('@/lib/backup')
|
||||
await runBackup()
|
||||
console.log('Manual backup trigger completed successfully.')
|
||||
return { success: true, message: 'Backup process completed successfully.' }
|
||||
} catch (error) {
|
||||
console.error("Manual backup trigger failed:", error);
|
||||
return { success: false, message: `Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}` };
|
||||
console.error('Manual backup trigger failed:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: `Backup failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +142,7 @@ async function calculateServerFreshnessToken(): Promise<string | null> {
|
||||
const habits = await loadHabitsData();
|
||||
const coins = await loadCoinsData();
|
||||
const wishlist = await loadWishlistData();
|
||||
const users = await loadUsersData();
|
||||
const users = await loadUsersPublicData();
|
||||
|
||||
const dataString = prepareDataForHashing(
|
||||
settings,
|
||||
@@ -362,13 +371,22 @@ export async function uploadAvatar(formData: FormData): Promise<string> {
|
||||
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
|
||||
const avatarsDir = path.join(process.cwd(), 'data', 'avatars')
|
||||
await fs.mkdir(avatarsDir, { recursive: true })
|
||||
|
||||
// Generate unique filename
|
||||
const ext = file.name.split('.').pop()
|
||||
const filename = `${Date.now()}.${ext}`
|
||||
const filename = `${Date.now()}-${uuid()}${ext}`
|
||||
const filePath = path.join(avatarsDir, filename)
|
||||
|
||||
// Save file
|
||||
@@ -389,7 +407,7 @@ export async function getChangelog(): Promise<string> {
|
||||
}
|
||||
|
||||
// user logic
|
||||
export async function loadUsersData(): Promise<UserData> {
|
||||
async function loadUsersData(): Promise<UserData> {
|
||||
try {
|
||||
return await loadData<UserData>('auth')
|
||||
} catch {
|
||||
@@ -397,6 +415,11 @@ export async function loadUsersData(): Promise<UserData> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadUsersPublicData(): Promise<PublicUserData> {
|
||||
const data = await loadUsersData()
|
||||
return sanitizeUserData(data)
|
||||
}
|
||||
|
||||
export async function saveUsersData(data: UserData): Promise<void> {
|
||||
return saveData('auth', data)
|
||||
}
|
||||
@@ -414,7 +437,7 @@ export async function getUser(username: string, plainTextPassword?: string): Pro
|
||||
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;
|
||||
let password = formData.get('password') as string | undefined;
|
||||
const avatarPath = formData.get('avatarPath') as string;
|
||||
@@ -451,10 +474,10 @@ export async function createUser(formData: FormData): Promise<User> {
|
||||
};
|
||||
|
||||
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 userIndex = data.users.findIndex(user => user.id === userId)
|
||||
|
||||
@@ -486,7 +509,7 @@ export async function updateUser(userId: string, updates: Partial<Omit<User, 'id
|
||||
}
|
||||
|
||||
await saveUsersData(newData)
|
||||
return updatedUser
|
||||
return sanitizeUserData({ users: [updatedUser] }).users[0]
|
||||
}
|
||||
|
||||
export async function updateUserPassword(userId: string, newPassword?: string): Promise<void> {
|
||||
|
||||
197
app/api/avatars/[...path]/route.test.ts
Normal file
197
app/api/avatars/[...path]/route.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
@@ -1,26 +1,109 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import fs from 'fs/promises'
|
||||
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(
|
||||
request: Request,
|
||||
_request: Request,
|
||||
{ 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 {
|
||||
const { path: pathSegments } = await Promise.resolve(params)
|
||||
const filePath = path.join(process.cwd(), 'data', 'avatars', ...(pathSegments || []))
|
||||
const file = await fs.readFile(filePath)
|
||||
const ext = path.extname(filePath).slice(1)
|
||||
const realAvatarsDir = await fs.realpath(avatarsDir)
|
||||
const fileStats = await fs.lstat(filePath)
|
||||
|
||||
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, {
|
||||
headers: {
|
||||
'Content-Type': `image/${ext}`,
|
||||
'Content-Type': AVATAR_CONTENT_TYPE[ext] ?? 'application/octet-stream',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
})
|
||||
} 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(
|
||||
{ error: 'File not found' },
|
||||
{ status: 404 }
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { ReactNode } from "react";
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export default function Debug({children}: {children: ReactNode}) {
|
||||
if (process.env.NODE_ENV !== 'development') return null
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="debug">
|
||||
{children}
|
||||
|
||||
@@ -2,7 +2,7 @@ import './globals.css'
|
||||
import { DM_Sans } from 'next/font/google'
|
||||
import { JotaiProvider } from '@/components/jotai-providers'
|
||||
import { JotaiHydrate } from '@/components/jotai-hydrate'
|
||||
import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData, loadUsersData, loadServerSettings } from './actions/data'
|
||||
import { loadSettings, loadHabitsData, loadCoinsData, loadWishlistData, loadUsersPublicData, loadServerSettings } from './actions/data'
|
||||
import Layout from '@/components/Layout'
|
||||
import { Toaster } from '@/components/ui/toaster'
|
||||
import { ThemeProvider } from "@/components/theme-provider"
|
||||
@@ -49,7 +49,7 @@ export default async function RootLayout({
|
||||
loadHabitsData(),
|
||||
loadCoinsData(),
|
||||
loadWishlistData(),
|
||||
loadUsersData(),
|
||||
loadUsersPublicData(),
|
||||
loadServerSettings(),
|
||||
])
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { updateLastNotificationReadTimestamp } from '@/app/actions/data';
|
||||
import { d2t, getNow, t2d } from '@/lib/utils';
|
||||
import { User, CoinTransaction } from '@/lib/types';
|
||||
import { CoinTransaction } from '@/lib/types';
|
||||
|
||||
export default function NotificationBell() {
|
||||
const t = useTranslations('NotificationBell');
|
||||
@@ -121,7 +121,7 @@ export default function NotificationBell() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="p-0 w-80 md:w-96">
|
||||
<NotificationDropdown
|
||||
currentUser={currentUser as User | null} // Cast needed as as currentUser can be undefined
|
||||
currentUser={currentUser ?? null}
|
||||
unreadNotifications={unreadNotifications}
|
||||
displayedReadNotifications={displayedReadNotifications}
|
||||
habitsData={habitsData} // Pass necessary data down
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { HabitsData, WishlistData, UserData, User, CoinTransaction } from '@/lib/types';
|
||||
import { HabitsData, WishlistData, PublicUserData, PublicUser, CoinTransaction } from '@/lib/types';
|
||||
import { t2d } from '@/lib/utils';
|
||||
import Link from 'next/link';
|
||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||
@@ -16,12 +16,12 @@ import {
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
interface NotificationDropdownProps {
|
||||
currentUser: User | null;
|
||||
currentUser: PublicUser | null;
|
||||
unreadNotifications: CoinTransaction[];
|
||||
displayedReadNotifications: CoinTransaction[];
|
||||
habitsData: HabitsData;
|
||||
wishlistData: WishlistData;
|
||||
usersData: UserData;
|
||||
usersData: PublicUserData;
|
||||
}
|
||||
|
||||
// Helper function to get the name of the related item
|
||||
@@ -48,7 +48,7 @@ export default function NotificationDropdown({
|
||||
const t = useTranslations('NotificationDropdown');
|
||||
|
||||
// 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 itemName = relatedItemName || t('defaultItemName');
|
||||
switch (tx.type) {
|
||||
|
||||
@@ -5,13 +5,13 @@ import { Input } from './ui/input';
|
||||
import { Button } from './ui/button';
|
||||
import { Label } from './ui/label';
|
||||
import { User as UserIcon } from 'lucide-react';
|
||||
import { User } from '@/lib/types';
|
||||
import { SafeUser } from '@/lib/types';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface PasswordEntryFormProps {
|
||||
user: User;
|
||||
user: SafeUser;
|
||||
onCancel: () => void;
|
||||
onSubmit: (password: string) => Promise<void>;
|
||||
error?: string;
|
||||
@@ -24,7 +24,7 @@ export default function PasswordEntryForm({
|
||||
error
|
||||
}: PasswordEntryFormProps) {
|
||||
const t = useTranslations('PasswordEntryForm');
|
||||
const hasPassword = !!user.password;
|
||||
const hasPassword = user.hasPassword ?? false;
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
||||
const [avatarPath, setAvatarPath] = useState(user?.avatarPath)
|
||||
const [username, setUsername] = useState(user?.username || '');
|
||||
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 [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(user?.isAdmin || false);
|
||||
@@ -173,7 +173,7 @@ export default function UserForm({ userId, onCancel, onSuccess }: UserFormProps)
|
||||
avatarPath,
|
||||
permissions,
|
||||
isAdmin,
|
||||
password: disablePassword ? '' : (password || u.password) // use the correct password to update atom
|
||||
hasPassword: disablePassword ? false : (password ? true : !!u.hasPassword)
|
||||
} : u
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -14,7 +14,7 @@ import { signIn } from '@/app/actions/user';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Description } from '@radix-ui/react-dialog';
|
||||
import { SafeUser, User } from '@/lib/types';
|
||||
import { SafeUser } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ function UserCard({
|
||||
showEdit,
|
||||
isCurrentUser,
|
||||
}: {
|
||||
user: User,
|
||||
user: SafeUser,
|
||||
onSelect: () => void,
|
||||
onEdit: () => void,
|
||||
showEdit: boolean,
|
||||
@@ -100,7 +100,7 @@ function UserSelectionView({
|
||||
onEditUser,
|
||||
onCreateUser,
|
||||
}: {
|
||||
users: User[],
|
||||
users: SafeUser[],
|
||||
currentUserFromHook?: SafeUser,
|
||||
onUserSelect: (userId: string) => void,
|
||||
onEditUser: (userId: string) => void,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getDefaultWishlistData,
|
||||
Habit,
|
||||
ViewType,
|
||||
getDefaultUsersData,
|
||||
getDefaultPublicUsersData,
|
||||
CompletionCache,
|
||||
getDefaultServerSettings,
|
||||
UserId,
|
||||
@@ -42,7 +42,7 @@ export const browserSettingsAtom = atomWithStorage('browserSettings', {
|
||||
expandedWishlist: false
|
||||
} as BrowserSettings)
|
||||
|
||||
export const usersAtom = atom(getDefaultUsersData())
|
||||
export const usersAtom = atom(getDefaultPublicUsersData())
|
||||
export const settingsAtom = atom(getDefaultSettings());
|
||||
export const habitsAtom = atom(getDefaultHabitsData());
|
||||
export const coinsAtom = atom(getDefaultCoinsData());
|
||||
|
||||
25
lib/avatar.ts
Normal file
25
lib/avatar.ts
Normal 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',
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
import { auth } from '@/auth'
|
||||
import 'server-only'
|
||||
import { User, UserId } from './types'
|
||||
import { loadUsersData } from '@/app/actions/data'
|
||||
import { User, UserData, UserId, getDefaultUsersData } from './types'
|
||||
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> {
|
||||
const session = await auth()
|
||||
@@ -15,7 +26,7 @@ export async function getCurrentUser(): Promise<User | undefined> {
|
||||
if (!currentUserId) {
|
||||
return undefined
|
||||
}
|
||||
const usersData = await loadUsersData()
|
||||
const usersData = await loadUsersDataFromStore()
|
||||
return usersData.users.find((u) => u.id === currentUserId)
|
||||
}
|
||||
export function saltAndHashPassword(password: string, salt?: string): string {
|
||||
|
||||
18
lib/types.ts
18
lib/types.ts
@@ -28,6 +28,7 @@ export type SafeUser = SessionUser & {
|
||||
avatarPath?: string
|
||||
permissions?: Permission[]
|
||||
isAdmin?: boolean
|
||||
hasPassword?: boolean
|
||||
}
|
||||
|
||||
export type User = SafeUser & {
|
||||
@@ -35,6 +36,10 @@ export type User = SafeUser & {
|
||||
lastNotificationReadTimestamp?: string // UTC ISO date string
|
||||
}
|
||||
|
||||
export type PublicUser = Omit<User, 'password'> & {
|
||||
hasPassword: boolean
|
||||
}
|
||||
|
||||
export type Habit = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -82,6 +87,10 @@ export interface UserData {
|
||||
users: User[]
|
||||
}
|
||||
|
||||
export interface PublicUserData {
|
||||
users: PublicUser[]
|
||||
}
|
||||
|
||||
export interface HabitsData {
|
||||
habits: Habit[];
|
||||
}
|
||||
@@ -111,6 +120,13 @@ export const getDefaultUsersData = (): UserData => ({
|
||||
]
|
||||
});
|
||||
|
||||
export const getDefaultPublicUsersData = (): PublicUserData => ({
|
||||
users: getDefaultUsersData().users.map(({ password, ...user }) => ({
|
||||
...user,
|
||||
hasPassword: !!password,
|
||||
})),
|
||||
});
|
||||
|
||||
export const getDefaultHabitsData = (): HabitsData => ({
|
||||
habits: []
|
||||
});
|
||||
@@ -192,7 +208,7 @@ export interface JotaiHydrateInitialValues {
|
||||
coins: CoinsData;
|
||||
habits: HabitsData;
|
||||
wishlist: WishlistData;
|
||||
users: UserData;
|
||||
users: PublicUserData;
|
||||
serverSettings: ServerSettings;
|
||||
}
|
||||
|
||||
|
||||
10
lib/user-sanitizer.ts
Normal file
10
lib/user-sanitizer.ts
Normal 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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { DateTime, DateTimeFormatOptions } from "luxon"
|
||||
import { datetime, RRule } from 'rrule'
|
||||
import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType, User, Settings, HabitsData, CoinsData, WishlistData, UserData } from '@/lib/types'
|
||||
import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType, User, Settings, HabitsData, CoinsData, WishlistData, UserData, PublicUserData } from '@/lib/types'
|
||||
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
|
||||
import * as chrono from 'chrono-node'
|
||||
import _ from "lodash"
|
||||
@@ -497,7 +497,7 @@ export function prepareDataForHashing(
|
||||
habits: HabitsData,
|
||||
coins: CoinsData,
|
||||
wishlist: WishlistData,
|
||||
users: UserData
|
||||
users: UserData | PublicUserData
|
||||
): string {
|
||||
// Combine all data into a single object.
|
||||
// The order of keys in this object itself doesn't matter due to stableStringify,
|
||||
|
||||
@@ -70,6 +70,14 @@
|
||||
"CoinBalance": {
|
||||
"coinBalanceTitle": "Coin Balance"
|
||||
},
|
||||
"DrawingModal": {
|
||||
"colorLabel": "Color",
|
||||
"thicknessLabel": "Thickness",
|
||||
"undoButton": "Undo",
|
||||
"clearButton": "Clear",
|
||||
"saveDrawingButton": "Save Drawing",
|
||||
"cancelButton": "Cancel"
|
||||
},
|
||||
"AddEditHabitModal": {
|
||||
"editTaskTitle": "Edit Task",
|
||||
"editHabitTitle": "Edit Habit",
|
||||
|
||||
13
middleware.ts
Normal file
13
middleware.ts
Normal 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*'],
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "habittrove",
|
||||
"version": "0.2.30",
|
||||
"version": "0.2.31",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -48,7 +48,7 @@
|
||||
"linkify-react": "^4.2.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"luxon": "^3.5.0",
|
||||
"next": "^v15.5.7",
|
||||
"next": "^15.5.10",
|
||||
"next-auth": "^5.0.0-beta.25",
|
||||
"next-intl": "^4.1.0",
|
||||
"next-themes": "^0.4.4",
|
||||
|
||||
Reference in New Issue
Block a user