From 4faa7bd05d1dad5d07dc5502806f1e859063e174 Mon Sep 17 00:00:00 2001 From: Bruno Bernardino Date: Thu, 27 Feb 2025 15:02:10 +0000 Subject: [PATCH] Security fix for path-traversal attack (#48) Additionally: - Make expense and budget modal "reset" once closed, saved, or deleted. - Make manifest icons dark - Budgets in small screens should be full-screen - Minor code cleanup Fixes #48 --- components/expenses/BudgetModal.tsx | 19 +++-- components/expenses/ExpenseModal.tsx | 29 ++++--- components/expenses/ListBudgets.tsx | 2 +- components/expenses/MainExpenses.tsx | 16 ++++ islands/news/Feeds.tsx | 5 +- lib/data/files.ts | 39 +++++++++ routes/dav.tsx | 22 ++++- static/images/favicon-dark.png | Bin 0 -> 7412 bytes static/images/favicon-dark.svg | 121 +++++++++++++++++++++++++++ static/manifest.json | 23 +++-- 10 files changed, 246 insertions(+), 30 deletions(-) create mode 100644 static/images/favicon-dark.png create mode 100644 static/images/favicon-dark.svg 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 0000000000000000000000000000000000000000..7ce68eecf49509132d522d608484dfc127f050a5 GIT binary patch literal 7412 zcmds6^;gv0yZy|-0FpCwGjvOL4c#fNv>**b35YOsiG(zWNOwpp$bfVz4Fb|4AR!2n zbA8`+|AG6x_ousmdGU8;_?=6b z3?ntOcDe0yg}myNl9rfFVj1*8RgzbT#mvP5+9(uV*({S~Li;&wGFirk8@s>&Ero-0qJ4C~sWW<9(<{4)7oJW-MFEEf{eD%PW(gUtL!`QD`Rsp3c||_~?5IZ}>yoldacB zR$7!3AmMp=?y_H$<+P=6fVLTJ%c9itm=|zwRz2D{Ps`yZu($thd8>0JJvU$`7BmAt zY16OO76#f2xGq%PR@0zB=R4O6PHbRa^d4`+jZO1C5IXvI^}8DzJ|oDQeg4l#W35h8 z;J!n1%E)j96zBfia+}3T=Do;|VDAF1)T-Q9MA#D|)eGAg8!-+%aJif5>DiSv4n*>N^%gILG>cm=!&(n?w;4GMM94$I3<4l!3-Sn0M{y|)c3b&frG|^ z#1pVJ7E0eEN&c?eWttf;WGmbBrb$1s2)-6(I4cttM1rk1C))7Gt2>PwgqC3)54-7T zhO8)}9Dj6Q?8rdY>MIupw*~Chq8%o7lO1Mg$iQ6Ng#AWzkcB$Ckl#n4SdayN;nvFw zC+_=z24#jGig0d|_K)Vq`2C@~HQ-A^I_P5^{sv4^9o&=pYUiZr1E`7W{!^;BzO8n4qh* z=#t!j#r916#Jmekr1%+mo@27_sGRoQbCS5a6^_yyH5YQ*Ok=qyovcR)K@rJdgjXYH z#=(yqOtcYzjy+>A<&sa*=(rtmh_=sXB!2Br)g(PPS7y1)|G<<}1&U|o@8np+Jv+$% z;A6~lML_p8;51%geaP)-;_XE3m-Y^@l`FjJ&`zG!_yWUYDB%G#^u@(C?#KUu*aN65 z8B~6M9BmVcMLavmPiL5i932 z+_FjWkE^2kFW;w+9wZHJs(Ea=>#c@{2tb;} z!7e&ra`5A1Z!7lad(Sf|s%poZm=%FU~n8K}!szw7of(@kGz zi%iVQ#Jk>OS;qRb=}&&tg@<@mkIaoLe!fCdpXy&7;J=I9bV3f@#(kssIbk71?Rveg z^bo?RZRxzsr9fg~bg=64gCuj)s_)H?MfD)-92UIHRxx$;OZMx=63FxDu5V5qgA(zR z!-x-s6id$Nh$(p&y=KPUgCDP!@3xm6nC~8+mYf6=ETNMuUNWVeJ28&l`$2YaC!B0< ze)Py*k2_=~VtcDrqoRO!=Sn6baHI3xsGw8?%wxnZ@BYzQoQKuNbPgqp~=` ze`{ICJaW@`>!3%qZsV+m^`?%5tdcxknh4;{4LU3onK1rUbX z_F{3Qa^|}t41T82{DgE1OFW3dJxNY%n#d#zyb_y|2V)RyB$V$x9}l@3T?OF>_seT` zC?X@iY#-Md4SERqBYi2S%6ZSlx5}g(_^{SIif7tyF#5$}qeJy~o-|FAHHy65RDoGe zAArQnyNMm4(d;jY@KbZT6V2id_d*&8M_7J7QQPF1*AD|7xu##gbR4Bt$k1Q zOa&K9Hd*Y$P1rGII|TI}f>_!+Y|3HH0?Sg=a0u6M5bkB_U(NU;BeqTRLZ-v zGjfcaVS~e-ogYtXd=qAv077>d_xR+7eWs-Fac`;QX?F}b&p5_4XG;NP{pk{1uCt-9 zfTJ1>D?o^K)3_t9XhqyW9+VEc+r3A-O$ez>>zzsrbR$gajZC2}h6YG8hKU}YSwTML zh`@rh{dy>&Ab zkAu0PkJb%%yjN-H0Y`X}xgT{U5mwi5`ppFI%r?DXc|;E0)ILO9N$ivb;-@Y9GvtBS z2rK$kHoM=$cN}BdQduCR*{)_QKB>*sfi0`i49yAtPotw)d2&niKrxrkZ#g?nFx~Q< zP93L}i51C(VTYq&!4Z{en;b1|&@}DKfE9b8yyQsijEDDvoQMeGiv)mFP!-w0^xaMi zyA3U|`001{iQYZjU@z)l1Z$6lcvAJvUcJqC7yVHV<4HRa{wzQyPvYuN0@b~ck$OWj zHbrQ%`yxtKI5OH`QQ;HW2*t$*wfl2ChYmMX_269*g%0Q+KMtj!*6MI`nL7LQT7j)( zlrQikP)#%Q^tI$v*YF8{O!rWai0(@GXGX`N0WwzN7IE&8bnk~KiKT{=>TBBNp04dZ zy?nPxvh*jld*}0Ra^~3y(%O=g?l01dT9OTO>6jI2Z0$JqUaT8Sr~b;eMhOUlv@$WD z@#ocku76IBPj@FRr0>Y3#ha`}cId3YP#RPdH@jMwI$nuJvILiKd;B#%jz~JBew7`i zcKSMeCgkZOJfAWqZ)OGRN}cLAJzWP}Gl5vd;71PhYu`0^W(#_|5bIQhPlFo@f595U zTYjqWnE4$G97dUV4TzwtiV=&6sl3#ypIYMB-zGhc9EB96YTB|(j%4^bNM_=TEvMgl zXVl#zzE-O%^T!&bR2pw}B5oLYfDVM4&5nWw!grISl;2Vm#i+pKesAgDQY6-)5`jje8zn58CWSs_RweW}_q)`Q7SnHs zHTzP5TFXe)u+DK?M;1#8N#*iZR^RT04^+QP!r}PnXG5gmd*E#&E{zTfaP8kmc6q2} zOY72S_mQ!!n`E@Qt%!_o+1CMq8=e_89|`xso4!)Gi5cjjeI4e_{=lCkx{ZUxD<)3f zD3kb5Z6 z_5&Lqw&Vlr2*kWXmo}Ir35*>n8dxiCFKhy2!3&ftI0mG52dxV)4Z_u5U?+f*@IEj*=kz>v~0Q(Fz@7^qn znHU$5wO6U3IQ6HSJf?uBS5_JhdnWt}Nve*E!E*N9mh*o54G z@G2a61#fA$TikA=Q#PxpHm&fye5&XA<|aCKG9N0r$LV?8Fg*b&{i)!-D@TQnq{0af zg~@we&?8gET@(3H$({FR1Ud%yv$4o}GX_VEO07-|aTNsFjjEkr7g0}y8Jj|0%(nJT zEOEJ^h1Vbq7lBw#U>uLaBnqa)fIs2fNZO=2B=PEXrhhL1PSrk(Lkw1x|Cl%Bd8nx& zgnrjLD_A2)q~yWODc4Jj24+#YyG)mFDJLk-839C9;^PYj9e_mKboQBux{sFNb>r}mGk1PuS~ z+NE{H2oiOQWYWRYt>?LGDmA? zBf#>7bRgMbG|3~#7?B*tld*|PBleP#{bZ^w{bT?4?wbPC>*9-)$L-AvvzF<(`Qfk2 zWvO_)+iy;7_J0?C6LY6j66a!X^@o#IDWCu=GdH?C)uX=C0Q2__bfnIN-2~3%f_H6k z5P{gJya8y>+P7~~SF27v`$LI{#rH}DVOw=`EY%>%hnX>K*DRYilcmQ_uc;}y&EG|k zi!tGe@AEiGHkdb2ZZ!ylmG!q}skOwGl%caU*~W%6pYv#v(#jW6Y^K$=Em@6Zh_ZVs zb~q)5+5_PD<`}-;dN>j_lbz1-@(Aj~$d;=9ugpQj_Ot+FE&bw*0|!cyRg(%uh|^j- zX$p5KBHpsYE;0OqGC|k{Lw5&Kb)~ejCGl(bnNx<5=2ttE?9KF$0Cr~3ZbuSpo?1Z{ zDs2n;V>Q)~=sb?Vhae|#PQ-1fhD(YR$p9z>aUd-H2Wz;5iiAq+M`KMyoK)kH5$5za z!n_X2?NE_Q0pJnT)nbAX$s9Efh+w^EWL#jq|A{=~w06S&9nE?0r}4*MT!QXLusr?5 zh=k?x8*?!TJe_9q(N`JvYwMhBhd0kT9l)sUlB$}!-#wMka=|jBj+s;kliQOUA1D31q+NwRrb*`dW|Oz&qKe=4%tJcyaiKeCh5f*{B7Kei@r>G#YD?m4GN|zIaUvqK1N6*diql$? z{y~m7zhv5aikX2x%zK}XDZ3g{gN4Uye}~o8!?84 z&*Jt@y8U~uleH#xafvGyp9GPdQ)zNai*fqjrW4SF;YID@umsdO%0BepLX2nhKxQ=3Wz?@OX7KlpB>N!sM}kW z__(5P6)LbKNd;7S+IGhFAsMJa=CDjRnic9osx__`W>=8_6h>1eJ)obSA?tsZ0077z zQ^W_~cC8tkodLDa@d%O_QV!+Y{-scr?Wi17fz|JA)hrS~kU}zGoZvF`7bxcH{vyHJ zY;a|ALk?`VuOaK5$Dd>u4t>jXAbbUd)W(+@{HT}R|AnA0U@ch}=T}*nC*nE5N2TW$ zZNDjT0hNmCJx{_9Nz%VK*+D3>=R99-vrCuyD1Z$S*b2J-<^F9s9-ZflK34?Oq0!}x z7&^Mr6&|;`t;a5yMA1V*4!#R;KMK<#!&!E&ejj63og@2993F@xDe8WjT}fCb#M=JA z5NB;kS-rq9N&rs*md@r{J=)s3K@6O@#KogL#c+Qe#)9tWmoI~*Ta7dc9n`$btJeFD zlVvWm#E87#LdVg1a?Q;QFn~qWjHoTrktUryY=+`-Z%JH0`cNw6XQ!#XERH=%pmuwb^nd(S)qY_5+?G|v2ynS5xdI*5M zDcIAf#99`e4c8&W3w_ce+9~E)DT^-%d(>1cO&Rk}4tR`;n-uu_jm4cx9oOqkOoo%< z&tPy|SHTq+hH3k1=!!H(u;(lo-+p5?AZJ^7E&m_%#pD2RQ@RJZEOWXd;Betepk*xvRI z?4EUQw(Qc&qJ`(@%$mqzSAEC%EG^^wo;~+=^SoqXhloQ-uBuPk;5#2SU-!ua7KK!_ zo%-7XhMn_HmgDQ2Hr9BuXjjS34pVHiGm{f`-XA6ovF$GKCsBONNp~7=ea^$pTF7}g z9Vx1_X+bIH46F1!#<80UrcJ-yqmSHXND)WFAeUy}E;y8}z$$w9Qcjg;5y5U6o?gRe zMs*QZs>Z2NKu6Dv9K5~bhCFy#X)8v{WR~o*N(5(&e#!#y6llatn z^_9nqw)(0f50CZUyyQ9HmEHgh>>d-MP>yUuG~6%UVlVJ(Cr@i=xtrA!He;~R>TNZq z(a2W{r2Hrz*6CSMP%8RVL!<^=BiulQqJ{67of)f+yAJe5WDwxa9Eh96Goz)H6#za{ z#L8U&MItA$dwApA#V*QHZdbuHd?H5*lya7A>6n$ksSxnzAQvS@?cZ6N{O3ckT`AE} z4!#)A9aoX*v|TuBRh6L2@+^T9YOkgC_iuWG0jBnNvtZ*?CA>y>XOKdC+1e$B0G;Ko18yV(zL0a~7GtI8P4lW` zc?+r>hZNzfp-5f1hlsL(D4v+B7i=k_G58|eanaDlF9-q{PC;L#))v;S!;#*rX`Bc_ zLjB^@2EY)fQ9zMzAOLUcu1&itO+imeZJ%%B`5}CE%Je$>C-+L!HOHq<2p#G#pcH+j!(|()96Sgu2@?ZF@l_u%0Wdgld4&g8Ra|xb& z0Xu=4hf|5sEHU6_jRCRd6hX>DF%~5RffmF$Q67RW@&q?WIT6_+AApv^@lr^wv7I3t zz$hLLxEUZqZM#E`nYd@!lPa-$!=fH!v{IpcLonM(>fpd6s}_?RDB9tw#6WQf1~5(w z;AiAxADe`g8t8t8q4iHbM9}>xL?KtqAWwTipyD#JXj+{>+^AS5KA>V)5BOHHg_+%9 zL782pW89jzNP&7nbKr*VvM^AmrnL6!Bf!S{w%#g`98CcG5k?fqLIYS!PTfw&Wl*An z=rIZ`bl4XIn`D|kh+J>E zS^y-e_42A=gp_ViBM9B^+>Vk5A66@(ddA9<=I>H-$Q5bPz+Kf+PFT9UbSZG5@v7>$ zM}O?&`_N0Ugm&BWCWEtNbcy>MC8+K~&f|m8LGlP@M~<3hLbb`GBDX00Gx?`{KKo9(0`nm{~7WZ|E~<3|DXQW(to!{@op~i(Z5dDzx_oU XsykN}2=VxN|1;E7)l;ccvW@sJhO-%B literal 0 HcmV?d00001 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" + } + ] }