Release/v0.2.31 (#188)

This commit is contained in:
Doh
2026-03-07 09:53:36 -05:00
committed by GitHub
parent b01c5dcd6a
commit 62b5ea41b3
21 changed files with 548 additions and 56 deletions

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

@@ -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> {

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 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 }
)
}
}

View File

@@ -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}

View File

@@ -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(),
])