diff --git a/components/expenses/BudgetModal.tsx b/components/expenses/BudgetModal.tsx index f731a1a..8a0fada 100644 --- a/components/expenses/BudgetModal.tsx +++ b/components/expenses/BudgetModal.tsx @@ -9,26 +9,33 @@ interface BudgetModalProps { onClickSave: (newBudgetName: string, newBudgetMonth: string, newBudgetValue: number) => Promise; onClickDelete: () => Promise; onClose: () => void; + shouldResetForm: boolean; } export default function BudgetModal( - { isOpen, budget, onClickSave, onClickDelete, onClose }: BudgetModalProps, + { isOpen, budget, onClickSave, onClickDelete, onClose, shouldResetForm }: BudgetModalProps, ) { const newBudgetName = useSignal(budget?.name ?? ''); const newBudgetMonth = useSignal(budget?.month ?? new Date().toISOString().substring(0, 10)); const newBudgetValue = useSignal(budget?.value ?? 100); + const resetForm = () => { + newBudgetName.value = ''; + newBudgetMonth.value = new Date().toISOString().substring(0, 10); + newBudgetValue.value = 100; + }; + useEffect(() => { if (budget) { newBudgetName.value = budget.name; newBudgetMonth.value = `${budget.month}-15`; newBudgetValue.value = budget.value; - } else { - newBudgetName.value = ''; - newBudgetMonth.value = new Date().toISOString().substring(0, 10); - newBudgetValue.value = 100; } - }, [budget]); + + if (shouldResetForm) { + resetForm(); + } + }, [budget, shouldResetForm]); return ( <> diff --git a/components/expenses/ExpenseModal.tsx b/components/expenses/ExpenseModal.tsx index bb76a14..7b9761b 100644 --- a/components/expenses/ExpenseModal.tsx +++ b/components/expenses/ExpenseModal.tsx @@ -21,10 +21,11 @@ interface ExpenseModalProps { ) => Promise; onClickDelete: () => Promise; onClose: () => void; + shouldResetForm: boolean; } export default function ExpenseModal( - { isOpen, expense, budgets, onClickSave, onClickDelete, onClose }: ExpenseModalProps, + { isOpen, expense, budgets, onClickSave, onClickDelete, onClose, shouldResetForm }: ExpenseModalProps, ) { const newExpenseCost = useSignal(expense?.cost ?? ''); const newExpenseDescription = useSignal(expense?.description ?? ''); @@ -34,6 +35,14 @@ export default function ExpenseModal( const suggestions = useSignal([]); const showSuggestions = useSignal(false); + const resetForm = () => { + newExpenseCost.value = ''; + newExpenseDescription.value = ''; + newExpenseBudget.value = 'Misc'; + newExpenseDate.value = ''; + newExpenseIsRecurring.value = false; + }; + useEffect(() => { if (expense) { newExpenseCost.value = expense.cost; @@ -42,15 +51,12 @@ export default function ExpenseModal( newExpenseDate.value = expense.date; newExpenseIsRecurring.value = expense.is_recurring; showSuggestions.value = false; - } else { - newExpenseCost.value = ''; - newExpenseDescription.value = ''; - newExpenseBudget.value = 'Misc'; - newExpenseDate.value = ''; - newExpenseIsRecurring.value = false; - showSuggestions.value = false; } - }, [expense]); + + if (shouldResetForm) { + resetForm(); + } + }, [expense, shouldResetForm]); const sortedBudgetNames = budgets.map((budget) => budget.name).sort(); @@ -225,14 +231,15 @@ export default function ExpenseModal( : null} diff --git a/components/expenses/ListBudgets.tsx b/components/expenses/ListBudgets.tsx index ce87f11..7ab9f6c 100644 --- a/components/expenses/ListBudgets.tsx +++ b/components/expenses/ListBudgets.tsx @@ -102,7 +102,7 @@ export default function ListBudgets( return (
budget.id === 'total' ? swapView('chart') : onClickEditBudget(budget.id)} - class='flex max-w-sm gap-y-4 gap-x-4 rounded shadow-md bg-slate-700 relative cursor-pointer py-4 px-6 hover:opacity-80' + class='flex w-full md:w-auto max-w-sm gap-y-4 gap-x-4 rounded shadow-md bg-slate-700 relative cursor-pointer py-4 px-6 hover:opacity-80' >
diff --git a/components/expenses/MainExpenses.tsx b/components/expenses/MainExpenses.tsx index bc1a722..5b8caa9 100644 --- a/components/expenses/MainExpenses.tsx +++ b/components/expenses/MainExpenses.tsx @@ -59,6 +59,8 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM const editingExpense = useSignal(null); const isBudgetModalOpen = useSignal(false); const editingBudget = useSignal(null); + const shouldResetExpenseModal = useSignal(false); + const shouldResetBudgetModal = useSignal(false); const searchTimeout = useSignal>(0); const dateFormat = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'long' }); @@ -197,6 +199,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM return; } + shouldResetExpenseModal.value = false; editingExpense.value = null; isExpenseModalOpen.value = true; } @@ -209,6 +212,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM return; } + shouldResetBudgetModal.value = false; editingBudget.value = null; isBudgetModalOpen.value = true; } @@ -221,6 +225,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM return; } + shouldResetExpenseModal.value = false; editingExpense.value = expenses.value.find((expense) => expense.id === expenseId)!; isExpenseModalOpen.value = true; } @@ -238,6 +243,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM return; } + shouldResetBudgetModal.value = false; editingBudget.value = budgets.value.find((budget) => budget.id === budgetId)!; isBudgetModalOpen.value = true; } @@ -292,6 +298,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM isExpenseModalOpen.value = false; editingExpense.value = null; + shouldResetExpenseModal.value = true; } catch (error) { console.error(error); alert(error); @@ -326,6 +333,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM budgets.value = [...result.newBudgets]; isExpenseModalOpen.value = false; + shouldResetExpenseModal.value = true; } catch (error) { console.error(error); alert(error); @@ -379,6 +387,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM isBudgetModalOpen.value = false; editingBudget.value = null; + shouldResetBudgetModal.value = true; } catch (error) { console.error(error); alert(error); @@ -410,6 +419,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM budgets.value = [...result.newBudgets]; isBudgetModalOpen.value = false; + shouldResetBudgetModal.value = true; } catch (error) { console.error(error); alert(error); @@ -460,6 +470,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM isExpenseModalOpen.value = false; editingExpense.value = null; + shouldResetExpenseModal.value = true; } catch (error) { console.error(error); alert(error); @@ -508,6 +519,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM isBudgetModalOpen.value = false; editingBudget.value = null; + shouldResetBudgetModal.value = true; } catch (error) { console.error(error); alert(error); @@ -519,11 +531,13 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM function onCloseExpense() { isExpenseModalOpen.value = false; editingExpense.value = null; + shouldResetExpenseModal.value = true; } function onCloseBudget() { isBudgetModalOpen.value = false; editingBudget.value = null; + shouldResetBudgetModal.value = true; } function toggleNewOptionsDropdown() { @@ -780,6 +794,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM onClickSave={onClickSaveExpense} onClickDelete={onClickDeleteExpense} onClose={onCloseExpense} + shouldResetForm={shouldResetExpenseModal.value} /> ); diff --git a/islands/news/Feeds.tsx b/islands/news/Feeds.tsx index 372150b..39e4efd 100644 --- a/islands/news/Feeds.tsx +++ b/islands/news/Feeds.tsx @@ -195,13 +195,12 @@ export default function Feeds({ initialFeeds }: FeedsProps) { isExporting.value = true; - const fileName = ['feeds-', new Date().toISOString().substring(0, 19).replace(/:/g, '-'), '.opml'] - .join(''); + const fileName = `feeds-${new Date().toISOString().substring(0, 19).replace(/:/g, '-')}.opml`; const exportContents = formatNewsFeedsToOpml([...feeds.peek()]); // Add content-type - const xmlContent = ['data:application/xml; charset=utf-8,', exportContents].join(''); + const xmlContent = `data:application/xml; charset=utf-8,${exportContents}`; // Download the file const data = encodeURI(xmlContent); diff --git a/lib/data/files.ts b/lib/data/files.ts index c5f95b9..d68ef54 100644 --- a/lib/data/files.ts +++ b/lib/data/files.ts @@ -1,11 +1,33 @@ import { join } from 'std/path/join.ts'; +import { resolve } from 'std/path/resolve.ts'; import { lookup } from 'mrmime'; import { getFilesRootPath } from '/lib/config.ts'; import { Directory, DirectoryFile } from '/lib/types.ts'; import { sortDirectoriesByName, sortEntriesByName, sortFilesByName, TRASH_PATH } from '/lib/utils/files.ts'; +/** + * Ensures the user path is valid and securely accessible (meaning it's not trying to access files outside of the user's root directory). + * Does not check if the path exists. + * + * @param userId - The user ID + * @param path - The relative path (user-provided) to check + */ +export function ensureUserPathIsValidAndSecurelyAccessible(userId: string, path: string): void { + const userRootPath = join(getFilesRootPath(), userId, '/'); + + const fullPath = join(userRootPath, path); + + const resolvedFullPath = `${resolve(fullPath)}/`; + + if (!resolvedFullPath.startsWith(userRootPath)) { + throw new Error('Invalid file path'); + } +} + export async function getDirectories(userId: string, path: string): Promise { + ensureUserPathIsValidAndSecurelyAccessible(userId, path); + const rootPath = join(getFilesRootPath(), userId, path); const directories: Directory[] = []; @@ -34,6 +56,8 @@ export async function getDirectories(userId: string, path: string): Promise { + ensureUserPathIsValidAndSecurelyAccessible(userId, path); + const rootPath = join(getFilesRootPath(), userId, path); const files: DirectoryFile[] = []; @@ -62,6 +86,8 @@ export async function getFiles(userId: string, path: string): Promise { + ensureUserPathIsValidAndSecurelyAccessible(userId, path); + const rootPath = join(getFilesRootPath(), userId, path); // Ensure the user directory exists @@ -98,6 +124,8 @@ async function getPathEntries(userId: string, path: string): Promise { + ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name)); + const rootPath = join(getFilesRootPath(), userId, path); try { @@ -117,6 +145,9 @@ export async function renameDirectoryOrFile( oldName: string, newName: string, ): Promise { + ensureUserPathIsValidAndSecurelyAccessible(userId, join(oldPath, oldName)); + ensureUserPathIsValidAndSecurelyAccessible(userId, join(newPath, newName)); + const oldRootPath = join(getFilesRootPath(), userId, oldPath); const newRootPath = join(getFilesRootPath(), userId, newPath); @@ -131,6 +162,8 @@ export async function renameDirectoryOrFile( } export async function deleteDirectoryOrFile(userId: string, path: string, name: string): Promise { + ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name)); + const rootPath = join(getFilesRootPath(), userId, path); try { @@ -154,6 +187,8 @@ export async function createFile( name: string, contents: string | ArrayBuffer, ): Promise { + ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name)); + const rootPath = join(getFilesRootPath(), userId, path); try { @@ -176,6 +211,8 @@ export async function updateFile( name: string, contents: string, ): Promise { + ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name)); + const rootPath = join(getFilesRootPath(), userId, path); try { @@ -193,6 +230,8 @@ export async function getFile( path: string, name?: string, ): Promise<{ success: boolean; contents?: Uint8Array; contentType?: string; byteSize?: number }> { + ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name || '')); + const rootPath = join(getFilesRootPath(), userId, path); try { diff --git a/routes/dav.tsx b/routes/dav.tsx index 046b939..ad9286f 100644 --- a/routes/dav.tsx +++ b/routes/dav.tsx @@ -11,7 +11,7 @@ import { getProperDestinationPath, getPropertyNames, } from '/lib/utils/webdav.ts'; -import { getFile } from '/lib/data/files.ts'; +import { ensureUserPathIsValidAndSecurelyAccessible, getFile } from '/lib/data/files.ts'; interface Data {} @@ -35,7 +35,9 @@ export const handler: Handler = async (request, context filePath = decodeURIComponent(filePath); - const rootPath = join(getFilesRootPath(), context.state.user.id); + const userId = context.state.user.id; + + const rootPath = join(getFilesRootPath(), userId); if (request.method === 'OPTIONS') { const headers = new Headers({ @@ -51,7 +53,7 @@ export const handler: Handler = async (request, context if (request.method === 'GET') { try { - const fileResult = await getFile(context.state.user.id, filePath); + const fileResult = await getFile(userId, filePath); if (!fileResult.success) { return new Response('Not Found', { status: 404 }); @@ -74,6 +76,8 @@ export const handler: Handler = async (request, context if (request.method === 'DELETE') { try { + ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); + await Deno.remove(join(rootPath, filePath)); return new Response(null, { status: 204 }); @@ -90,6 +94,8 @@ export const handler: Handler = async (request, context const body = contentLength === 0 ? new Blob([new Uint8Array([0])]).stream() : request.clone().body; try { + ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); + const newFile = await Deno.open(join(rootPath, filePath), { create: true, write: true, @@ -110,6 +116,9 @@ export const handler: Handler = async (request, context const newFilePath = request.headers.get('destination'); if (newFilePath) { try { + ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); + ensureUserPathIsValidAndSecurelyAccessible(userId, getProperDestinationPath(newFilePath)); + await Deno.copyFile(join(rootPath, filePath), join(rootPath, getProperDestinationPath(newFilePath))); return new Response('Created', { status: 201 }); } catch (error) { @@ -124,6 +133,9 @@ export const handler: Handler = async (request, context const newFilePath = request.headers.get('destination'); if (newFilePath) { try { + ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); + ensureUserPathIsValidAndSecurelyAccessible(userId, getProperDestinationPath(newFilePath)); + await Deno.rename(join(rootPath, filePath), join(rootPath, getProperDestinationPath(newFilePath))); return new Response('Created', { status: 201 }); } catch (error) { @@ -134,6 +146,7 @@ export const handler: Handler = async (request, context if (request.method === 'MKCOL') { try { + ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); await Deno.mkdir(join(rootPath, filePath), { recursive: true }); return new Response('Created', { status: 201 }); } catch (error) { @@ -209,6 +222,9 @@ export const handler: Handler = async (request, context const parsedXml = parse(xml); const properties = getPropertyNames(parsedXml); + + ensureUserPathIsValidAndSecurelyAccessible(userId, filePath); + const responseXml = await buildPropFindResponse(properties, rootPath, filePath, depth); return responseXml['D:multistatus']['D:response'].length === 0 diff --git a/static/images/favicon-dark.png b/static/images/favicon-dark.png new file mode 100644 index 0000000..7ce68ee Binary files /dev/null and b/static/images/favicon-dark.png differ diff --git a/static/images/favicon-dark.svg b/static/images/favicon-dark.svg new file mode 100644 index 0000000..82129bd --- /dev/null +++ b/static/images/favicon-dark.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/manifest.json b/static/manifest.json index fa0ccdf..c36f0ac 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -3,20 +3,31 @@ "short_name": "bewCloud", "icons": [ { - "src": "/images/logomark.svg", + "src": "/images/favicon-dark.svg", "sizes": "1024x1024 any", "type": "image/svg+xml", - "purpose": "monochrome maskable" + "purpose": "any maskable" }, { - "src": "/images/favicon.png", + "src": "/images/favicon-dark.png", "sizes": "1024x1024", - "type": "image/png" + "type": "image/png", + "purpose": "any maskable" } ], - "theme_color": "#51a4fb", + "theme_color": "#1e293b", "background_color": "#1e293b", "start_url": "/", "display": "standalone", - "orientation": "portrait" + "orientation": "portrait", + "shortcuts": [ + { + "name": "News", + "url": "/news" + }, + { + "name": "Expenses", + "url": "/expenses" + } + ] }