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:
@@ -9,26 +9,33 @@ interface BudgetModalProps {
|
||||
onClickSave: (newBudgetName: string, newBudgetMonth: string, newBudgetValue: number) => Promise<void>;
|
||||
onClickDelete: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
shouldResetForm: boolean;
|
||||
}
|
||||
|
||||
export default function BudgetModal(
|
||||
{ isOpen, budget, onClickSave, onClickDelete, onClose }: BudgetModalProps,
|
||||
{ isOpen, budget, onClickSave, onClickDelete, onClose, shouldResetForm }: BudgetModalProps,
|
||||
) {
|
||||
const newBudgetName = useSignal<string>(budget?.name ?? '');
|
||||
const newBudgetMonth = useSignal<string>(budget?.month ?? new Date().toISOString().substring(0, 10));
|
||||
const newBudgetValue = useSignal<number>(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 (
|
||||
<>
|
||||
|
||||
@@ -21,10 +21,11 @@ interface ExpenseModalProps {
|
||||
) => Promise<void>;
|
||||
onClickDelete: () => Promise<void>;
|
||||
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<number | ''>(expense?.cost ?? '');
|
||||
const newExpenseDescription = useSignal<string>(expense?.description ?? '');
|
||||
@@ -34,6 +35,14 @@ export default function ExpenseModal(
|
||||
const suggestions = useSignal<string[]>([]);
|
||||
const showSuggestions = useSignal<boolean>(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}
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md mr-2'
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
onClickSave(
|
||||
newExpenseCost.value as number,
|
||||
newExpenseDescription.value,
|
||||
newExpenseBudget.value,
|
||||
newExpenseDate.value,
|
||||
newExpenseIsRecurring.value,
|
||||
)}
|
||||
);
|
||||
}}
|
||||
>
|
||||
{expense ? 'Update' : 'Create'}
|
||||
</button>
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function ListBudgets(
|
||||
return (
|
||||
<div
|
||||
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'>
|
||||
<span class='font-bold text-lg' title='Amount used from budgeted amount'>
|
||||
|
||||
@@ -59,6 +59,8 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
|
||||
const editingExpense = useSignal<Expense | null>(null);
|
||||
const isBudgetModalOpen = useSignal<boolean>(false);
|
||||
const editingBudget = useSignal<Budget | null>(null);
|
||||
const shouldResetExpenseModal = useSignal<boolean>(false);
|
||||
const shouldResetBudgetModal = useSignal<boolean>(false);
|
||||
const searchTimeout = useSignal<ReturnType<typeof setTimeout>>(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}
|
||||
/>
|
||||
|
||||
<BudgetModal
|
||||
@@ -788,6 +803,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
|
||||
onClickSave={onClickSaveBudget}
|
||||
onClickDelete={onClickDeleteBudget}
|
||||
onClose={onCloseBudget}
|
||||
shouldResetForm={shouldResetBudgetModal.value}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Directory[]> {
|
||||
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<Dire
|
||||
}
|
||||
|
||||
export async function getFiles(userId: string, path: string): Promise<DirectoryFile[]> {
|
||||
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<DirectoryF
|
||||
}
|
||||
|
||||
async function getPathEntries(userId: string, path: string): Promise<Deno.DirEntry[]> {
|
||||
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<Deno.DirEnt
|
||||
}
|
||||
|
||||
export async function createDirectory(userId: string, path: string, name: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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 {
|
||||
|
||||
@@ -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<Data, FreshContextState> = 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<Data, FreshContextState> = 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<Data, FreshContextState> = 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<Data, FreshContextState> = 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<Data, FreshContextState> = 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<Data, FreshContextState> = 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<Data, FreshContextState> = 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<Data, FreshContextState> = 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
|
||||
|
||||
BIN
static/images/favicon-dark.png
Normal file
BIN
static/images/favicon-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
121
static/images/favicon-dark.svg
Normal file
121
static/images/favicon-dark.svg
Normal 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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user