diff --git a/CHANGELOG.md b/CHANGELOG.md index e937402..3f81433 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Version 0.2.28 + +### Added + +* Server permission checking system to validate data directory access on startup +* Permission error display with troubleshooting guidance and recheck functionality + ## Version 0.2.27 ### Fixed diff --git a/components/Layout.tsx b/components/Layout.tsx index ae73a28..9512db2 100644 --- a/components/Layout.tsx +++ b/components/Layout.tsx @@ -1,18 +1,21 @@ import ClientWrapper from './ClientWrapper' import Header from './Header' import Navigation from './Navigation' +import PermissionError from './PermissionError' export default function Layout({ children }: { children: React.ReactNode }) { return (
-
-
- -
-
- {/* responsive container (optimized for mobile) */} -
+ +
+
+ +
+
+ {/* responsive container (optimized for mobile) */} +
+ {children}
diff --git a/components/PermissionError.tsx b/components/PermissionError.tsx new file mode 100644 index 0000000..a1bc83a --- /dev/null +++ b/components/PermissionError.tsx @@ -0,0 +1,43 @@ +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { AlertTriangle } from 'lucide-react' +import { checkStartupPermissions } from '@/lib/startup-checks' +import RecheckButton from './RecheckButton' + +export default async function PermissionError() { + const permissionResult = await checkStartupPermissions() + + // If everything is fine, render nothing + if (permissionResult.success) { + return null + } + + // Get error message + const getErrorMessage = () => { + return permissionResult.error?.message || 'Unknown permission error occurred.' + } + + return ( + + + Permission Error + +
+ + {getErrorMessage()}{" "} + + Troubleshooting Guide + + +
+ +
+
+
+
+ ) +} diff --git a/components/RecheckButton.tsx b/components/RecheckButton.tsx new file mode 100644 index 0000000..da30091 --- /dev/null +++ b/components/RecheckButton.tsx @@ -0,0 +1,22 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { RefreshCw } from 'lucide-react' + +export default function RecheckButton() { + const handleRecheck = () => { + window.location.reload() + } + + return ( + + ) +} \ No newline at end of file diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..896d1ee --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,61 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + warning: + "border-yellow-200 bg-yellow-50 text-yellow-800 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-200 [&>svg]:text-yellow-600 dark:[&>svg]:text-yellow-400", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } \ No newline at end of file diff --git a/lib/startup-checks.test.ts b/lib/startup-checks.test.ts new file mode 100644 index 0000000..ace0570 --- /dev/null +++ b/lib/startup-checks.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test, beforeEach, mock } from 'bun:test' +import { checkStartupPermissions } from './startup-checks' + +// Mock the fs promises module +const mockStat = mock() +const mockWriteFile = mock() +const mockReadFile = mock() +const mockUnlink = mock() + +mock.module('fs', () => ({ + promises: { + stat: mockStat, + writeFile: mockWriteFile, + readFile: mockReadFile, + unlink: mockUnlink, + }, +})) + +describe('checkStartupPermissions', () => { + beforeEach(() => { + // Reset all mocks before each test + mockStat.mockReset() + mockWriteFile.mockReset() + mockReadFile.mockReset() + mockUnlink.mockReset() + }) + + test('should return success when directory exists and has proper permissions', async () => { + // Mock successful directory stat + mockStat.mockResolvedValue({ + isDirectory: () => true, + }) + + // Mock successful file operations + mockWriteFile.mockResolvedValue(undefined) + mockReadFile.mockResolvedValue('permission-test') + mockUnlink.mockResolvedValue(undefined) + + const result = await checkStartupPermissions() + + expect(result).toEqual({ success: true }) + expect(mockStat).toHaveBeenCalledWith('data') + expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test') + expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8') + expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test') + }) + + test('should return error when directory does not exist', async () => { + mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory')) + + const result = await checkStartupPermissions() + + expect(result).toEqual({ + success: false, + error: { + path: 'data', + message: 'Data directory \'data\' does not exist or is not accessible. Check volume mounts and permissions.', + type: 'writable_data_dir' + } + }) + expect(mockStat).toHaveBeenCalledWith('data') + expect(mockWriteFile).not.toHaveBeenCalled() + }) + + test('should return error when path exists but is not a directory', async () => { + // Mock path exists but is a file, not directory + mockStat.mockResolvedValue({ + isDirectory: () => false, + }) + + const result = await checkStartupPermissions() + + expect(result).toEqual({ + success: false, + error: { + path: 'data', + message: 'Path \'data\' exists but is not a directory. Please ensure the data directory is properly configured.', + type: 'writable_data_dir' + } + }) + expect(mockStat).toHaveBeenCalledWith('data') + expect(mockWriteFile).not.toHaveBeenCalled() + }) + + test('should return error when write permission fails', async () => { + // Mock successful directory stat + mockStat.mockResolvedValue({ + isDirectory: () => true, + }) + + // Mock write failure + mockWriteFile.mockRejectedValue(new Error('EACCES: permission denied')) + + const result = await checkStartupPermissions() + + expect(result).toEqual({ + success: false, + error: { + path: 'data', + message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.', + type: 'writable_data_dir' + } + }) + expect(mockStat).toHaveBeenCalledWith('data') + expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test') + expect(mockReadFile).not.toHaveBeenCalled() + }) + + test('should return error when read permission fails', async () => { + // Mock successful directory stat and write + mockStat.mockResolvedValue({ + isDirectory: () => true, + }) + mockWriteFile.mockResolvedValue(undefined) + + // Mock read failure + mockReadFile.mockRejectedValue(new Error('EACCES: permission denied')) + + const result = await checkStartupPermissions() + + expect(result).toEqual({ + success: false, + error: { + path: 'data', + message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.', + type: 'writable_data_dir' + } + }) + expect(mockStat).toHaveBeenCalledWith('data') + expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test') + expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8') + }) + + test('should return error when read content does not match written content', async () => { + // Mock successful directory stat and write + mockStat.mockResolvedValue({ + isDirectory: () => true, + }) + mockWriteFile.mockResolvedValue(undefined) + + // Mock read with different content + mockReadFile.mockResolvedValue('different-content') + + const result = await checkStartupPermissions() + + expect(result).toEqual({ + success: false, + error: { + path: 'data', + message: 'Data integrity check failed in \'data\'. File system may be corrupted or have inconsistent behavior.', + type: 'writable_data_dir' + } + }) + expect(mockStat).toHaveBeenCalledWith('data') + expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test') + expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8') + expect(mockUnlink).not.toHaveBeenCalled() + }) + + test('should return error when cleanup (unlink) fails', async () => { + // Mock successful directory stat, write, and read + mockStat.mockResolvedValue({ + isDirectory: () => true, + }) + mockWriteFile.mockResolvedValue(undefined) + mockReadFile.mockResolvedValue('permission-test') + + // Mock cleanup failure + mockUnlink.mockRejectedValue(new Error('EACCES: permission denied')) + + const result = await checkStartupPermissions() + + // Should return error since cleanup failed and is part of the try-catch block + expect(result).toEqual({ + success: false, + error: { + path: 'data', + message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.', + type: 'writable_data_dir' + } + }) + expect(mockStat).toHaveBeenCalledWith('data') + expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test') + expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8') + expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test') + }) + + test('should use correct file paths', async () => { + // Mock successful operations + mockStat.mockResolvedValue({ + isDirectory: () => true, + }) + mockWriteFile.mockResolvedValue(undefined) + mockReadFile.mockResolvedValue('permission-test') + mockUnlink.mockResolvedValue(undefined) + + await checkStartupPermissions() + + // Verify the correct paths are used + expect(mockStat).toHaveBeenCalledWith('data') + expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test') + expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8') + expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test') + }) +}) \ No newline at end of file diff --git a/lib/startup-checks.ts b/lib/startup-checks.ts new file mode 100644 index 0000000..b6d9e38 --- /dev/null +++ b/lib/startup-checks.ts @@ -0,0 +1,73 @@ +import { promises as fs } from 'fs' +import { join } from 'path' + +const DEFAULT_DATA_DIR = 'data' + +/** + * Checks startup permissions for the data directory + */ +interface StartupPermissionResult { + success: boolean + error?: { path: string; message: string; type?: 'writable_data_dir' } +} + +export async function checkStartupPermissions(): Promise { + const dirPath = DEFAULT_DATA_DIR + + // Check if directory exists and is accessible + try { + const stats = await fs.stat(dirPath) + if (!stats.isDirectory()) { + return { + success: false, + error: { + path: dirPath, + message: `Path '${dirPath}' exists but is not a directory. Please ensure the data directory is properly configured.`, + type: 'writable_data_dir' + } + } + } + } catch (statError) { + return { + success: false, + error: { + path: dirPath, + message: `Data directory '${dirPath}' does not exist or is not accessible. Check volume mounts and permissions.`, + type: 'writable_data_dir' + } + } + } + + // Test read/write permissions with a temporary file + const testFilePath = join(dirPath, '.habittrove-permission-test') + const testContent = 'permission-test' + + try { + await fs.writeFile(testFilePath, testContent) + const readContent = await fs.readFile(testFilePath, 'utf8') + + if (readContent !== testContent) { + return { + success: false, + error: { + path: dirPath, + message: `Data integrity check failed in '${dirPath}'. File system may be corrupted or have inconsistent behavior.`, + type: 'writable_data_dir' + } + } + } + + await fs.unlink(testFilePath) + return { success: true } + + } catch (rwError) { + return { + success: false, + error: { + path: dirPath, + message: `Insufficient read/write permissions for data directory '${dirPath}'. Check file permissions and ownership.`, + type: 'writable_data_dir' + } + } + } +} diff --git a/package.json b/package.json index b67b56a..ca02a86 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "habittrove", - "version": "0.2.27", + "version": "0.2.28", "private": true, "scripts": { "dev": "next dev --turbopack",