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
This commit is contained in:
Bruno Bernardino
2025-02-27 15:02:10 +00:00
parent b3bd8cb3cc
commit 4faa7bd05d
10 changed files with 246 additions and 30 deletions

View File

@@ -9,26 +9,33 @@ interface BudgetModalProps {
onClickSave: (newBudgetName: string, newBudgetMonth: string, newBudgetValue: number) => Promise<void>; onClickSave: (newBudgetName: string, newBudgetMonth: string, newBudgetValue: number) => Promise<void>;
onClickDelete: () => Promise<void>; onClickDelete: () => Promise<void>;
onClose: () => void; onClose: () => void;
shouldResetForm: boolean;
} }
export default function BudgetModal( export default function BudgetModal(
{ isOpen, budget, onClickSave, onClickDelete, onClose }: BudgetModalProps, { isOpen, budget, onClickSave, onClickDelete, onClose, shouldResetForm }: BudgetModalProps,
) { ) {
const newBudgetName = useSignal<string>(budget?.name ?? ''); const newBudgetName = useSignal<string>(budget?.name ?? '');
const newBudgetMonth = useSignal<string>(budget?.month ?? new Date().toISOString().substring(0, 10)); const newBudgetMonth = useSignal<string>(budget?.month ?? new Date().toISOString().substring(0, 10));
const newBudgetValue = useSignal<number>(budget?.value ?? 100); const newBudgetValue = useSignal<number>(budget?.value ?? 100);
const resetForm = () => {
newBudgetName.value = '';
newBudgetMonth.value = new Date().toISOString().substring(0, 10);
newBudgetValue.value = 100;
};
useEffect(() => { useEffect(() => {
if (budget) { if (budget) {
newBudgetName.value = budget.name; newBudgetName.value = budget.name;
newBudgetMonth.value = `${budget.month}-15`; newBudgetMonth.value = `${budget.month}-15`;
newBudgetValue.value = budget.value; 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 ( return (
<> <>

View File

@@ -21,10 +21,11 @@ interface ExpenseModalProps {
) => Promise<void>; ) => Promise<void>;
onClickDelete: () => Promise<void>; onClickDelete: () => Promise<void>;
onClose: () => void; onClose: () => void;
shouldResetForm: boolean;
} }
export default function ExpenseModal( export default function ExpenseModal(
{ isOpen, expense, budgets, onClickSave, onClickDelete, onClose }: ExpenseModalProps, { isOpen, expense, budgets, onClickSave, onClickDelete, onClose, shouldResetForm }: ExpenseModalProps,
) { ) {
const newExpenseCost = useSignal<number | ''>(expense?.cost ?? ''); const newExpenseCost = useSignal<number | ''>(expense?.cost ?? '');
const newExpenseDescription = useSignal<string>(expense?.description ?? ''); const newExpenseDescription = useSignal<string>(expense?.description ?? '');
@@ -34,6 +35,14 @@ export default function ExpenseModal(
const suggestions = useSignal<string[]>([]); const suggestions = useSignal<string[]>([]);
const showSuggestions = useSignal<boolean>(false); const showSuggestions = useSignal<boolean>(false);
const resetForm = () => {
newExpenseCost.value = '';
newExpenseDescription.value = '';
newExpenseBudget.value = 'Misc';
newExpenseDate.value = '';
newExpenseIsRecurring.value = false;
};
useEffect(() => { useEffect(() => {
if (expense) { if (expense) {
newExpenseCost.value = expense.cost; newExpenseCost.value = expense.cost;
@@ -42,15 +51,12 @@ export default function ExpenseModal(
newExpenseDate.value = expense.date; newExpenseDate.value = expense.date;
newExpenseIsRecurring.value = expense.is_recurring; newExpenseIsRecurring.value = expense.is_recurring;
showSuggestions.value = false; 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(); const sortedBudgetNames = budgets.map((budget) => budget.name).sort();
@@ -225,14 +231,15 @@ export default function ExpenseModal(
: null} : null}
<button <button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md mr-2' class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md mr-2'
onClick={() => onClick={() => {
onClickSave( onClickSave(
newExpenseCost.value as number, newExpenseCost.value as number,
newExpenseDescription.value, newExpenseDescription.value,
newExpenseBudget.value, newExpenseBudget.value,
newExpenseDate.value, newExpenseDate.value,
newExpenseIsRecurring.value, newExpenseIsRecurring.value,
)} );
}}
> >
{expense ? 'Update' : 'Create'} {expense ? 'Update' : 'Create'}
</button> </button>

View File

@@ -102,7 +102,7 @@ export default function ListBudgets(
return ( return (
<div <div
onClick={() => budget.id === 'total' ? swapView('chart') : onClickEditBudget(budget.id)} onClick={() => 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'
> >
<article class='order-first tracking-tight flex flex-col text-base mr-4'> <article class='order-first tracking-tight flex flex-col text-base mr-4'>
<span class='font-bold text-lg' title='Amount used from budgeted amount'> <span class='font-bold text-lg' title='Amount used from budgeted amount'>

View File

@@ -59,6 +59,8 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
const editingExpense = useSignal<Expense | null>(null); const editingExpense = useSignal<Expense | null>(null);
const isBudgetModalOpen = useSignal<boolean>(false); const isBudgetModalOpen = useSignal<boolean>(false);
const editingBudget = useSignal<Budget | null>(null); const editingBudget = useSignal<Budget | null>(null);
const shouldResetExpenseModal = useSignal<boolean>(false);
const shouldResetBudgetModal = useSignal<boolean>(false);
const searchTimeout = useSignal<ReturnType<typeof setTimeout>>(0); const searchTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
const dateFormat = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'long' }); const dateFormat = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'long' });
@@ -197,6 +199,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
return; return;
} }
shouldResetExpenseModal.value = false;
editingExpense.value = null; editingExpense.value = null;
isExpenseModalOpen.value = true; isExpenseModalOpen.value = true;
} }
@@ -209,6 +212,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
return; return;
} }
shouldResetBudgetModal.value = false;
editingBudget.value = null; editingBudget.value = null;
isBudgetModalOpen.value = true; isBudgetModalOpen.value = true;
} }
@@ -221,6 +225,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
return; return;
} }
shouldResetExpenseModal.value = false;
editingExpense.value = expenses.value.find((expense) => expense.id === expenseId)!; editingExpense.value = expenses.value.find((expense) => expense.id === expenseId)!;
isExpenseModalOpen.value = true; isExpenseModalOpen.value = true;
} }
@@ -238,6 +243,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
return; return;
} }
shouldResetBudgetModal.value = false;
editingBudget.value = budgets.value.find((budget) => budget.id === budgetId)!; editingBudget.value = budgets.value.find((budget) => budget.id === budgetId)!;
isBudgetModalOpen.value = true; isBudgetModalOpen.value = true;
} }
@@ -292,6 +298,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
isExpenseModalOpen.value = false; isExpenseModalOpen.value = false;
editingExpense.value = null; editingExpense.value = null;
shouldResetExpenseModal.value = true;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
alert(error); alert(error);
@@ -326,6 +333,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
budgets.value = [...result.newBudgets]; budgets.value = [...result.newBudgets];
isExpenseModalOpen.value = false; isExpenseModalOpen.value = false;
shouldResetExpenseModal.value = true;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
alert(error); alert(error);
@@ -379,6 +387,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
isBudgetModalOpen.value = false; isBudgetModalOpen.value = false;
editingBudget.value = null; editingBudget.value = null;
shouldResetBudgetModal.value = true;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
alert(error); alert(error);
@@ -410,6 +419,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
budgets.value = [...result.newBudgets]; budgets.value = [...result.newBudgets];
isBudgetModalOpen.value = false; isBudgetModalOpen.value = false;
shouldResetBudgetModal.value = true;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
alert(error); alert(error);
@@ -460,6 +470,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
isExpenseModalOpen.value = false; isExpenseModalOpen.value = false;
editingExpense.value = null; editingExpense.value = null;
shouldResetExpenseModal.value = true;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
alert(error); alert(error);
@@ -508,6 +519,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
isBudgetModalOpen.value = false; isBudgetModalOpen.value = false;
editingBudget.value = null; editingBudget.value = null;
shouldResetBudgetModal.value = true;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
alert(error); alert(error);
@@ -519,11 +531,13 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
function onCloseExpense() { function onCloseExpense() {
isExpenseModalOpen.value = false; isExpenseModalOpen.value = false;
editingExpense.value = null; editingExpense.value = null;
shouldResetExpenseModal.value = true;
} }
function onCloseBudget() { function onCloseBudget() {
isBudgetModalOpen.value = false; isBudgetModalOpen.value = false;
editingBudget.value = null; editingBudget.value = null;
shouldResetBudgetModal.value = true;
} }
function toggleNewOptionsDropdown() { function toggleNewOptionsDropdown() {
@@ -780,6 +794,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
onClickSave={onClickSaveExpense} onClickSave={onClickSaveExpense}
onClickDelete={onClickDeleteExpense} onClickDelete={onClickDeleteExpense}
onClose={onCloseExpense} onClose={onCloseExpense}
shouldResetForm={shouldResetExpenseModal.value}
/> />
<BudgetModal <BudgetModal
@@ -788,6 +803,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
onClickSave={onClickSaveBudget} onClickSave={onClickSaveBudget}
onClickDelete={onClickDeleteBudget} onClickDelete={onClickDeleteBudget}
onClose={onCloseBudget} onClose={onCloseBudget}
shouldResetForm={shouldResetBudgetModal.value}
/> />
</> </>
); );

View File

@@ -195,13 +195,12 @@ export default function Feeds({ initialFeeds }: FeedsProps) {
isExporting.value = true; isExporting.value = true;
const fileName = ['feeds-', new Date().toISOString().substring(0, 19).replace(/:/g, '-'), '.opml'] const fileName = `feeds-${new Date().toISOString().substring(0, 19).replace(/:/g, '-')}.opml`;
.join('');
const exportContents = formatNewsFeedsToOpml([...feeds.peek()]); const exportContents = formatNewsFeedsToOpml([...feeds.peek()]);
// Add content-type // 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 // Download the file
const data = encodeURI(xmlContent); const data = encodeURI(xmlContent);

View File

@@ -1,11 +1,33 @@
import { join } from 'std/path/join.ts'; import { join } from 'std/path/join.ts';
import { resolve } from 'std/path/resolve.ts';
import { lookup } from 'mrmime'; import { lookup } from 'mrmime';
import { getFilesRootPath } from '/lib/config.ts'; import { getFilesRootPath } from '/lib/config.ts';
import { Directory, DirectoryFile } from '/lib/types.ts'; import { Directory, DirectoryFile } from '/lib/types.ts';
import { sortDirectoriesByName, sortEntriesByName, sortFilesByName, TRASH_PATH } from '/lib/utils/files.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<Directory[]> { export async function getDirectories(userId: string, path: string): Promise<Directory[]> {
ensureUserPathIsValidAndSecurelyAccessible(userId, path);
const rootPath = join(getFilesRootPath(), userId, path); const rootPath = join(getFilesRootPath(), userId, path);
const directories: Directory[] = []; const directories: Directory[] = [];
@@ -34,6 +56,8 @@ export async function getDirectories(userId: string, path: string): Promise<Dire
} }
export async function getFiles(userId: string, path: string): Promise<DirectoryFile[]> { export async function getFiles(userId: string, path: string): Promise<DirectoryFile[]> {
ensureUserPathIsValidAndSecurelyAccessible(userId, path);
const rootPath = join(getFilesRootPath(), userId, path); const rootPath = join(getFilesRootPath(), userId, path);
const files: DirectoryFile[] = []; const files: DirectoryFile[] = [];
@@ -62,6 +86,8 @@ export async function getFiles(userId: string, path: string): Promise<DirectoryF
} }
async function getPathEntries(userId: string, path: string): Promise<Deno.DirEntry[]> { async function getPathEntries(userId: string, path: string): Promise<Deno.DirEntry[]> {
ensureUserPathIsValidAndSecurelyAccessible(userId, path);
const rootPath = join(getFilesRootPath(), userId, path); const rootPath = join(getFilesRootPath(), userId, path);
// Ensure the user directory exists // Ensure the user directory exists
@@ -98,6 +124,8 @@ async function getPathEntries(userId: string, path: string): Promise<Deno.DirEnt
} }
export async function createDirectory(userId: string, path: string, name: string): Promise<boolean> { export async function createDirectory(userId: string, path: string, name: string): Promise<boolean> {
ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name));
const rootPath = join(getFilesRootPath(), userId, path); const rootPath = join(getFilesRootPath(), userId, path);
try { try {
@@ -117,6 +145,9 @@ export async function renameDirectoryOrFile(
oldName: string, oldName: string,
newName: string, newName: string,
): Promise<boolean> { ): Promise<boolean> {
ensureUserPathIsValidAndSecurelyAccessible(userId, join(oldPath, oldName));
ensureUserPathIsValidAndSecurelyAccessible(userId, join(newPath, newName));
const oldRootPath = join(getFilesRootPath(), userId, oldPath); const oldRootPath = join(getFilesRootPath(), userId, oldPath);
const newRootPath = join(getFilesRootPath(), userId, newPath); 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<boolean> { export async function deleteDirectoryOrFile(userId: string, path: string, name: string): Promise<boolean> {
ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name));
const rootPath = join(getFilesRootPath(), userId, path); const rootPath = join(getFilesRootPath(), userId, path);
try { try {
@@ -154,6 +187,8 @@ export async function createFile(
name: string, name: string,
contents: string | ArrayBuffer, contents: string | ArrayBuffer,
): Promise<boolean> { ): Promise<boolean> {
ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name));
const rootPath = join(getFilesRootPath(), userId, path); const rootPath = join(getFilesRootPath(), userId, path);
try { try {
@@ -176,6 +211,8 @@ export async function updateFile(
name: string, name: string,
contents: string, contents: string,
): Promise<boolean> { ): Promise<boolean> {
ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name));
const rootPath = join(getFilesRootPath(), userId, path); const rootPath = join(getFilesRootPath(), userId, path);
try { try {
@@ -193,6 +230,8 @@ export async function getFile(
path: string, path: string,
name?: string, name?: string,
): Promise<{ success: boolean; contents?: Uint8Array; contentType?: string; byteSize?: number }> { ): Promise<{ success: boolean; contents?: Uint8Array; contentType?: string; byteSize?: number }> {
ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name || ''));
const rootPath = join(getFilesRootPath(), userId, path); const rootPath = join(getFilesRootPath(), userId, path);
try { try {

View File

@@ -11,7 +11,7 @@ import {
getProperDestinationPath, getProperDestinationPath,
getPropertyNames, getPropertyNames,
} from '/lib/utils/webdav.ts'; } from '/lib/utils/webdav.ts';
import { getFile } from '/lib/data/files.ts'; import { ensureUserPathIsValidAndSecurelyAccessible, getFile } from '/lib/data/files.ts';
interface Data {} interface Data {}
@@ -35,7 +35,9 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
filePath = decodeURIComponent(filePath); 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') { if (request.method === 'OPTIONS') {
const headers = new Headers({ const headers = new Headers({
@@ -51,7 +53,7 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
if (request.method === 'GET') { if (request.method === 'GET') {
try { try {
const fileResult = await getFile(context.state.user.id, filePath); const fileResult = await getFile(userId, filePath);
if (!fileResult.success) { if (!fileResult.success) {
return new Response('Not Found', { status: 404 }); return new Response('Not Found', { status: 404 });
@@ -74,6 +76,8 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
if (request.method === 'DELETE') { if (request.method === 'DELETE') {
try { try {
ensureUserPathIsValidAndSecurelyAccessible(userId, filePath);
await Deno.remove(join(rootPath, filePath)); await Deno.remove(join(rootPath, filePath));
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
@@ -90,6 +94,8 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
const body = contentLength === 0 ? new Blob([new Uint8Array([0])]).stream() : request.clone().body; const body = contentLength === 0 ? new Blob([new Uint8Array([0])]).stream() : request.clone().body;
try { try {
ensureUserPathIsValidAndSecurelyAccessible(userId, filePath);
const newFile = await Deno.open(join(rootPath, filePath), { const newFile = await Deno.open(join(rootPath, filePath), {
create: true, create: true,
write: true, write: true,
@@ -110,6 +116,9 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
const newFilePath = request.headers.get('destination'); const newFilePath = request.headers.get('destination');
if (newFilePath) { if (newFilePath) {
try { try {
ensureUserPathIsValidAndSecurelyAccessible(userId, filePath);
ensureUserPathIsValidAndSecurelyAccessible(userId, getProperDestinationPath(newFilePath));
await Deno.copyFile(join(rootPath, filePath), join(rootPath, getProperDestinationPath(newFilePath))); await Deno.copyFile(join(rootPath, filePath), join(rootPath, getProperDestinationPath(newFilePath)));
return new Response('Created', { status: 201 }); return new Response('Created', { status: 201 });
} catch (error) { } catch (error) {
@@ -124,6 +133,9 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
const newFilePath = request.headers.get('destination'); const newFilePath = request.headers.get('destination');
if (newFilePath) { if (newFilePath) {
try { try {
ensureUserPathIsValidAndSecurelyAccessible(userId, filePath);
ensureUserPathIsValidAndSecurelyAccessible(userId, getProperDestinationPath(newFilePath));
await Deno.rename(join(rootPath, filePath), join(rootPath, getProperDestinationPath(newFilePath))); await Deno.rename(join(rootPath, filePath), join(rootPath, getProperDestinationPath(newFilePath)));
return new Response('Created', { status: 201 }); return new Response('Created', { status: 201 });
} catch (error) { } catch (error) {
@@ -134,6 +146,7 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
if (request.method === 'MKCOL') { if (request.method === 'MKCOL') {
try { try {
ensureUserPathIsValidAndSecurelyAccessible(userId, filePath);
await Deno.mkdir(join(rootPath, filePath), { recursive: true }); await Deno.mkdir(join(rootPath, filePath), { recursive: true });
return new Response('Created', { status: 201 }); return new Response('Created', { status: 201 });
} catch (error) { } catch (error) {
@@ -209,6 +222,9 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
const parsedXml = parse(xml); const parsedXml = parse(xml);
const properties = getPropertyNames(parsedXml); const properties = getPropertyNames(parsedXml);
ensureUserPathIsValidAndSecurelyAccessible(userId, filePath);
const responseXml = await buildPropFindResponse(properties, rootPath, filePath, depth); const responseXml = await buildPropFindResponse(properties, rootPath, filePath, depth);
return responseXml['D:multistatus']['D:response'].length === 0 return responseXml['D:multistatus']['D:response'].length === 0

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
data-v-fde0c5aa=""
viewBox="0 0 80 80"
class="iconLeft"
version="1.1"
id="svg3"
sodipodi:docname="favicon-dark.svg"
inkscape:export-filename="favicon-dark.png"
inkscape:export-xdpi="1228.8"
inkscape:export-ydpi="1228.8"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
width="80"
height="80"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview3"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.92"
inkscape:cx="67.96875"
inkscape:cy="15.104167"
inkscape:window-width="1312"
inkscape:window-height="449"
inkscape:window-x="0"
inkscape:window-y="38"
inkscape:window-maximized="0"
inkscape:current-layer="g5" />
<!---->
<defs
data-v-fde0c5aa=""
id="defs1">
<!---->
<linearGradient
inkscape:collect="always"
xlink:href="#d27698e6-eb24-4a3f-96af-256d84d5b3ea"
id="linearGradient4"
gradientTransform="scale(1.3465159,0.74265739)"
x1="0"
y1="0"
x2="74.265739"
y2="0"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#d27698e6-eb24-4a3f-96af-256d84d5b3ea"
id="linearGradient5"
gradientTransform="scale(1.3465159,0.74265739)"
x1="0"
y1="0"
x2="74.265739"
y2="0"
gradientUnits="userSpaceOnUse" />
</defs>
<defs
data-v-fde0c5aa=""
id="defs2">
<!---->
</defs>
<defs
data-v-fde0c5aa=""
id="defs3">
<linearGradient
data-v-fde0c5aa=""
gradientTransform="rotate(25)"
id="d27698e6-eb24-4a3f-96af-256d84d5b3ea"
x1="0"
y1="0"
x2="1"
y2="0">
<stop
data-v-fde0c5aa=""
offset="0%"
stop-color="#51A4FB"
stop-opacity="1"
id="stop2" />
<stop
data-v-fde0c5aa=""
offset="100%"
stop-color="#51A4FB"
stop-opacity="1"
id="stop3" />
</linearGradient>
</defs>
<g
id="g5"
transform="translate(-24.999993,-125)">
<rect
data-v-fde0c5aa=""
fill="#e6f2fe"
x="24.999992"
y="125"
width="80"
height="80"
class="logo-background-square"
id="rect1"
style="stroke-width:0.266666;fill:#02203a;fill-opacity:1" />
<g
data-v-fde0c5aa=""
id="f64f14cc-5390-468e-9cd6-ee0163dcbf09"
stroke="none"
fill="url(#d27698e6-eb24-4a3f-96af-256d84d5b3ea)"
transform="matrix(0.60062915,0,0,0.60062915,34.968536,148.43645)"
style="fill:url(#linearGradient5)">
<path
d="M 77.959,55.154 H 11.282 C 5.061,55.154 0,50.093 0,43.873 0,38.595 3.643,34.152 8.546,32.927 10.734,27.458 15.794,23.541 21.695,22.838 22.317,10.14 32.843,0 45.693,0 55.11,0 63.515,5.536 67.416,13.756 a 21.944,21.944 0 0 1 10.543,-2.682 c 12.153,0 22.041,9.887 22.041,22.04 0,12.153 -9.887,22.04 -22.041,22.04 z M 11.209,40.372 a 3.507,3.507 0 0 0 -3.429,3.501 3.504,3.504 0 0 0 3.501,3.501 h 66.678 c 7.863,0 14.26,-6.397 14.26,-14.26 0,-7.863 -6.396,-14.259 -14.26,-14.259 -3.683,0 -7.18,1.404 -9.847,3.952 L 63.016,27.678 61.612,20.77 C 60.083,13.243 53.388,7.78 45.693,7.78 c -8.958,0 -16.247,7.288 -16.247,16.246 0,0.728 0.054,1.483 0.159,2.246 l 0.73,5.287 -5.256,-0.923 a 8.59,8.59 0 0 0 -1.474,-0.132 c -4.002,0 -7.479,2.842 -8.267,6.757 l -0.65,3.227 -3.29,-0.106 z"
id="path3"
style="fill:url(#linearGradient4)" />
</g>
</g>
<!---->
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -3,20 +3,31 @@
"short_name": "bewCloud", "short_name": "bewCloud",
"icons": [ "icons": [
{ {
"src": "/images/logomark.svg", "src": "/images/favicon-dark.svg",
"sizes": "1024x1024 any", "sizes": "1024x1024 any",
"type": "image/svg+xml", "type": "image/svg+xml",
"purpose": "monochrome maskable" "purpose": "any maskable"
}, },
{ {
"src": "/images/favicon.png", "src": "/images/favicon-dark.png",
"sizes": "1024x1024", "sizes": "1024x1024",
"type": "image/png" "type": "image/png",
"purpose": "any maskable"
} }
], ],
"theme_color": "#51a4fb", "theme_color": "#1e293b",
"background_color": "#1e293b", "background_color": "#1e293b",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
"orientation": "portrait" "orientation": "portrait",
"shortcuts": [
{
"name": "News",
"url": "/news"
},
{
"name": "Expenses",
"url": "/expenses"
}
]
} }