Merge Tag 'v0.2.31'

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

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'
import { ALLOWED_AVATAR_EXTENSIONS, ALLOWED_AVATAR_MIME_TYPES } from '@/lib/avatar';
import { getCurrentUser, saltAndHashPassword, verifyPassword } from "@/lib/server-helpers";
import {
CoinsData,
@@ -13,6 +14,8 @@ import {
getDefaultWishlistData,
HabitsData,
Permission,
PublicUser,
PublicUserData,
ServerSettings,
Settings,
TransactionType,
@@ -21,8 +24,10 @@ import {
WishlistData,
WishlistItemType
} from '@/lib/types';
import { sanitizeUserData } from '@/lib/user-sanitizer';
import { d2t, generateCryptoHash, getNow, prepareDataForHashing } from '@/lib/utils';
import { signInSchema } from '@/lib/zod';
import { randomUUID } from "crypto";
import fs from 'fs/promises';
import _ from 'lodash';
import path from 'path';
@@ -32,6 +37,20 @@ import path from 'path';
type ResourceType = 'habit' | 'wishlist' | 'coins'
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 {
return DATA_DEFAULTS[type]() as T;
@@ -48,22 +67,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'}`,
}
}
}
@@ -136,7 +161,7 @@ async function calculateServerFreshnessToken(): Promise<string | null> {
// Wishlist specific functions
export async function loadWishlistData(): Promise<WishlistData> {
const user = await getCurrentUser()
if (!user) return getDefaultWishlistData<WishlistData>()
if (!user) return getDefaultWishlistData()
const data = await loadData<WishlistData>('wishlist')
return {
@@ -173,7 +198,7 @@ export async function saveWishlistItems(data: WishlistData): Promise<void> {
// Habits specific functions
export async function loadHabitsData(): Promise<HabitsData> {
const user = await getCurrentUser()
if (!user) return getDefaultHabitsData<HabitsData>()
if (!user) return getDefaultHabitsData()
const data = await loadData<HabitsData>('habits')
return {
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> {
try {
const user = await getCurrentUser()
if (!user) return getDefaultCoinsData<CoinsData>()
if (!user) return getDefaultCoinsData()
const data = await loadData<CoinsData>('coins')
return {
...data,
transactions: user.isAdmin ? data.transactions : data.transactions.filter(x => x.userId === user.id)
}
} catch {
return getDefaultCoinsData<CoinsData>()
return getDefaultCoinsData()
}
}
@@ -278,7 +303,7 @@ export async function addCoins({
}
export async function loadSettings(): Promise<Settings> {
const defaultSettings = getDefaultSettings<Settings>()
const defaultSettings = getDefaultSettings()
try {
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')
}
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()}-${randomUUID()}${ext}`
const filePath = path.join(avatarsDir, filename)
// Save file
@@ -366,14 +400,19 @@ 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 {
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> {
return saveData('auth', data)
}
@@ -391,7 +430,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;
@@ -428,10 +467,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)
@@ -463,7 +502,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> {