From 6cfb62d1a2a2777b04150257fb14be822a403c58 Mon Sep 17 00:00:00 2001 From: Bruno Bernardino Date: Sat, 24 May 2025 08:24:10 +0100 Subject: [PATCH] Refactor data handlers + misc fixes This refactors the data handlers into a more standard/understood model-like architecture, to prepare for a new, more robust config system. It also fixes a problem with creating new Notes and uploading new Photos via the web interface (related to #58). Finally, it speeds up docker builds by sending in less files, which aren't necessary or will be built anyway. This is all in preparation to allow building #13 more robustly. --- .dockerignore | 3 + .dvmrc | 2 +- Dockerfile | 2 +- components/notes/MainNotes.tsx | 1 + components/photos/MainPhotos.tsx | 1 + crons/news.ts | 4 +- lib/auth.ts | 8 +- lib/config.ts | 4 +- lib/data/dashboard.ts | 42 -- lib/data/expenses.ts | 469 --------------------- lib/data/files.ts | 504 ---------------------- lib/data/news.ts | 311 -------------- lib/data/user.ts | 286 ------------- lib/interfaces/database.ts | 2 +- lib/models/dashboard.ts | 45 ++ lib/models/expenses.ts | 479 +++++++++++++++++++++ lib/models/files.ts | 538 ++++++++++++++++++++++++ lib/models/news.ts | 315 ++++++++++++++ lib/models/user.ts | 290 +++++++++++++ routes/api/dashboard/save-links.tsx | 6 +- routes/api/dashboard/save-notes.tsx | 6 +- routes/api/expenses/add-budget.tsx | 6 +- routes/api/expenses/add-expense.tsx | 8 +- routes/api/expenses/auto-complete.tsx | 4 +- routes/api/expenses/delete-budget.tsx | 8 +- routes/api/expenses/delete-expense.tsx | 10 +- routes/api/expenses/export-expenses.tsx | 6 +- routes/api/expenses/import-expenses.tsx | 16 +- routes/api/expenses/update-budget.tsx | 8 +- routes/api/expenses/update-expense.tsx | 10 +- routes/api/files/create-directory.tsx | 6 +- routes/api/files/delete-directory.tsx | 6 +- routes/api/files/delete.tsx | 6 +- routes/api/files/get-directories.tsx | 4 +- routes/api/files/get.tsx | 4 +- routes/api/files/move-directory.tsx | 6 +- routes/api/files/move.tsx | 6 +- routes/api/files/rename-directory.tsx | 6 +- routes/api/files/rename.tsx | 6 +- routes/api/files/search.tsx | 2 +- routes/api/files/upload.tsx | 8 +- routes/api/news/add-feed.tsx | 6 +- routes/api/news/delete-feed.tsx | 8 +- routes/api/news/import-feeds.tsx | 6 +- routes/api/news/mark-read.tsx | 8 +- routes/api/news/refresh-articles.tsx | 6 +- routes/api/notes/save.tsx | 6 +- routes/dashboard.tsx | 6 +- routes/dav.tsx | 4 +- routes/expenses.tsx | 10 +- routes/files.tsx | 6 +- routes/files/open/[fileName].tsx | 4 +- routes/login.tsx | 12 +- routes/news.tsx | 4 +- routes/news/feeds.tsx | 4 +- routes/notes.tsx | 6 +- routes/notes/open/[fileName].tsx | 4 +- routes/photos.tsx | 6 +- routes/photos/thumbnail/[fileName].tsx | 4 +- routes/settings.tsx | 24 +- routes/signup.tsx | 8 +- 61 files changed, 1822 insertions(+), 1774 deletions(-) delete mode 100644 lib/data/dashboard.ts delete mode 100644 lib/data/expenses.ts delete mode 100644 lib/data/files.ts delete mode 100644 lib/data/news.ts delete mode 100644 lib/data/user.ts create mode 100644 lib/models/dashboard.ts create mode 100644 lib/models/expenses.ts create mode 100644 lib/models/files.ts create mode 100644 lib/models/news.ts create mode 100644 lib/models/user.ts diff --git a/.dockerignore b/.dockerignore index da80e51..6722551 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,6 @@ render.yaml LICENSE README.md .env.sample +node_modules +_fresh +data-files diff --git a/.dvmrc b/.dvmrc index 2bf1c1c..0bee604 100644 --- a/.dvmrc +++ b/.dvmrc @@ -1 +1 @@ -2.3.1 +2.3.3 diff --git a/Dockerfile b/Dockerfile index 63191f9..0f0f516 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM denoland/deno:ubuntu-2.3.1 +FROM denoland/deno:ubuntu-2.3.3 EXPOSE 8000 diff --git a/components/notes/MainNotes.tsx b/components/notes/MainNotes.tsx index 7904efa..2c1ebcc 100644 --- a/components/notes/MainNotes.tsx +++ b/components/notes/MainNotes.tsx @@ -55,6 +55,7 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat const requestBody = new FormData(); requestBody.set('parent_path', path.value); + requestBody.set('path_in_view', path.value); requestBody.set('name', `${newNoteName}.md`); requestBody.set('contents', `# ${newNoteName}\n\nStart your new note!\n`); diff --git a/components/photos/MainPhotos.tsx b/components/photos/MainPhotos.tsx index 0692522..241f09a 100644 --- a/components/photos/MainPhotos.tsx +++ b/components/photos/MainPhotos.tsx @@ -49,6 +49,7 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa const requestBody = new FormData(); requestBody.set('parent_path', path.value); + requestBody.set('path_in_view', path.value); requestBody.set('name', chosenFile.name); requestBody.set('contents', chosenFile); diff --git a/crons/news.ts b/crons/news.ts index e2d2a43..bc74721 100644 --- a/crons/news.ts +++ b/crons/news.ts @@ -1,7 +1,7 @@ import Database, { sql } from '/lib/interfaces/database.ts'; import { NewsFeed } from '/lib/types.ts'; import { concurrentPromises } from '/lib/utils/misc.ts'; -import { crawlNewsFeed } from '/lib/data/news.ts'; +import { FeedModel } from '/lib/models/news.ts'; const db = new Database(); @@ -18,7 +18,7 @@ export async function fetchNewArticles(forceFetch = false) { console.info('Will crawl', feedsToCrawl.length, 'news feeds'); - await concurrentPromises(feedsToCrawl.map((newsFeed) => () => crawlNewsFeed(newsFeed)), 3); + await concurrentPromises(feedsToCrawl.map((newsFeed) => () => FeedModel.crawl(newsFeed)), 3); console.info('Crawled', feedsToCrawl.length, 'news feeds'); } catch (error) { diff --git a/lib/auth.ts b/lib/auth.ts index d58120c..b6ff4bd 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -5,7 +5,7 @@ import 'std/dotenv/load.ts'; import { baseUrl, generateHash, isRunningLocally } from './utils/misc.ts'; import { User, UserSession } from './types.ts'; -import { createUserSession, deleteUserSession, getUserByEmail, validateUserAndSession } from './data/user.ts'; +import { UserModel, UserSessionModel, validateUserAndSession } from './models/user.ts'; import { isCookieDomainAllowed, isCookieDomainSecurityDisabled } from './config.ts'; const JWT_SECRET = Deno.env.get('JWT_SECRET') || ''; @@ -102,7 +102,7 @@ async function getDataFromAuthorizationHeader(authorizationHeader: string) { const hashedPassword = await generateHash(`${basicAuthPassword}:${PASSWORD_SALT}`, 'SHA-256'); - const user = await getUserByEmail(basicAuthUsername); + const user = await UserModel.getByEmail(basicAuthUsername); if (!user || (user.hashed_password !== hashedPassword && user.extra.dav_hashed_password !== hashedPassword)) { throw new Error('Email not found or invalid password.'); @@ -159,7 +159,7 @@ export async function logoutUser(request: Request) { const { session_id } = tokenData; // Delete user session - await deleteUserSession(session_id); + await UserSessionModel.delete(session_id); // Generate response with empty and expiring cookie const cookie: Cookie = { @@ -210,7 +210,7 @@ export async function createSessionCookie( response: Response, isShortLived = false, ) { - const newSession = await createUserSession(user, isShortLived); + const newSession = await UserSessionModel.create(user, isShortLived); // Generate response with session cookie const token = await generateToken({ user_id: user.id, session_id: newSession.id }); diff --git a/lib/config.ts b/lib/config.ts index 2b9ad16..497aca1 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1,11 +1,11 @@ import 'std/dotenv/load.ts'; -import { isThereAnAdmin } from './data/user.ts'; +import { UserModel } from './models/user.ts'; export async function isSignupAllowed() { const areSignupsAllowed = Deno.env.get('CONFIG_ALLOW_SIGNUPS') === 'true'; - const areThereAdmins = await isThereAnAdmin(); + const areThereAdmins = await UserModel.isThereAnAdmin(); if (areSignupsAllowed || !areThereAdmins) { return true; diff --git a/lib/data/dashboard.ts b/lib/data/dashboard.ts deleted file mode 100644 index 55e1993..0000000 --- a/lib/data/dashboard.ts +++ /dev/null @@ -1,42 +0,0 @@ -import Database, { sql } from '/lib/interfaces/database.ts'; -import { Dashboard } from '/lib/types.ts'; - -const db = new Database(); - -export async function getDashboardByUserId(userId: string) { - const dashboard = (await db.query(sql`SELECT * FROM "bewcloud_dashboards" WHERE "user_id" = $1 LIMIT 1`, [ - userId, - ]))[0]; - - return dashboard; -} - -export async function createDashboard(userId: string) { - const data: Dashboard['data'] = { links: [], notes: '' }; - - const newDashboard = (await db.query( - sql`INSERT INTO "bewcloud_dashboards" ( - "user_id", - "data" - ) VALUES ($1, $2) - RETURNING *`, - [ - userId, - JSON.stringify(data), - ], - ))[0]; - - return newDashboard; -} - -export async function updateDashboard(dashboard: Dashboard) { - await db.query( - sql`UPDATE "bewcloud_dashboards" SET - "data" = $2 - WHERE "id" = $1`, - [ - dashboard.id, - JSON.stringify(dashboard.data), - ], - ); -} diff --git a/lib/data/expenses.ts b/lib/data/expenses.ts deleted file mode 100644 index 3e9b984..0000000 --- a/lib/data/expenses.ts +++ /dev/null @@ -1,469 +0,0 @@ -import Database, { sql } from '/lib/interfaces/database.ts'; -import Locker from '/lib/interfaces/locker.ts'; -import { Budget, Expense } from '/lib/types.ts'; - -const db = new Database(); - -export async function getBudgets( - userId: string, - month: string, - { skipRecalculation = false }: { skipRecalculation?: boolean } = {}, -) { - if (!skipRecalculation) { - await recalculateMonthBudgets(userId, month); - } - - const budgets = await db.query( - sql`SELECT * FROM "bewcloud_budgets" WHERE "user_id" = $1 AND "month" = $2 ORDER BY cast("extra"->>'availableValue' as numeric) DESC, "value" DESC, "name" ASC`, - [ - userId, - month, - ], - ); - - // Numeric values come as strings, so we need to convert them to numbers - return budgets.map((budget) => ({ - ...budget, - value: Number(budget.value), - })); -} - -export async function getBudgetByName(userId: string, month: string, name: string) { - const budget = (await db.query( - sql`SELECT * FROM "bewcloud_budgets" WHERE "user_id" = $1 AND "month" = $2 AND LOWER("name") = LOWER($3)`, - [ - userId, - month, - name, - ], - ))[0]; - - if (!budget) { - return null; - } - - // Numeric values come as strings, so we need to convert them to numbers - return { - ...budget, - value: Number(budget.value), - }; -} - -export async function getBudgetById(userId: string, id: string) { - const budget = (await db.query( - sql`SELECT * FROM "bewcloud_budgets" WHERE "user_id" = $1 AND "id" = $2`, - [userId, id], - ))[0]; - - if (!budget) { - return null; - } - - // Numeric values come as strings, so we need to convert them to numbers - return { - ...budget, - value: Number(budget.value), - }; -} - -export async function getAllBudgetsForExport( - userId: string, -): Promise<(Omit & { extra: Record })[]> { - const budgets = await db.query( - sql`SELECT * FROM "bewcloud_budgets" WHERE "user_id" = $1 ORDER BY "month" DESC, "name" ASC`, - [ - userId, - ], - ); - - return budgets.map((budget) => ({ - name: budget.name, - month: budget.month, - // Numeric values come as strings, so we need to convert them to numbers - value: Number(budget.value), - extra: {}, - })); -} - -export async function getExpenses(userId: string, month: string) { - const expenses = await db.query( - sql`SELECT * FROM "bewcloud_expenses" WHERE "user_id" = $1 AND "date" >= $2 AND "date" <= $3 ORDER BY "date" DESC, "created_at" DESC`, - [ - userId, - `${month}-01`, - `${month}-31`, - ], - ); - - // Numeric values come as strings, so we need to convert them to numbers - return expenses.map((expense) => ({ - ...expense, - cost: Number(expense.cost), - })); -} - -export async function getExpenseByName(userId: string, name: string) { - const expense = (await db.query( - sql`SELECT * FROM "bewcloud_expenses" WHERE "user_id" = $1 AND LOWER("description") = LOWER($2) ORDER BY "date" DESC, "created_at" DESC`, - [ - userId, - name, - ], - ))[0]; - - if (!expense) { - return null; - } - - // Numeric values come as strings, so we need to convert them to numbers - return { - ...expense, - cost: Number(expense.cost), - }; -} - -export async function getExpenseSuggestions(userId: string, name: string) { - const expenses = await db.query>( - sql`SELECT "description" FROM "bewcloud_expenses" WHERE "user_id" = $1 AND LOWER("description") ILIKE LOWER($2) GROUP BY "description" ORDER BY LENGTH("description") ASC, "description" ASC`, - [ - userId, - `%${name}%`, - ], - ); - - return expenses.map((expense) => expense.description); -} - -export async function getAllExpensesForExport( - userId: string, -): Promise<(Omit & { extra: Record })[]> { - const expenses = await db.query( - sql`SELECT * FROM "bewcloud_expenses" WHERE "user_id" = $1 ORDER BY "date" DESC, "created_at" DESC`, - [ - userId, - ], - ); - - return expenses.map((expense) => ({ - description: expense.description, - budget: expense.budget, - date: expense.date, - is_recurring: expense.is_recurring, - // Numeric values come as strings, so we need to convert them to numbers - cost: Number(expense.cost), - extra: {}, - })); -} - -export async function getExpenseById(userId: string, id: string) { - const expense = (await db.query( - sql`SELECT * FROM "bewcloud_expenses" WHERE "user_id" = $1 AND "id" = $2`, - [userId, id], - ))[0]; - - if (!expense) { - return null; - } - - // Numeric values come as strings, so we need to convert them to numbers - return { - ...expense, - cost: Number(expense.cost), - }; -} - -export async function createBudget(userId: string, name: string, month: string, value: number) { - const extra: Budget['extra'] = { - usedValue: 0, - availableValue: value, - }; - - const newBudget = (await db.query( - sql`INSERT INTO "bewcloud_budgets" ( - "user_id", - "name", - "month", - "value", - "extra" - ) VALUES ($1, $2, $3, $4, $5) - RETURNING *`, - [ - userId, - name, - month, - value, - JSON.stringify(extra), - ], - ))[0]; - - // Numeric values come as strings, so we need to convert them to numbers - return { - ...newBudget, - value: Number(newBudget.value), - }; -} - -export async function updateBudget( - budget: Budget, - { skipRecalculation = false }: { skipRecalculation?: boolean } = {}, -) { - await db.query( - sql`UPDATE "bewcloud_budgets" SET - "name" = $2, - "month" = $3, - "value" = $4, - "extra" = $5 - WHERE "id" = $1`, - [ - budget.id, - budget.name, - budget.month, - budget.value, - JSON.stringify(budget.extra), - ], - ); - - if (!skipRecalculation) { - await recalculateMonthBudgets(budget.user_id, budget.month); - } -} - -export async function deleteBudget(userId: string, id: string) { - await db.query( - sql`DELETE FROM "bewcloud_budgets" WHERE "id" = $1 AND "user_id" = $2`, - [ - id, - userId, - ], - ); -} - -export async function deleteAllBudgetsAndExpenses(userId: string) { - await db.query( - sql`DELETE FROM "bewcloud_expenses" WHERE "user_id" = $1`, - [ - userId, - ], - ); - - await db.query( - sql`DELETE FROM "bewcloud_budgets" WHERE "user_id" = $1`, - [ - userId, - ], - ); -} - -export async function createExpense( - userId: string, - cost: number, - description: string, - budget: string, - date: string, - is_recurring: boolean, - { skipRecalculation = false, skipBudgetMatching = false, skipBudgetCreation = false }: { - skipRecalculation?: boolean; - skipBudgetMatching?: boolean; - skipBudgetCreation?: boolean; - } = {}, -) { - const extra: Expense['extra'] = {}; - - if (!budget.trim()) { - budget = 'Misc'; - } - - // Match budget to an existing expense "by default" - if (!skipBudgetMatching && budget === 'Misc') { - const existingExpense = await getExpenseByName(userId, description); - - if (existingExpense) { - budget = existingExpense.budget; - } - } - - if (!skipBudgetCreation) { - const existingBudgetInMonth = await getBudgetByName(userId, date.substring(0, 7), budget); - - if (!existingBudgetInMonth) { - await createBudget(userId, budget, date.substring(0, 7), 100); - } - } - - const newExpense = (await db.query( - sql`INSERT INTO "bewcloud_expenses" ( - "user_id", - "cost", - "description", - "budget", - "date", - "is_recurring", - "extra" - ) VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING *`, - [ - userId, - cost, - description, - budget, - date, - is_recurring, - JSON.stringify(extra), - ], - ))[0]; - - if (!skipRecalculation) { - await recalculateMonthBudgets(userId, date.substring(0, 7)); - } - - // Numeric values come as strings, so we need to convert them to numbers - return { - ...newExpense, - cost: Number(newExpense.cost), - }; -} - -export async function updateExpense(expense: Expense) { - const existingBudgetInMonth = await getBudgetByName(expense.user_id, expense.date.substring(0, 7), expense.budget); - - if (!existingBudgetInMonth) { - await createBudget(expense.user_id, expense.budget, expense.date.substring(0, 7), 100); - } - - await db.query( - sql`UPDATE "bewcloud_expenses" SET - "cost" = $2, - "description" = $3, - "budget" = $4, - "date" = $5, - "is_recurring" = $6, - "extra" = $7 - WHERE "id" = $1`, - [ - expense.id, - expense.cost, - expense.description, - expense.budget, - expense.date, - expense.is_recurring, - JSON.stringify(expense.extra), - ], - ); - - await recalculateMonthBudgets(expense.user_id, expense.date.substring(0, 7)); -} - -export async function deleteExpense(userId: string, id: string) { - const expense = await getExpenseById(userId, id); - - await db.query( - sql`DELETE FROM "bewcloud_expenses" WHERE "id" = $1 AND "user_id" = $2`, - [ - id, - userId, - ], - ); - - await recalculateMonthBudgets(userId, expense!.date.substring(0, 7)); -} - -export async function generateMonthlyBudgetsAndExpenses(userId: string, month: string) { - const lock = new Locker(`expenses:${userId}:${month}`); - - await lock.acquire(); - - let addedBudgetsCount = 0; - let addedExpensesCount = 0; - - try { - // Confirm there are no budgets or expenses for this month - const monthBudgets = await getBudgets(userId, month, { skipRecalculation: true }); - const monthExpenses = await getExpenses(userId, month); - - if (monthBudgets.length > 0 || monthExpenses.length > 0) { - throw new Error('Budgets and expenses already exist for this month!'); - } - - // Get the previous month's budgets, to copy over - const previousMonthDate = new Date(month); - previousMonthDate.setMonth(previousMonthDate.getMonth() - 1); - - const previousMonth = previousMonthDate.toISOString().substring(0, 7); - - const budgets = await getBudgets(userId, previousMonth, { skipRecalculation: true }); - - for (const budget of budgets) { - await createBudget(userId, budget.name, month, budget.value); - - addedBudgetsCount++; - } - - // Get the recurring expenses for the previous month, to copy over - const recurringExpenses = (await getExpenses(userId, previousMonth)).filter((expense) => expense.is_recurring); - - for (const expense of recurringExpenses) { - await createExpense( - userId, - expense.cost, - expense.description, - expense.budget, - expense.date.replace(previousMonth, month), - expense.is_recurring, - { skipRecalculation: true }, - ); - - addedExpensesCount++; - } - - console.info(`Added ${addedBudgetsCount} new budgets and ${addedExpensesCount} new expenses for ${month}`); - - lock.release(); - } catch (error) { - lock.release(); - - throw error; - } -} - -export async function recalculateMonthBudgets(userId: string, month: string) { - const lock = new Locker(`expenses:${userId}:${month}`); - - await lock.acquire(); - - try { - const budgets = await getBudgets(userId, month, { skipRecalculation: true }); - const expenses = await getExpenses(userId, month); - - // Calculate total expenses for each budget - const budgetExpenses = new Map(); - for (const expense of expenses) { - const currentTotal = Number(budgetExpenses.get(expense.budget) || 0); - budgetExpenses.set(expense.budget, Number(currentTotal + expense.cost)); - } - - // Update each budget with new calculations - for (const budget of budgets) { - const usedValue = Number(budgetExpenses.get(budget.name) || 0); - const availableValue = Number(budget.value - usedValue); - - const updatedBudget: Budget = { - ...budget, - extra: { - ...budget.extra, - usedValue, - availableValue, - }, - }; - - if (budget.extra.usedValue !== usedValue || budget.extra.availableValue !== availableValue) { - await updateBudget(updatedBudget, { skipRecalculation: true }); - } - } - - lock.release(); - } catch (error) { - lock.release(); - - throw error; - } -} diff --git a/lib/data/files.ts b/lib/data/files.ts deleted file mode 100644 index 12c8977..0000000 --- a/lib/data/files.ts +++ /dev/null @@ -1,504 +0,0 @@ -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[] = []; - - const directoryEntries = (await getPathEntries(userId, path)).filter((entry) => entry.isDirectory || entry.isSymlink); - - for (const entry of directoryEntries) { - const stat = await Deno.stat(join(rootPath, entry.name)); - - const directory: Directory = { - user_id: userId, - parent_path: path, - directory_name: entry.name, - has_write_access: true, - size_in_bytes: stat.size, - updated_at: stat.mtime || new Date(), - created_at: stat.birthtime || new Date(), - }; - - directories.push(directory); - } - - directories.sort(sortDirectoriesByName); - - return directories; -} - -export async function getFiles(userId: string, path: string): Promise { - ensureUserPathIsValidAndSecurelyAccessible(userId, path); - - const rootPath = join(getFilesRootPath(), userId, path); - - const files: DirectoryFile[] = []; - - const fileEntries = (await getPathEntries(userId, path)).filter((entry) => entry.isFile); - - for (const entry of fileEntries) { - const stat = await Deno.stat(join(rootPath, entry.name)); - - const file: DirectoryFile = { - user_id: userId, - parent_path: path, - file_name: entry.name, - has_write_access: true, - size_in_bytes: stat.size, - updated_at: stat.mtime || new Date(), - created_at: stat.birthtime || new Date(), - }; - - files.push(file); - } - - files.sort(sortFilesByName); - - return files; -} - -async function getPathEntries(userId: string, path: string): Promise { - ensureUserPathIsValidAndSecurelyAccessible(userId, path); - - const rootPath = join(getFilesRootPath(), userId, path); - - // Ensure the user directory exists - if (path === '/') { - try { - await Deno.stat(rootPath); - } catch (error) { - if ((error as Error).toString().includes('NotFound')) { - await Deno.mkdir(join(rootPath, TRASH_PATH), { recursive: true }); - } - } - } - - // Ensure the Notes or Photos directories exist, if being requested - if (path === '/Notes/' || path === '/Photos/') { - try { - await Deno.stat(rootPath); - } catch (error) { - if ((error as Error).toString().includes('NotFound')) { - await Deno.mkdir(rootPath, { recursive: true }); - } - } - } - - const entries: Deno.DirEntry[] = []; - - for await (const dirEntry of Deno.readDir(rootPath)) { - entries.push(dirEntry); - } - - entries.sort(sortEntriesByName); - - return entries; -} - -export async function createDirectory(userId: string, path: string, name: string): Promise { - ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name)); - - const rootPath = join(getFilesRootPath(), userId, path); - - try { - await Deno.mkdir(join(rootPath, name), { recursive: true }); - } catch (error) { - console.error(error); - return false; - } - - return true; -} - -export async function renameDirectoryOrFile( - userId: string, - oldPath: string, - newPath: string, - 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); - - try { - await Deno.rename(join(oldRootPath, oldName), join(newRootPath, newName)); - } catch (error) { - console.error(error); - return false; - } - - return true; -} - -export async function deleteDirectoryOrFile(userId: string, path: string, name: string): Promise { - ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name)); - - const rootPath = join(getFilesRootPath(), userId, path); - - try { - if (path.startsWith(TRASH_PATH)) { - await Deno.remove(join(rootPath, name), { recursive: true }); - } else { - const trashPath = join(getFilesRootPath(), userId, TRASH_PATH); - await Deno.rename(join(rootPath, name), join(trashPath, name)); - } - } catch (error) { - console.error(error); - return false; - } - - return true; -} - -export async function createFile( - userId: string, - path: string, - name: string, - contents: string | ArrayBuffer, -): Promise { - ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name)); - - const rootPath = join(getFilesRootPath(), userId, path); - - try { - // Ensure the directory exist, if being requested - try { - await Deno.stat(rootPath); - } catch (error) { - if ((error as Error).toString().includes('NotFound')) { - await Deno.mkdir(rootPath, { recursive: true }); - } - } - - if (typeof contents === 'string') { - await Deno.writeTextFile(join(rootPath, name), contents, { append: false, createNew: true }); - } else { - await Deno.writeFile(join(rootPath, name), new Uint8Array(contents), { append: false, createNew: true }); - } - } catch (error) { - console.error(error); - return false; - } - - return true; -} - -export async function updateFile( - userId: string, - path: string, - name: string, - contents: string, -): Promise { - ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name)); - - const rootPath = join(getFilesRootPath(), userId, path); - - try { - await Deno.writeTextFile(join(rootPath, name), contents, { append: false, createNew: false }); - } catch (error) { - console.error(error); - return false; - } - - return true; -} - -export async function getFile( - userId: string, - 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 { - const stat = await Deno.stat(join(rootPath, name || '')); - - if (stat) { - const contents = await Deno.readFile(join(rootPath, name || '')); - - const extension = (name || path).split('.').slice(-1).join('').toLowerCase(); - - const contentType = lookup(extension) || 'application/octet-stream'; - - return { - success: true, - contents, - contentType, - byteSize: stat.size, - }; - } - } catch (error) { - console.error(error); - } - - return { - success: false, - }; -} - -export async function searchFilesAndDirectories( - userId: string, - searchTerm: string, -): Promise<{ success: boolean; directories: Directory[]; files: DirectoryFile[] }> { - const directoryNamesResult = await searchDirectoryNames(userId, searchTerm); - const fileNamesResult = await searchFileNames(userId, searchTerm); - const fileContentsResult = await searchFileContents(userId, searchTerm); - - const success = directoryNamesResult.success && fileNamesResult.success && fileContentsResult.success; - - const directories = [...directoryNamesResult.directories]; - directories.sort(sortDirectoriesByName); - - const files = [...fileNamesResult.files, ...fileContentsResult.files]; - files.sort(sortFilesByName); - - return { - success, - directories, - files, - }; -} - -async function searchDirectoryNames( - userId: string, - searchTerm: string, -): Promise<{ success: boolean; directories: Directory[] }> { - const rootPath = join(getFilesRootPath(), userId); - - const directories: Directory[] = []; - - try { - const controller = new AbortController(); - const commandTimeout = setTimeout(() => controller.abort(), 10_000); - - const command = new Deno.Command(`find`, { - args: [ - `.`, // proper cwd is sent below - `-type`, - `d,l`, // directories and symbolic links - `-iname`, - `*${searchTerm}*`, - ], - cwd: rootPath, - signal: controller.signal, - }); - - const { code, stdout, stderr } = await command.output(); - - if (commandTimeout) { - clearTimeout(commandTimeout); - } - - if (code !== 0) { - if (stderr) { - throw new Error(new TextDecoder().decode(stderr)); - } - - throw new Error(`Unknown error running "find"`); - } - - const output = new TextDecoder().decode(stdout); - const matchingDirectories = output.split('\n').map((directoryPath) => directoryPath.trim()).filter(Boolean); - - for (const relativeDirectoryPath of matchingDirectories) { - const stat = await Deno.stat(join(rootPath, relativeDirectoryPath)); - let parentPath = `/${relativeDirectoryPath.replace('./', '/').split('/').slice(0, -1).join('')}/`; - const directoryName = relativeDirectoryPath.split('/').pop()!; - - if (parentPath === '//') { - parentPath = '/'; - } - - const directory: Directory = { - user_id: userId, - parent_path: parentPath, - directory_name: directoryName, - has_write_access: true, - size_in_bytes: stat.size, - updated_at: stat.mtime || new Date(), - created_at: stat.birthtime || new Date(), - }; - - directories.push(directory); - } - - return { success: true, directories }; - } catch (error) { - console.error(error); - } - - return { success: false, directories }; -} - -async function searchFileNames( - userId: string, - searchTerm: string, -): Promise<{ success: boolean; files: DirectoryFile[] }> { - const rootPath = join(getFilesRootPath(), userId); - - const files: DirectoryFile[] = []; - - try { - const controller = new AbortController(); - const commandTimeout = setTimeout(() => controller.abort(), 10_000); - - const command = new Deno.Command(`find`, { - args: [ - `.`, // proper cwd is sent below - `-type`, - `f`, - `-iname`, - `*${searchTerm}*`, - ], - cwd: rootPath, - signal: controller.signal, - }); - - const { code, stdout, stderr } = await command.output(); - - if (commandTimeout) { - clearTimeout(commandTimeout); - } - - if (code !== 0) { - if (stderr) { - throw new Error(new TextDecoder().decode(stderr)); - } - - throw new Error(`Unknown error running "find"`); - } - - const output = new TextDecoder().decode(stdout); - const matchingFiles = output.split('\n').map((filePath) => filePath.trim()).filter(Boolean); - - for (const relativeFilePath of matchingFiles) { - const stat = await Deno.stat(join(rootPath, relativeFilePath)); - let parentPath = `/${relativeFilePath.replace('./', '/').split('/').slice(0, -1).join('')}/`; - const fileName = relativeFilePath.split('/').pop()!; - - if (parentPath === '//') { - parentPath = '/'; - } - - const file: DirectoryFile = { - user_id: userId, - parent_path: parentPath, - file_name: fileName, - has_write_access: true, - size_in_bytes: stat.size, - updated_at: stat.mtime || new Date(), - created_at: stat.birthtime || new Date(), - }; - - files.push(file); - } - - return { success: true, files }; - } catch (error) { - console.error(error); - } - - return { success: false, files }; -} - -async function searchFileContents( - userId: string, - searchTerm: string, -): Promise<{ success: boolean; files: DirectoryFile[] }> { - const rootPath = join(getFilesRootPath(), userId); - - const files: DirectoryFile[] = []; - - try { - const controller = new AbortController(); - const commandTimeout = setTimeout(() => controller.abort(), 10_000); - - const command = new Deno.Command(`grep`, { - args: [ - `-rHisl`, - `${searchTerm}`, - `.`, // proper cwd is sent below - ], - cwd: rootPath, - signal: controller.signal, - }); - - const { code, stdout, stderr } = await command.output(); - - if (commandTimeout) { - clearTimeout(commandTimeout); - } - - if (code > 1) { - if (stderr) { - throw new Error(new TextDecoder().decode(stderr)); - } - - throw new Error(`Unknown error running "grep"`); - } - - const output = new TextDecoder().decode(stdout); - const matchingFiles = output.split('\n').map((filePath) => filePath.trim()).filter(Boolean); - - for (const relativeFilePath of matchingFiles) { - const stat = await Deno.stat(join(rootPath, relativeFilePath)); - let parentPath = `/${relativeFilePath.replace('./', '/').split('/').slice(0, -1).join('')}/`; - const fileName = relativeFilePath.split('/').pop()!; - - if (parentPath === '//') { - parentPath = '/'; - } - - const file: DirectoryFile = { - user_id: userId, - parent_path: parentPath, - file_name: fileName, - has_write_access: true, - size_in_bytes: stat.size, - updated_at: stat.mtime || new Date(), - created_at: stat.birthtime || new Date(), - }; - - files.push(file); - } - - return { success: true, files }; - } catch (error) { - console.error(error); - } - - return { success: false, files }; -} diff --git a/lib/data/news.ts b/lib/data/news.ts deleted file mode 100644 index a1d5215..0000000 --- a/lib/data/news.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { Feed } from 'https://deno.land/x/rss@1.0.0/mod.ts'; - -import Database, { sql } from '/lib/interfaces/database.ts'; -import Locker from '/lib/interfaces/locker.ts'; -import { NewsFeed, NewsFeedArticle } from '/lib/types.ts'; -import { - findFeedInUrl, - getArticleUrl, - getFeedInfo, - JsonFeed, - parseTextFromHtml, - parseUrl, - parseUrlAsGooglebot, - parseUrlWithProxy, -} from '/lib/feed.ts'; - -const db = new Database(); - -export async function getNewsFeeds(userId: string) { - const newsFeeds = await db.query(sql`SELECT * FROM "bewcloud_news_feeds" WHERE "user_id" = $1`, [ - userId, - ]); - - return newsFeeds; -} - -export async function getNewsFeed(id: string, userId: string) { - const newsFeeds = await db.query( - sql`SELECT * FROM "bewcloud_news_feeds" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`, - [ - id, - userId, - ], - ); - - return newsFeeds[0]; -} - -export async function getNewsArticles(userId: string) { - const articles = await db.query( - sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "user_id" = $1 ORDER BY "article_date" DESC`, - [ - userId, - ], - ); - - return articles; -} - -export async function getNewsArticlesByFeedId(feedId: string) { - const articles = await db.query( - sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "feed_id" = $1 ORDER BY "article_date" DESC`, - [ - feedId, - ], - ); - - return articles; -} - -export async function getNewsArticle(id: string, userId: string) { - const articles = await db.query( - sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`, - [ - id, - userId, - ], - ); - - return articles[0]; -} - -export async function createNewsFeed(userId: string, feedUrl: string) { - const extra: NewsFeed['extra'] = {}; - - const newNewsFeed = (await db.query( - sql`INSERT INTO "bewcloud_news_feeds" ( - "user_id", - "feed_url", - "extra" - ) VALUES ($1, $2, $3) - RETURNING *`, - [ - userId, - feedUrl, - JSON.stringify(extra), - ], - ))[0]; - - return newNewsFeed; -} - -export async function updateNewsFeed(newsFeed: NewsFeed) { - await db.query( - sql`UPDATE "bewcloud_news_feeds" SET - "feed_url" = $2, - "last_crawled_at" = $3, - "extra" = $4 - WHERE "id" = $1`, - [ - newsFeed.id, - newsFeed.feed_url, - newsFeed.last_crawled_at, - JSON.stringify(newsFeed.extra), - ], - ); -} - -export async function deleteNewsFeed(id: string, userId: string) { - await db.query( - sql`DELETE FROM "bewcloud_news_feed_articles" WHERE "feed_id" = $1 AND "user_id" = $2`, - [ - id, - userId, - ], - ); - - await db.query( - sql`DELETE FROM "bewcloud_news_feeds" WHERE "id" = $1 AND "user_id" = $2`, - [ - id, - userId, - ], - ); -} - -export async function createsNewsArticle( - userId: string, - feedId: string, - article: Omit, -) { - const extra: NewsFeedArticle['extra'] = {}; - - const newNewsArticle = (await db.query( - sql`INSERT INTO "bewcloud_news_feed_articles" ( - "user_id", - "feed_id", - "article_url", - "article_title", - "article_summary", - "article_date", - "extra" - ) VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING *`, - [ - userId, - feedId, - article.article_url, - article.article_title, - article.article_summary, - article.article_date, - JSON.stringify(extra), - ], - ))[0]; - - return newNewsArticle; -} - -export async function updateNewsArticle(article: NewsFeedArticle) { - await db.query( - sql`UPDATE "bewcloud_news_feed_articles" SET - "is_read" = $2, - "extra" = $3 - WHERE "id" = $1`, - [ - article.id, - article.is_read, - JSON.stringify(article.extra), - ], - ); -} - -export async function markAllArticlesRead(userId: string) { - await db.query( - sql`UPDATE "bewcloud_news_feed_articles" SET - "is_read" = TRUE - WHERE "user_id" = $1`, - [ - userId, - ], - ); -} - -async function fetchNewsArticles(newsFeed: NewsFeed): Promise { - try { - if (!newsFeed.extra.title || !newsFeed.extra.feed_type || !newsFeed.extra.crawl_type) { - throw new Error('Invalid News Feed!'); - } - - let feed: JsonFeed | Feed | null = null; - - if (newsFeed.extra.crawl_type === 'direct') { - feed = await parseUrl(newsFeed.feed_url); - } else if (newsFeed.extra.crawl_type === 'googlebot') { - feed = await parseUrlAsGooglebot(newsFeed.feed_url); - } else if (newsFeed.extra.crawl_type === 'proxy') { - feed = await parseUrlWithProxy(newsFeed.feed_url); - } - - return (feed as Feed)?.entries || (feed as JsonFeed)?.items || []; - } catch (error) { - console.error('Failed parsing feed to get articles', newsFeed.feed_url); - console.error(error); - } - - return []; -} - -type FeedArticle = Feed['entries'][number]; -type JsonFeedArticle = JsonFeed['items'][number]; - -const MAX_ARTICLES_CRAWLED_PER_RUN = 10; - -export async function crawlNewsFeed(newsFeed: NewsFeed) { - const lock = new Locker(`feeds:${newsFeed.id}`); - - await lock.acquire(); - - try { - if (!newsFeed.extra.title || !newsFeed.extra.feed_type || !newsFeed.extra.crawl_type) { - const feedUrl = await findFeedInUrl(newsFeed.feed_url); - - if (!feedUrl) { - throw new Error( - `Invalid URL for feed: "${feedUrl}"`, - ); - } - - if (feedUrl !== newsFeed.feed_url) { - newsFeed.feed_url = feedUrl; - } - - const feedInfo = await getFeedInfo(newsFeed.feed_url); - - newsFeed.extra.title = feedInfo.title; - newsFeed.extra.feed_type = feedInfo.feed_type; - newsFeed.extra.crawl_type = feedInfo.crawl_type; - } - - const feedArticles = await fetchNewsArticles(newsFeed); - - const articles: Omit[] = []; - - for (const feedArticle of feedArticles) { - // Don't add too many articles per run - if (articles.length >= MAX_ARTICLES_CRAWLED_PER_RUN) { - continue; - } - - const url = (feedArticle as JsonFeedArticle).url || getArticleUrl((feedArticle as FeedArticle).links) || - feedArticle.id; - - const articleIsoDate = (feedArticle as JsonFeedArticle).date_published || - (feedArticle as FeedArticle).published?.toISOString() || (feedArticle as JsonFeedArticle).date_modified || - (feedArticle as FeedArticle).updated?.toISOString(); - - const articleDate = articleIsoDate ? new Date(articleIsoDate) : new Date(); - - const summary = await parseTextFromHtml( - (feedArticle as FeedArticle).description?.value || (feedArticle as FeedArticle).content?.value || - (feedArticle as JsonFeedArticle).content_text || (feedArticle as JsonFeedArticle).content_html || - (feedArticle as JsonFeedArticle).summary || '', - ); - - if (url) { - articles.push({ - article_title: (feedArticle as FeedArticle).title?.value || (feedArticle as JsonFeedArticle).title || - url.replace('http://', '').replace('https://', ''), - article_url: url, - article_summary: summary, - article_date: articleDate, - }); - } - } - - const existingArticles = await getNewsArticlesByFeedId(newsFeed.id); - const existingArticleUrls = new Set(existingArticles.map((article) => article.article_url)); - const previousLatestArticleUrl = existingArticles[0]?.article_url; - let seenPreviousLatestArticleUrl = false; - let addedArticlesCount = 0; - - for (const article of articles) { - // Stop looking after seeing the previous latest article - if (article.article_url === previousLatestArticleUrl) { - seenPreviousLatestArticleUrl = true; - } - - if (!seenPreviousLatestArticleUrl && !existingArticleUrls.has(article.article_url)) { - try { - await createsNewsArticle(newsFeed.user_id, newsFeed.id, article); - ++addedArticlesCount; - } catch (error) { - console.error(error); - console.error(`Failed to add new article: "${article.article_url}"`); - } - } - } - - console.info('Added', addedArticlesCount, 'new articles'); - - newsFeed.last_crawled_at = new Date(); - - await updateNewsFeed(newsFeed); - - lock.release(); - } catch (error) { - lock.release(); - - throw error; - } -} diff --git a/lib/data/user.ts b/lib/data/user.ts deleted file mode 100644 index 02cfe5b..0000000 --- a/lib/data/user.ts +++ /dev/null @@ -1,286 +0,0 @@ -import Database, { sql } from '/lib/interfaces/database.ts'; -import { User, UserSession, VerificationCode } from '/lib/types.ts'; -import { generateRandomCode } from '/lib/utils/misc.ts'; -import { isEmailEnabled, isForeverSignupEnabled } from '/lib/config.ts'; - -const db = new Database(); - -export async function isThereAnAdmin() { - const user = - (await db.query(sql`SELECT * FROM "bewcloud_users" WHERE ("extra" ->> 'is_admin')::boolean IS TRUE LIMIT 1`))[ - 0 - ]; - - return Boolean(user); -} - -export async function getUserByEmail(email: string) { - const lowercaseEmail = email.toLowerCase().trim(); - - const user = (await db.query(sql`SELECT * FROM "bewcloud_users" WHERE "email" = $1 LIMIT 1`, [ - lowercaseEmail, - ]))[0]; - - return user; -} - -export async function getUserById(id: string) { - const user = (await db.query(sql`SELECT * FROM "bewcloud_users" WHERE "id" = $1 LIMIT 1`, [ - id, - ]))[0]; - - return user; -} - -export async function createUser(email: User['email'], hashedPassword: User['hashed_password']) { - const trialDays = isForeverSignupEnabled() ? 36_525 : 30; - const now = new Date(); - const trialEndDate = new Date(new Date().setUTCDate(new Date().getUTCDate() + trialDays)); - - const subscription: User['subscription'] = { - external: {}, - expires_at: trialEndDate.toISOString(), - updated_at: now.toISOString(), - }; - - const extra: User['extra'] = { is_email_verified: isEmailEnabled() ? false : true }; - - // First signup will be an admin "forever" - if (!(await isThereAnAdmin())) { - extra.is_admin = true; - subscription.expires_at = new Date('2100-12-31').toISOString(); - } - - const newUser = (await db.query( - sql`INSERT INTO "bewcloud_users" ( - "email", - "subscription", - "status", - "hashed_password", - "extra" - ) VALUES ($1, $2, $3, $4, $5) - RETURNING *`, - [ - email, - JSON.stringify(subscription), - extra.is_admin || isForeverSignupEnabled() ? 'active' : 'trial', - hashedPassword, - JSON.stringify(extra), - ], - ))[0]; - - return newUser; -} - -export async function updateUser(user: User) { - await db.query( - sql`UPDATE "bewcloud_users" SET - "email" = $2, - "subscription" = $3, - "status" = $4, - "hashed_password" = $5, - "extra" = $6 - WHERE "id" = $1`, - [ - user.id, - user.email, - JSON.stringify(user.subscription), - user.status, - user.hashed_password, - JSON.stringify(user.extra), - ], - ); -} - -export async function deleteUser(userId: string) { - await db.query( - sql`DELETE FROM "bewcloud_user_sessions" WHERE "user_id" = $1`, - [ - userId, - ], - ); - - await db.query( - sql`DELETE FROM "bewcloud_verification_codes" WHERE "user_id" = $1`, - [ - userId, - ], - ); - - await db.query( - sql`DELETE FROM "bewcloud_news_feed_articles" WHERE "user_id" = $1`, - [ - userId, - ], - ); - - await db.query( - sql`DELETE FROM "bewcloud_news_feeds" WHERE "user_id" = $1`, - [ - userId, - ], - ); - - await db.query( - sql`DELETE FROM "bewcloud_users" WHERE "id" = $1`, - [ - userId, - ], - ); -} - -export async function getSessionById(id: string) { - const session = (await db.query( - sql`SELECT * FROM "bewcloud_user_sessions" WHERE "id" = $1 AND "expires_at" > now() LIMIT 1`, - [ - id, - ], - ))[0]; - - return session; -} - -export async function createUserSession(user: User, isShortLived = false) { - const oneMonthFromToday = new Date(new Date().setUTCMonth(new Date().getUTCMonth() + 1)); - const oneWeekFromToday = new Date(new Date().setUTCDate(new Date().getUTCDate() + 7)); - - const newSession: Omit = { - user_id: user.id, - expires_at: isShortLived ? oneWeekFromToday : oneMonthFromToday, - last_seen_at: new Date(), - }; - - const newUserSessionResult = (await db.query( - sql`INSERT INTO "bewcloud_user_sessions" ( - "user_id", - "expires_at", - "last_seen_at" - ) VALUES ($1, $2, $3) - RETURNING *`, - [ - newSession.user_id, - newSession.expires_at, - newSession.last_seen_at, - ], - ))[0]; - - return newUserSessionResult; -} - -export async function updateSession(session: UserSession) { - await db.query( - sql`UPDATE "bewcloud_user_sessions" SET - "expires_at" = $2, - "last_seen_at" = $3 - WHERE "id" = $1`, - [ - session.id, - session.expires_at, - session.last_seen_at, - ], - ); -} - -export async function deleteUserSession(sessionId: string) { - await db.query( - sql`DELETE FROM "bewcloud_user_sessions" WHERE "id" = $1`, - [ - sessionId, - ], - ); -} - -export async function validateUserAndSession(userId: string, sessionId: string) { - const user = await getUserById(userId); - - if (!user) { - throw new Error('Not Found'); - } - - const session = await getSessionById(sessionId); - - if (!session || session.user_id !== user.id) { - throw new Error('Not Found'); - } - - const oneMonthFromToday = new Date(new Date().setUTCMonth(new Date().getUTCMonth() + 1)); - - session.last_seen_at = new Date(); - session.expires_at = oneMonthFromToday; - - await updateSession(session); - - return { user, session }; -} - -export async function createVerificationCode( - user: User, - verificationId: string, - type: VerificationCode['verification']['type'], -) { - const inThirtyMinutes = new Date(new Date().setUTCMinutes(new Date().getUTCMinutes() + 30)); - - const code = generateRandomCode(); - - const newVerificationCode: Omit = { - user_id: user.id, - code, - expires_at: inThirtyMinutes, - verification: { - id: verificationId, - type, - }, - }; - - await db.query( - sql`INSERT INTO "bewcloud_verification_codes" ( - "user_id", - "code", - "expires_at", - "verification" - ) VALUES ($1, $2, $3, $4) - RETURNING "id"`, - [ - newVerificationCode.user_id, - newVerificationCode.code, - newVerificationCode.expires_at, - JSON.stringify(newVerificationCode.verification), - ], - ); - - return code; -} - -export async function validateVerificationCode( - user: User, - verificationId: string, - code: string, - type: VerificationCode['verification']['type'], -) { - const verificationCode = (await db.query( - sql`SELECT * FROM "bewcloud_verification_codes" - WHERE "user_id" = $1 AND - "code" = $2 AND - "verification" ->> 'type' = $3 AND - "verification" ->> 'id' = $4 AND - "expires_at" > now() - LIMIT 1`, - [ - user.id, - code, - type, - verificationId, - ], - ))[0]; - - if (verificationCode) { - await db.query( - sql`DELETE FROM "bewcloud_verification_codes" WHERE "id" = $1`, - [ - verificationCode.id, - ], - ); - } else { - throw new Error('Not Found'); - } -} diff --git a/lib/interfaces/database.ts b/lib/interfaces/database.ts index 1ffea29..bb1aff7 100644 --- a/lib/interfaces/database.ts +++ b/lib/interfaces/database.ts @@ -1,4 +1,4 @@ -import { Client } from 'https://deno.land/x/postgres@v0.19.2/mod.ts'; +import { Client } from 'https://deno.land/x/postgres@v0.19.3/mod.ts'; import 'std/dotenv/load.ts'; const POSTGRESQL_HOST = Deno.env.get('POSTGRESQL_HOST') || ''; diff --git a/lib/models/dashboard.ts b/lib/models/dashboard.ts new file mode 100644 index 0000000..fd49ec0 --- /dev/null +++ b/lib/models/dashboard.ts @@ -0,0 +1,45 @@ +import Database, { sql } from '/lib/interfaces/database.ts'; +import { Dashboard } from '/lib/types.ts'; + +const db = new Database(); + +export class DashboardModel { + static async getByUserId(userId: string) { + const dashboard = + (await db.query(sql`SELECT * FROM "bewcloud_dashboards" WHERE "user_id" = $1 LIMIT 1`, [ + userId, + ]))[0]; + + return dashboard; + } + + static async create(userId: string) { + const data: Dashboard['data'] = { links: [], notes: '' }; + + const newDashboard = (await db.query( + sql`INSERT INTO "bewcloud_dashboards" ( + "user_id", + "data" + ) VALUES ($1, $2) + RETURNING *`, + [ + userId, + JSON.stringify(data), + ], + ))[0]; + + return newDashboard; + } + + static async update(dashboard: Dashboard) { + await db.query( + sql`UPDATE "bewcloud_dashboards" SET + "data" = $2 + WHERE "id" = $1`, + [ + dashboard.id, + JSON.stringify(dashboard.data), + ], + ); + } +} diff --git a/lib/models/expenses.ts b/lib/models/expenses.ts new file mode 100644 index 0000000..d34c5ca --- /dev/null +++ b/lib/models/expenses.ts @@ -0,0 +1,479 @@ +import Database, { sql } from '/lib/interfaces/database.ts'; +import Locker from '/lib/interfaces/locker.ts'; +import { Budget, Expense } from '/lib/types.ts'; + +const db = new Database(); + +export class BudgetModel { + static async list( + userId: string, + month: string, + { skipRecalculation = false }: { skipRecalculation?: boolean } = {}, + ) { + if (!skipRecalculation) { + await recalculateMonthBudgets(userId, month); + } + + const budgets = await db.query( + sql`SELECT * FROM "bewcloud_budgets" WHERE "user_id" = $1 AND "month" = $2 ORDER BY cast("extra"->>'availableValue' as numeric) DESC, "value" DESC, "name" ASC`, + [ + userId, + month, + ], + ); + + // Numeric values come as strings, so we need to convert them to numbers + return budgets.map((budget) => ({ + ...budget, + value: Number(budget.value), + })); + } + + static async getByName(userId: string, month: string, name: string) { + const budget = (await db.query( + sql`SELECT * FROM "bewcloud_budgets" WHERE "user_id" = $1 AND "month" = $2 AND LOWER("name") = LOWER($3)`, + [ + userId, + month, + name, + ], + ))[0]; + + if (!budget) { + return null; + } + + // Numeric values come as strings, so we need to convert them to numbers + return { + ...budget, + value: Number(budget.value), + }; + } + + static async getById(userId: string, id: string) { + const budget = (await db.query( + sql`SELECT * FROM "bewcloud_budgets" WHERE "user_id" = $1 AND "id" = $2`, + [userId, id], + ))[0]; + + if (!budget) { + return null; + } + + // Numeric values come as strings, so we need to convert them to numbers + return { + ...budget, + value: Number(budget.value), + }; + } + + static async getAllForExport( + userId: string, + ): Promise<(Omit & { extra: Record })[]> { + const budgets = await db.query( + sql`SELECT * FROM "bewcloud_budgets" WHERE "user_id" = $1 ORDER BY "month" DESC, "name" ASC`, + [ + userId, + ], + ); + + return budgets.map((budget) => ({ + name: budget.name, + month: budget.month, + // Numeric values come as strings, so we need to convert them to numbers + value: Number(budget.value), + extra: {}, + })); + } + + static async create(userId: string, name: string, month: string, value: number) { + const extra: Budget['extra'] = { + usedValue: 0, + availableValue: value, + }; + + const newBudget = (await db.query( + sql`INSERT INTO "bewcloud_budgets" ( + "user_id", + "name", + "month", + "value", + "extra" + ) VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [ + userId, + name, + month, + value, + JSON.stringify(extra), + ], + ))[0]; + + // Numeric values come as strings, so we need to convert them to numbers + return { + ...newBudget, + value: Number(newBudget.value), + }; + } + + static async update( + budget: Budget, + { skipRecalculation = false }: { skipRecalculation?: boolean } = {}, + ) { + await db.query( + sql`UPDATE "bewcloud_budgets" SET + "name" = $2, + "month" = $3, + "value" = $4, + "extra" = $5 + WHERE "id" = $1`, + [ + budget.id, + budget.name, + budget.month, + budget.value, + JSON.stringify(budget.extra), + ], + ); + + if (!skipRecalculation) { + await recalculateMonthBudgets(budget.user_id, budget.month); + } + } + + static async delete(userId: string, id: string) { + await db.query( + sql`DELETE FROM "bewcloud_budgets" WHERE "id" = $1 AND "user_id" = $2`, + [ + id, + userId, + ], + ); + } +} + +export class ExpenseModel { + static async list(userId: string, month: string) { + const expenses = await db.query( + sql`SELECT * FROM "bewcloud_expenses" WHERE "user_id" = $1 AND "date" >= $2 AND "date" <= $3 ORDER BY "date" DESC, "created_at" DESC`, + [ + userId, + `${month}-01`, + `${month}-31`, + ], + ); + + // Numeric values come as strings, so we need to convert them to numbers + return expenses.map((expense) => ({ + ...expense, + cost: Number(expense.cost), + })); + } + + static async getByName(userId: string, name: string) { + const expense = (await db.query( + sql`SELECT * FROM "bewcloud_expenses" WHERE "user_id" = $1 AND LOWER("description") = LOWER($2) ORDER BY "date" DESC, "created_at" DESC`, + [ + userId, + name, + ], + ))[0]; + + if (!expense) { + return null; + } + + // Numeric values come as strings, so we need to convert them to numbers + return { + ...expense, + cost: Number(expense.cost), + }; + } + + static async listSuggestions(userId: string, name: string) { + const expenses = await db.query>( + sql`SELECT "description" FROM "bewcloud_expenses" WHERE "user_id" = $1 AND LOWER("description") ILIKE LOWER($2) GROUP BY "description" ORDER BY LENGTH("description") ASC, "description" ASC`, + [ + userId, + `%${name}%`, + ], + ); + + return expenses.map((expense) => expense.description); + } + + static async getAllForExport( + userId: string, + ): Promise<(Omit & { extra: Record })[]> { + const expenses = await db.query( + sql`SELECT * FROM "bewcloud_expenses" WHERE "user_id" = $1 ORDER BY "date" DESC, "created_at" DESC`, + [ + userId, + ], + ); + + return expenses.map((expense) => ({ + description: expense.description, + budget: expense.budget, + date: expense.date, + is_recurring: expense.is_recurring, + // Numeric values come as strings, so we need to convert them to numbers + cost: Number(expense.cost), + extra: {}, + })); + } + + static async getById(userId: string, id: string) { + const expense = (await db.query( + sql`SELECT * FROM "bewcloud_expenses" WHERE "user_id" = $1 AND "id" = $2`, + [userId, id], + ))[0]; + + if (!expense) { + return null; + } + + // Numeric values come as strings, so we need to convert them to numbers + return { + ...expense, + cost: Number(expense.cost), + }; + } + + static async create( + userId: string, + cost: number, + description: string, + budget: string, + date: string, + is_recurring: boolean, + { skipRecalculation = false, skipBudgetMatching = false, skipBudgetCreation = false }: { + skipRecalculation?: boolean; + skipBudgetMatching?: boolean; + skipBudgetCreation?: boolean; + } = {}, + ) { + const extra: Expense['extra'] = {}; + + if (!budget.trim()) { + budget = 'Misc'; + } + + // Match budget to an existing expense "by default" + if (!skipBudgetMatching && budget === 'Misc') { + const existingExpense = await this.getByName(userId, description); + + if (existingExpense) { + budget = existingExpense.budget; + } + } + + if (!skipBudgetCreation) { + const existingBudgetInMonth = await BudgetModel.getByName(userId, date.substring(0, 7), budget); + + if (!existingBudgetInMonth) { + await BudgetModel.create(userId, budget, date.substring(0, 7), 100); + } + } + + const newExpense = (await db.query( + sql`INSERT INTO "bewcloud_expenses" ( + "user_id", + "cost", + "description", + "budget", + "date", + "is_recurring", + "extra" + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [ + userId, + cost, + description, + budget, + date, + is_recurring, + JSON.stringify(extra), + ], + ))[0]; + + if (!skipRecalculation) { + await recalculateMonthBudgets(userId, date.substring(0, 7)); + } + + // Numeric values come as strings, so we need to convert them to numbers + return { + ...newExpense, + cost: Number(newExpense.cost), + }; + } + + static async update(expense: Expense) { + const existingBudgetInMonth = await BudgetModel.getByName( + expense.user_id, + expense.date.substring(0, 7), + expense.budget, + ); + + if (!existingBudgetInMonth) { + await BudgetModel.create(expense.user_id, expense.budget, expense.date.substring(0, 7), 100); + } + + await db.query( + sql`UPDATE "bewcloud_expenses" SET + "cost" = $2, + "description" = $3, + "budget" = $4, + "date" = $5, + "is_recurring" = $6, + "extra" = $7 + WHERE "id" = $1`, + [ + expense.id, + expense.cost, + expense.description, + expense.budget, + expense.date, + expense.is_recurring, + JSON.stringify(expense.extra), + ], + ); + + await recalculateMonthBudgets(expense.user_id, expense.date.substring(0, 7)); + } + + static async delete(userId: string, id: string) { + const expense = await this.getById(userId, id); + + await db.query( + sql`DELETE FROM "bewcloud_expenses" WHERE "id" = $1 AND "user_id" = $2`, + [ + id, + userId, + ], + ); + + await recalculateMonthBudgets(userId, expense!.date.substring(0, 7)); + } +} + +export async function deleteAllBudgetsAndExpenses(userId: string) { + await db.query( + sql`DELETE FROM "bewcloud_expenses" WHERE "user_id" = $1`, + [ + userId, + ], + ); + + await db.query( + sql`DELETE FROM "bewcloud_budgets" WHERE "user_id" = $1`, + [ + userId, + ], + ); +} + +export async function generateMonthlyBudgetsAndExpenses(userId: string, month: string) { + const lock = new Locker(`expenses:${userId}:${month}`); + + await lock.acquire(); + + let addedBudgetsCount = 0; + let addedExpensesCount = 0; + + try { + // Confirm there are no budgets or expenses for this month + const monthBudgets = await BudgetModel.list(userId, month, { skipRecalculation: true }); + const monthExpenses = await ExpenseModel.list(userId, month); + + if (monthBudgets.length > 0 || monthExpenses.length > 0) { + throw new Error('Budgets and expenses already exist for this month!'); + } + + // Get the previous month's budgets, to copy over + const previousMonthDate = new Date(month); + previousMonthDate.setMonth(previousMonthDate.getMonth() - 1); + + const previousMonth = previousMonthDate.toISOString().substring(0, 7); + + const budgets = await BudgetModel.list(userId, previousMonth, { skipRecalculation: true }); + + for (const budget of budgets) { + await BudgetModel.create(userId, budget.name, month, budget.value); + + addedBudgetsCount++; + } + + // Get the recurring expenses for the previous month, to copy over + const recurringExpenses = (await ExpenseModel.list(userId, previousMonth)).filter((expense) => + expense.is_recurring + ); + + for (const expense of recurringExpenses) { + await ExpenseModel.create( + userId, + expense.cost, + expense.description, + expense.budget, + expense.date.replace(previousMonth, month), + expense.is_recurring, + { skipRecalculation: true }, + ); + + addedExpensesCount++; + } + + console.info(`Added ${addedBudgetsCount} new budgets and ${addedExpensesCount} new expenses for ${month}`); + + lock.release(); + } catch (error) { + lock.release(); + + throw error; + } +} + +export async function recalculateMonthBudgets(userId: string, month: string) { + const lock = new Locker(`expenses:${userId}:${month}`); + + await lock.acquire(); + + try { + const budgets = await BudgetModel.list(userId, month, { skipRecalculation: true }); + const expenses = await ExpenseModel.list(userId, month); + + // Calculate total expenses for each budget + const budgetExpenses = new Map(); + for (const expense of expenses) { + const currentTotal = Number(budgetExpenses.get(expense.budget) || 0); + budgetExpenses.set(expense.budget, Number(currentTotal + expense.cost)); + } + + // Update each budget with new calculations + for (const budget of budgets) { + const usedValue = Number(budgetExpenses.get(budget.name) || 0); + const availableValue = Number(budget.value - usedValue); + + const updatedBudget: Budget = { + ...budget, + extra: { + ...budget.extra, + usedValue, + availableValue, + }, + }; + + if (budget.extra.usedValue !== usedValue || budget.extra.availableValue !== availableValue) { + await BudgetModel.update(updatedBudget, { skipRecalculation: true }); + } + } + + lock.release(); + } catch (error) { + lock.release(); + + throw error; + } +} diff --git a/lib/models/files.ts b/lib/models/files.ts new file mode 100644 index 0000000..32f3f4b --- /dev/null +++ b/lib/models/files.ts @@ -0,0 +1,538 @@ +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'; + +export class DirectoryModel { + static async list(userId: string, path: string): Promise { + ensureUserPathIsValidAndSecurelyAccessible(userId, path); + + const rootPath = join(getFilesRootPath(), userId, path); + + const directories: Directory[] = []; + + const directoryEntries = (await getPathEntries(userId, path)).filter((entry) => + entry.isDirectory || entry.isSymlink + ); + + for (const entry of directoryEntries) { + const stat = await Deno.stat(join(rootPath, entry.name)); + + const directory: Directory = { + user_id: userId, + parent_path: path, + directory_name: entry.name, + has_write_access: true, + size_in_bytes: stat.size, + updated_at: stat.mtime || new Date(), + created_at: stat.birthtime || new Date(), + }; + + directories.push(directory); + } + + directories.sort(sortDirectoriesByName); + + return directories; + } + + static async create(userId: string, path: string, name: string): Promise { + ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name)); + + const rootPath = join(getFilesRootPath(), userId, path); + + try { + await Deno.mkdir(join(rootPath, name), { recursive: true }); + } catch (error) { + console.error(error); + return false; + } + + return true; + } + + static async rename( + userId: string, + oldPath: string, + newPath: string, + oldName: string, + newName: string, + ): Promise { + return await renameDirectoryOrFile(userId, oldPath, newPath, oldName, newName); + } + + static async delete(userId: string, path: string, name: string): Promise { + return await deleteDirectoryOrFile(userId, path, name); + } + + static async searchNames( + userId: string, + searchTerm: string, + ): Promise<{ success: boolean; directories: Directory[] }> { + const rootPath = join(getFilesRootPath(), userId); + + const directories: Directory[] = []; + + try { + const controller = new AbortController(); + const commandTimeout = setTimeout(() => controller.abort(), 10_000); + + const command = new Deno.Command(`find`, { + args: [ + `.`, // proper cwd is sent below + `-type`, + `d,l`, // directories and symbolic links + `-iname`, + `*${searchTerm}*`, + ], + cwd: rootPath, + signal: controller.signal, + }); + + const { code, stdout, stderr } = await command.output(); + + if (commandTimeout) { + clearTimeout(commandTimeout); + } + + if (code !== 0) { + if (stderr) { + throw new Error(new TextDecoder().decode(stderr)); + } + + throw new Error(`Unknown error running "find"`); + } + + const output = new TextDecoder().decode(stdout); + const matchingDirectories = output.split('\n').map((directoryPath) => directoryPath.trim()).filter(Boolean); + + for (const relativeDirectoryPath of matchingDirectories) { + const stat = await Deno.stat(join(rootPath, relativeDirectoryPath)); + let parentPath = `/${relativeDirectoryPath.replace('./', '/').split('/').slice(0, -1).join('')}/`; + const directoryName = relativeDirectoryPath.split('/').pop()!; + + if (parentPath === '//') { + parentPath = '/'; + } + + const directory: Directory = { + user_id: userId, + parent_path: parentPath, + directory_name: directoryName, + has_write_access: true, + size_in_bytes: stat.size, + updated_at: stat.mtime || new Date(), + created_at: stat.birthtime || new Date(), + }; + + directories.push(directory); + } + + return { success: true, directories }; + } catch (error) { + console.error(error); + } + + return { success: false, directories }; + } +} + +export class FileModel { + static async list(userId: string, path: string): Promise { + ensureUserPathIsValidAndSecurelyAccessible(userId, path); + + const rootPath = join(getFilesRootPath(), userId, path); + + const files: DirectoryFile[] = []; + + const fileEntries = (await getPathEntries(userId, path)).filter((entry) => entry.isFile); + + for (const entry of fileEntries) { + const stat = await Deno.stat(join(rootPath, entry.name)); + + const file: DirectoryFile = { + user_id: userId, + parent_path: path, + file_name: entry.name, + has_write_access: true, + size_in_bytes: stat.size, + updated_at: stat.mtime || new Date(), + created_at: stat.birthtime || new Date(), + }; + + files.push(file); + } + + files.sort(sortFilesByName); + + return files; + } + + static async create( + userId: string, + path: string, + name: string, + contents: string | ArrayBuffer, + ): Promise { + ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name)); + + const rootPath = join(getFilesRootPath(), userId, path); + + try { + // Ensure the directory exist, if being requested + try { + await Deno.stat(rootPath); + } catch (error) { + if ((error as Error).toString().includes('NotFound')) { + await Deno.mkdir(rootPath, { recursive: true }); + } + } + + if (typeof contents === 'string') { + await Deno.writeTextFile(join(rootPath, name), contents, { append: false, createNew: true }); + } else { + await Deno.writeFile(join(rootPath, name), new Uint8Array(contents), { append: false, createNew: true }); + } + } catch (error) { + console.error(error); + return false; + } + + return true; + } + + static async update( + userId: string, + path: string, + name: string, + contents: string, + ): Promise { + ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name)); + + const rootPath = join(getFilesRootPath(), userId, path); + + try { + await Deno.writeTextFile(join(rootPath, name), contents, { append: false, createNew: false }); + } catch (error) { + console.error(error); + return false; + } + + return true; + } + + static async get( + userId: string, + 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 { + const stat = await Deno.stat(join(rootPath, name || '')); + + if (stat) { + const contents = await Deno.readFile(join(rootPath, name || '')); + + const extension = (name || path).split('.').slice(-1).join('').toLowerCase(); + + const contentType = lookup(extension) || 'application/octet-stream'; + + return { + success: true, + contents, + contentType, + byteSize: stat.size, + }; + } + } catch (error) { + console.error(error); + } + + return { + success: false, + }; + } + + static async rename( + userId: string, + oldPath: string, + newPath: string, + oldName: string, + newName: string, + ): Promise { + return await renameDirectoryOrFile(userId, oldPath, newPath, oldName, newName); + } + + static async delete(userId: string, path: string, name: string): Promise { + return await deleteDirectoryOrFile(userId, path, name); + } + + static async searchNames( + userId: string, + searchTerm: string, + ): Promise<{ success: boolean; files: DirectoryFile[] }> { + const rootPath = join(getFilesRootPath(), userId); + + const files: DirectoryFile[] = []; + + try { + const controller = new AbortController(); + const commandTimeout = setTimeout(() => controller.abort(), 10_000); + + const command = new Deno.Command(`find`, { + args: [ + `.`, // proper cwd is sent below + `-type`, + `f`, + `-iname`, + `*${searchTerm}*`, + ], + cwd: rootPath, + signal: controller.signal, + }); + + const { code, stdout, stderr } = await command.output(); + + if (commandTimeout) { + clearTimeout(commandTimeout); + } + + if (code !== 0) { + if (stderr) { + throw new Error(new TextDecoder().decode(stderr)); + } + + throw new Error(`Unknown error running "find"`); + } + + const output = new TextDecoder().decode(stdout); + const matchingFiles = output.split('\n').map((filePath) => filePath.trim()).filter(Boolean); + + for (const relativeFilePath of matchingFiles) { + const stat = await Deno.stat(join(rootPath, relativeFilePath)); + let parentPath = `/${relativeFilePath.replace('./', '/').split('/').slice(0, -1).join('')}/`; + const fileName = relativeFilePath.split('/').pop()!; + + if (parentPath === '//') { + parentPath = '/'; + } + + const file: DirectoryFile = { + user_id: userId, + parent_path: parentPath, + file_name: fileName, + has_write_access: true, + size_in_bytes: stat.size, + updated_at: stat.mtime || new Date(), + created_at: stat.birthtime || new Date(), + }; + + files.push(file); + } + + return { success: true, files }; + } catch (error) { + console.error(error); + } + + return { success: false, files }; + } + + static async searchContents( + userId: string, + searchTerm: string, + ): Promise<{ success: boolean; files: DirectoryFile[] }> { + const rootPath = join(getFilesRootPath(), userId); + + const files: DirectoryFile[] = []; + + try { + const controller = new AbortController(); + const commandTimeout = setTimeout(() => controller.abort(), 10_000); + + const command = new Deno.Command(`grep`, { + args: [ + `-rHisl`, + `${searchTerm}`, + `.`, // proper cwd is sent below + ], + cwd: rootPath, + signal: controller.signal, + }); + + const { code, stdout, stderr } = await command.output(); + + if (commandTimeout) { + clearTimeout(commandTimeout); + } + + if (code > 1) { + if (stderr) { + throw new Error(new TextDecoder().decode(stderr)); + } + + throw new Error(`Unknown error running "grep"`); + } + + const output = new TextDecoder().decode(stdout); + const matchingFiles = output.split('\n').map((filePath) => filePath.trim()).filter(Boolean); + + for (const relativeFilePath of matchingFiles) { + const stat = await Deno.stat(join(rootPath, relativeFilePath)); + let parentPath = `/${relativeFilePath.replace('./', '/').split('/').slice(0, -1).join('')}/`; + const fileName = relativeFilePath.split('/').pop()!; + + if (parentPath === '//') { + parentPath = '/'; + } + + const file: DirectoryFile = { + user_id: userId, + parent_path: parentPath, + file_name: fileName, + has_write_access: true, + size_in_bytes: stat.size, + updated_at: stat.mtime || new Date(), + created_at: stat.birthtime || new Date(), + }; + + files.push(file); + } + + return { success: true, files }; + } catch (error) { + console.error(error); + } + + return { success: false, files }; + } +} + +/** + * 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'); + } +} + +async function getPathEntries(userId: string, path: string): Promise { + ensureUserPathIsValidAndSecurelyAccessible(userId, path); + + const rootPath = join(getFilesRootPath(), userId, path); + + // Ensure the user directory exists + if (path === '/') { + try { + await Deno.stat(rootPath); + } catch (error) { + if ((error as Error).toString().includes('NotFound')) { + await Deno.mkdir(join(rootPath, TRASH_PATH), { recursive: true }); + } + } + } + + // Ensure the Notes or Photos directories exist, if being requested + if (path === '/Notes/' || path === '/Photos/') { + try { + await Deno.stat(rootPath); + } catch (error) { + if ((error as Error).toString().includes('NotFound')) { + await Deno.mkdir(rootPath, { recursive: true }); + } + } + } + + const entries: Deno.DirEntry[] = []; + + for await (const dirEntry of Deno.readDir(rootPath)) { + entries.push(dirEntry); + } + + entries.sort(sortEntriesByName); + + return entries; +} + +async function renameDirectoryOrFile( + userId: string, + oldPath: string, + newPath: string, + 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); + + try { + await Deno.rename(join(oldRootPath, oldName), join(newRootPath, newName)); + } catch (error) { + console.error(error); + return false; + } + + return true; +} + +async function deleteDirectoryOrFile(userId: string, path: string, name: string): Promise { + ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name)); + + const rootPath = join(getFilesRootPath(), userId, path); + + try { + if (path.startsWith(TRASH_PATH)) { + await Deno.remove(join(rootPath, name), { recursive: true }); + } else { + const trashPath = join(getFilesRootPath(), userId, TRASH_PATH); + await Deno.rename(join(rootPath, name), join(trashPath, name)); + } + } catch (error) { + console.error(error); + return false; + } + + return true; +} + +export async function searchFilesAndDirectories( + userId: string, + searchTerm: string, +): Promise<{ success: boolean; directories: Directory[]; files: DirectoryFile[] }> { + const directoryNamesResult = await DirectoryModel.searchNames(userId, searchTerm); + const fileNamesResult = await FileModel.searchNames(userId, searchTerm); + const fileContentsResult = await FileModel.searchContents(userId, searchTerm); + + const success = directoryNamesResult.success && fileNamesResult.success && fileContentsResult.success; + + const directories = [...directoryNamesResult.directories]; + directories.sort(sortDirectoriesByName); + + const files = [...fileNamesResult.files, ...fileContentsResult.files]; + files.sort(sortFilesByName); + + return { + success, + directories, + files, + }; +} diff --git a/lib/models/news.ts b/lib/models/news.ts new file mode 100644 index 0000000..70a1d74 --- /dev/null +++ b/lib/models/news.ts @@ -0,0 +1,315 @@ +import { Feed } from 'https://deno.land/x/rss@1.0.0/mod.ts'; + +import Database, { sql } from '/lib/interfaces/database.ts'; +import Locker from '/lib/interfaces/locker.ts'; +import { NewsFeed, NewsFeedArticle } from '/lib/types.ts'; +import { + findFeedInUrl, + getArticleUrl, + getFeedInfo, + JsonFeed, + parseTextFromHtml, + parseUrl, + parseUrlAsGooglebot, + parseUrlWithProxy, +} from '/lib/feed.ts'; + +const db = new Database(); + +export class FeedModel { + static async list(userId: string) { + const newsFeeds = await db.query(sql`SELECT * FROM "bewcloud_news_feeds" WHERE "user_id" = $1`, [ + userId, + ]); + + return newsFeeds; + } + + static async get(id: string, userId: string) { + const newsFeeds = await db.query( + sql`SELECT * FROM "bewcloud_news_feeds" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`, + [ + id, + userId, + ], + ); + + return newsFeeds[0]; + } + + static async create(userId: string, feedUrl: string) { + const extra: NewsFeed['extra'] = {}; + + const newNewsFeed = (await db.query( + sql`INSERT INTO "bewcloud_news_feeds" ( + "user_id", + "feed_url", + "extra" + ) VALUES ($1, $2, $3) + RETURNING *`, + [ + userId, + feedUrl, + JSON.stringify(extra), + ], + ))[0]; + + return newNewsFeed; + } + + static async update(newsFeed: NewsFeed) { + await db.query( + sql`UPDATE "bewcloud_news_feeds" SET + "feed_url" = $2, + "last_crawled_at" = $3, + "extra" = $4 + WHERE "id" = $1`, + [ + newsFeed.id, + newsFeed.feed_url, + newsFeed.last_crawled_at, + JSON.stringify(newsFeed.extra), + ], + ); + } + + static async delete(id: string, userId: string) { + await db.query( + sql`DELETE FROM "bewcloud_news_feed_articles" WHERE "feed_id" = $1 AND "user_id" = $2`, + [ + id, + userId, + ], + ); + + await db.query( + sql`DELETE FROM "bewcloud_news_feeds" WHERE "id" = $1 AND "user_id" = $2`, + [ + id, + userId, + ], + ); + } + + static async crawl(newsFeed: NewsFeed) { + type FeedArticle = Feed['entries'][number]; + type JsonFeedArticle = JsonFeed['items'][number]; + + const MAX_ARTICLES_CRAWLED_PER_RUN = 10; + + const lock = new Locker(`feeds:${newsFeed.id}`); + + await lock.acquire(); + + try { + if (!newsFeed.extra.title || !newsFeed.extra.feed_type || !newsFeed.extra.crawl_type) { + const feedUrl = await findFeedInUrl(newsFeed.feed_url); + + if (!feedUrl) { + throw new Error( + `Invalid URL for feed: "${feedUrl}"`, + ); + } + + if (feedUrl !== newsFeed.feed_url) { + newsFeed.feed_url = feedUrl; + } + + const feedInfo = await getFeedInfo(newsFeed.feed_url); + + newsFeed.extra.title = feedInfo.title; + newsFeed.extra.feed_type = feedInfo.feed_type; + newsFeed.extra.crawl_type = feedInfo.crawl_type; + } + + const feedArticles = await fetchNewsArticles(newsFeed); + + const articles: Omit[] = []; + + for (const feedArticle of feedArticles) { + // Don't add too many articles per run + if (articles.length >= MAX_ARTICLES_CRAWLED_PER_RUN) { + continue; + } + + const url = (feedArticle as JsonFeedArticle).url || getArticleUrl((feedArticle as FeedArticle).links) || + feedArticle.id; + + const articleIsoDate = (feedArticle as JsonFeedArticle).date_published || + (feedArticle as FeedArticle).published?.toISOString() || (feedArticle as JsonFeedArticle).date_modified || + (feedArticle as FeedArticle).updated?.toISOString(); + + const articleDate = articleIsoDate ? new Date(articleIsoDate) : new Date(); + + const summary = await parseTextFromHtml( + (feedArticle as FeedArticle).description?.value || (feedArticle as FeedArticle).content?.value || + (feedArticle as JsonFeedArticle).content_text || (feedArticle as JsonFeedArticle).content_html || + (feedArticle as JsonFeedArticle).summary || '', + ); + + if (url) { + articles.push({ + article_title: (feedArticle as FeedArticle).title?.value || (feedArticle as JsonFeedArticle).title || + url.replace('http://', '').replace('https://', ''), + article_url: url, + article_summary: summary, + article_date: articleDate, + }); + } + } + + const existingArticles = await ArticleModel.listByFeedId(newsFeed.id); + const existingArticleUrls = new Set(existingArticles.map((article) => article.article_url)); + const previousLatestArticleUrl = existingArticles[0]?.article_url; + let seenPreviousLatestArticleUrl = false; + let addedArticlesCount = 0; + + for (const article of articles) { + // Stop looking after seeing the previous latest article + if (article.article_url === previousLatestArticleUrl) { + seenPreviousLatestArticleUrl = true; + } + + if (!seenPreviousLatestArticleUrl && !existingArticleUrls.has(article.article_url)) { + try { + await ArticleModel.create(newsFeed.user_id, newsFeed.id, article); + ++addedArticlesCount; + } catch (error) { + console.error(error); + console.error(`Failed to add new article: "${article.article_url}"`); + } + } + } + + console.info('Added', addedArticlesCount, 'new articles'); + + newsFeed.last_crawled_at = new Date(); + + await this.update(newsFeed); + + lock.release(); + } catch (error) { + lock.release(); + + throw error; + } + } +} + +export class ArticleModel { + static async list(userId: string) { + const articles = await db.query( + sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "user_id" = $1 ORDER BY "article_date" DESC`, + [ + userId, + ], + ); + + return articles; + } + + static async listByFeedId(feedId: string) { + const articles = await db.query( + sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "feed_id" = $1 ORDER BY "article_date" DESC`, + [ + feedId, + ], + ); + + return articles; + } + + static async get(id: string, userId: string) { + const articles = await db.query( + sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`, + [ + id, + userId, + ], + ); + + return articles[0]; + } + + static async create( + userId: string, + feedId: string, + article: Omit, + ) { + const extra: NewsFeedArticle['extra'] = {}; + + const newNewsArticle = (await db.query( + sql`INSERT INTO "bewcloud_news_feed_articles" ( + "user_id", + "feed_id", + "article_url", + "article_title", + "article_summary", + "article_date", + "extra" + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [ + userId, + feedId, + article.article_url, + article.article_title, + article.article_summary, + article.article_date, + JSON.stringify(extra), + ], + ))[0]; + + return newNewsArticle; + } + + static async update(article: NewsFeedArticle) { + await db.query( + sql`UPDATE "bewcloud_news_feed_articles" SET + "is_read" = $2, + "extra" = $3 + WHERE "id" = $1`, + [ + article.id, + article.is_read, + JSON.stringify(article.extra), + ], + ); + } + + static async markAllRead(userId: string) { + await db.query( + sql`UPDATE "bewcloud_news_feed_articles" SET + "is_read" = TRUE + WHERE "user_id" = $1`, + [ + userId, + ], + ); + } +} + +async function fetchNewsArticles(newsFeed: NewsFeed): Promise { + try { + if (!newsFeed.extra.title || !newsFeed.extra.feed_type || !newsFeed.extra.crawl_type) { + throw new Error('Invalid News Feed!'); + } + + let feed: JsonFeed | Feed | null = null; + + if (newsFeed.extra.crawl_type === 'direct') { + feed = await parseUrl(newsFeed.feed_url); + } else if (newsFeed.extra.crawl_type === 'googlebot') { + feed = await parseUrlAsGooglebot(newsFeed.feed_url); + } else if (newsFeed.extra.crawl_type === 'proxy') { + feed = await parseUrlWithProxy(newsFeed.feed_url); + } + + return (feed as Feed)?.entries || (feed as JsonFeed)?.items || []; + } catch (error) { + console.error('Failed parsing feed to get articles', newsFeed.feed_url); + console.error(error); + } + + return []; +} diff --git a/lib/models/user.ts b/lib/models/user.ts new file mode 100644 index 0000000..885e9ba --- /dev/null +++ b/lib/models/user.ts @@ -0,0 +1,290 @@ +import Database, { sql } from '/lib/interfaces/database.ts'; +import { User, UserSession, VerificationCode } from '/lib/types.ts'; +import { generateRandomCode } from '/lib/utils/misc.ts'; +import { isEmailEnabled, isForeverSignupEnabled } from '/lib/config.ts'; + +const db = new Database(); + +export class UserModel { + static async isThereAnAdmin() { + const user = (await db.query( + sql`SELECT * FROM "bewcloud_users" WHERE ("extra" ->> 'is_admin')::boolean IS TRUE LIMIT 1`, + ))[ + 0 + ]; + + return Boolean(user); + } + + static async getByEmail(email: string) { + const lowercaseEmail = email.toLowerCase().trim(); + + const user = (await db.query(sql`SELECT * FROM "bewcloud_users" WHERE "email" = $1 LIMIT 1`, [ + lowercaseEmail, + ]))[0]; + + return user; + } + + static async getById(id: string) { + const user = (await db.query(sql`SELECT * FROM "bewcloud_users" WHERE "id" = $1 LIMIT 1`, [ + id, + ]))[0]; + + return user; + } + + static async create(email: User['email'], hashedPassword: User['hashed_password']) { + const trialDays = isForeverSignupEnabled() ? 36_525 : 30; + const now = new Date(); + const trialEndDate = new Date(new Date().setUTCDate(new Date().getUTCDate() + trialDays)); + + const subscription: User['subscription'] = { + external: {}, + expires_at: trialEndDate.toISOString(), + updated_at: now.toISOString(), + }; + + const extra: User['extra'] = { is_email_verified: isEmailEnabled() ? false : true }; + + // First signup will be an admin "forever" + if (!(await this.isThereAnAdmin())) { + extra.is_admin = true; + subscription.expires_at = new Date('2100-12-31').toISOString(); + } + + const newUser = (await db.query( + sql`INSERT INTO "bewcloud_users" ( + "email", + "subscription", + "status", + "hashed_password", + "extra" + ) VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [ + email, + JSON.stringify(subscription), + extra.is_admin || isForeverSignupEnabled() ? 'active' : 'trial', + hashedPassword, + JSON.stringify(extra), + ], + ))[0]; + + return newUser; + } + + static async update(user: User) { + await db.query( + sql`UPDATE "bewcloud_users" SET + "email" = $2, + "subscription" = $3, + "status" = $4, + "hashed_password" = $5, + "extra" = $6 + WHERE "id" = $1`, + [ + user.id, + user.email, + JSON.stringify(user.subscription), + user.status, + user.hashed_password, + JSON.stringify(user.extra), + ], + ); + } + + static async delete(userId: string) { + await db.query( + sql`DELETE FROM "bewcloud_user_sessions" WHERE "user_id" = $1`, + [ + userId, + ], + ); + + await db.query( + sql`DELETE FROM "bewcloud_verification_codes" WHERE "user_id" = $1`, + [ + userId, + ], + ); + + await db.query( + sql`DELETE FROM "bewcloud_news_feed_articles" WHERE "user_id" = $1`, + [ + userId, + ], + ); + + await db.query( + sql`DELETE FROM "bewcloud_news_feeds" WHERE "user_id" = $1`, + [ + userId, + ], + ); + + await db.query( + sql`DELETE FROM "bewcloud_users" WHERE "id" = $1`, + [ + userId, + ], + ); + } +} + +export class UserSessionModel { + static async getById(id: string) { + const session = (await db.query( + sql`SELECT * FROM "bewcloud_user_sessions" WHERE "id" = $1 AND "expires_at" > now() LIMIT 1`, + [ + id, + ], + ))[0]; + + return session; + } + + static async create(user: User, isShortLived = false) { + const oneMonthFromToday = new Date(new Date().setUTCMonth(new Date().getUTCMonth() + 1)); + const oneWeekFromToday = new Date(new Date().setUTCDate(new Date().getUTCDate() + 7)); + + const newSession: Omit = { + user_id: user.id, + expires_at: isShortLived ? oneWeekFromToday : oneMonthFromToday, + last_seen_at: new Date(), + }; + + const newUserSessionResult = (await db.query( + sql`INSERT INTO "bewcloud_user_sessions" ( + "user_id", + "expires_at", + "last_seen_at" + ) VALUES ($1, $2, $3) + RETURNING *`, + [ + newSession.user_id, + newSession.expires_at, + newSession.last_seen_at, + ], + ))[0]; + + return newUserSessionResult; + } + + static async update(session: UserSession) { + await db.query( + sql`UPDATE "bewcloud_user_sessions" SET + "expires_at" = $2, + "last_seen_at" = $3 + WHERE "id" = $1`, + [ + session.id, + session.expires_at, + session.last_seen_at, + ], + ); + } + + static async delete(sessionId: string) { + await db.query( + sql`DELETE FROM "bewcloud_user_sessions" WHERE "id" = $1`, + [ + sessionId, + ], + ); + } +} + +export async function validateUserAndSession(userId: string, sessionId: string) { + const user = await UserModel.getById(userId); + + if (!user) { + throw new Error('Not Found'); + } + + const session = await UserSessionModel.getById(sessionId); + + if (!session || session.user_id !== user.id) { + throw new Error('Not Found'); + } + + session.last_seen_at = new Date(); + + await UserSessionModel.update(session); + + return { user, session }; +} + +export class VerificationCodeModel { + static async create( + user: User, + verificationId: string, + type: VerificationCode['verification']['type'], + ) { + const inThirtyMinutes = new Date(new Date().setUTCMinutes(new Date().getUTCMinutes() + 30)); + + const code = generateRandomCode(); + + const newVerificationCode: Omit = { + user_id: user.id, + code, + expires_at: inThirtyMinutes, + verification: { + id: verificationId, + type, + }, + }; + + await db.query( + sql`INSERT INTO "bewcloud_verification_codes" ( + "user_id", + "code", + "expires_at", + "verification" + ) VALUES ($1, $2, $3, $4) + RETURNING "id"`, + [ + newVerificationCode.user_id, + newVerificationCode.code, + newVerificationCode.expires_at, + JSON.stringify(newVerificationCode.verification), + ], + ); + + return code; + } + + static async validate( + user: User, + verificationId: string, + code: string, + type: VerificationCode['verification']['type'], + ) { + const verificationCode = (await db.query( + sql`SELECT * FROM "bewcloud_verification_codes" + WHERE "user_id" = $1 AND + "code" = $2 AND + "verification" ->> 'type' = $3 AND + "verification" ->> 'id' = $4 AND + "expires_at" > now() + LIMIT 1`, + [ + user.id, + code, + type, + verificationId, + ], + ))[0]; + + if (verificationCode) { + await db.query( + sql`DELETE FROM "bewcloud_verification_codes" WHERE "id" = $1`, + [ + verificationCode.id, + ], + ); + } else { + throw new Error('Not Found'); + } + } +} diff --git a/routes/api/dashboard/save-links.tsx b/routes/api/dashboard/save-links.tsx index a836ea8..531828b 100644 --- a/routes/api/dashboard/save-links.tsx +++ b/routes/api/dashboard/save-links.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { DashboardLink, FreshContextState } from '/lib/types.ts'; -import { getDashboardByUserId, updateDashboard } from '/lib/data/dashboard.ts'; +import { DashboardModel } from '/lib/models/dashboard.ts'; interface Data {} @@ -19,7 +19,7 @@ export const handler: Handlers = { return new Response('Unauthorized', { status: 401 }); } - const userDashboard = await getDashboardByUserId(context.state.user.id); + const userDashboard = await DashboardModel.getByUserId(context.state.user.id); if (!userDashboard) { return new Response('Not found', { status: 404 }); @@ -30,7 +30,7 @@ export const handler: Handlers = { if (typeof requestBody.links !== 'undefined') { userDashboard.data.links = requestBody.links; - await updateDashboard(userDashboard); + await DashboardModel.update(userDashboard); } const responseBody: ResponseBody = { success: true }; diff --git a/routes/api/dashboard/save-notes.tsx b/routes/api/dashboard/save-notes.tsx index cc79d98..91ae900 100644 --- a/routes/api/dashboard/save-notes.tsx +++ b/routes/api/dashboard/save-notes.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { FreshContextState } from '/lib/types.ts'; -import { getDashboardByUserId, updateDashboard } from '/lib/data/dashboard.ts'; +import { DashboardModel } from '/lib/models/dashboard.ts'; interface Data {} @@ -19,7 +19,7 @@ export const handler: Handlers = { return new Response('Unauthorized', { status: 401 }); } - const userDashboard = await getDashboardByUserId(context.state.user.id); + const userDashboard = await DashboardModel.getByUserId(context.state.user.id); if (!userDashboard) { return new Response('Not found', { status: 404 }); @@ -30,7 +30,7 @@ export const handler: Handlers = { if (typeof requestBody.notes !== 'undefined' && userDashboard.data.notes !== requestBody.notes) { userDashboard.data.notes = requestBody.notes; - await updateDashboard(userDashboard); + await DashboardModel.update(userDashboard); } const responseBody: ResponseBody = { success: true }; diff --git a/routes/api/expenses/add-budget.tsx b/routes/api/expenses/add-budget.tsx index edb6bf4..abc0228 100644 --- a/routes/api/expenses/add-budget.tsx +++ b/routes/api/expenses/add-budget.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { Budget, FreshContextState } from '/lib/types.ts'; -import { createBudget, getBudgets } from '/lib/data/expenses.ts'; +import { BudgetModel } from '/lib/models/expenses.ts'; interface Data {} @@ -33,7 +33,7 @@ export const handler: Handlers = { } try { - const newBudget = await createBudget( + const newBudget = await BudgetModel.create( context.state.user.id, requestBody.name, requestBody.month, @@ -48,7 +48,7 @@ export const handler: Handlers = { return new Response(`${error}`, { status: 500 }); } - const newBudgets = await getBudgets(context.state.user.id, requestBody.currentMonth); + const newBudgets = await BudgetModel.list(context.state.user.id, requestBody.currentMonth); const responseBody: ResponseBody = { success: true, newBudgets }; diff --git a/routes/api/expenses/add-expense.tsx b/routes/api/expenses/add-expense.tsx index e6f9ee9..36198cb 100644 --- a/routes/api/expenses/add-expense.tsx +++ b/routes/api/expenses/add-expense.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { Budget, Expense, FreshContextState } from '/lib/types.ts'; -import { createExpense, getBudgets, getExpenses } from '/lib/data/expenses.ts'; +import { BudgetModel, ExpenseModel } from '/lib/models/expenses.ts'; interface Data {} @@ -52,7 +52,7 @@ export const handler: Handlers = { } try { - const newExpense = await createExpense( + const newExpense = await ExpenseModel.create( context.state.user.id, requestBody.cost, requestBody.description, @@ -69,9 +69,9 @@ export const handler: Handlers = { return new Response(`${error}`, { status: 500 }); } - const newExpenses = await getExpenses(context.state.user.id, requestBody.month); + const newExpenses = await ExpenseModel.list(context.state.user.id, requestBody.month); - const newBudgets = await getBudgets(context.state.user.id, requestBody.month); + const newBudgets = await BudgetModel.list(context.state.user.id, requestBody.month); const responseBody: ResponseBody = { success: true, newExpenses, newBudgets }; diff --git a/routes/api/expenses/auto-complete.tsx b/routes/api/expenses/auto-complete.tsx index a424498..dbeb609 100644 --- a/routes/api/expenses/auto-complete.tsx +++ b/routes/api/expenses/auto-complete.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { FreshContextState } from '/lib/types.ts'; -import { getExpenseSuggestions } from '/lib/data/expenses.ts'; +import { ExpenseModel } from '/lib/models/expenses.ts'; interface Data {} @@ -26,7 +26,7 @@ export const handler: Handlers = { return new Response('Bad request', { status: 400 }); } - const suggestions = await getExpenseSuggestions( + const suggestions = await ExpenseModel.listSuggestions( context.state.user.id, requestBody.name, ); diff --git a/routes/api/expenses/delete-budget.tsx b/routes/api/expenses/delete-budget.tsx index b89118f..ec9ad8b 100644 --- a/routes/api/expenses/delete-budget.tsx +++ b/routes/api/expenses/delete-budget.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { Budget, FreshContextState } from '/lib/types.ts'; -import { deleteBudget, getBudgetById, getBudgets } from '/lib/data/expenses.ts'; +import { BudgetModel } from '/lib/models/expenses.ts'; interface Data {} @@ -29,20 +29,20 @@ export const handler: Handlers = { return new Response('Bad request', { status: 400 }); } - const budget = await getBudgetById(context.state.user.id, requestBody.id); + const budget = await BudgetModel.getById(context.state.user.id, requestBody.id); if (!budget) { return new Response('Not found', { status: 404 }); } try { - await deleteBudget(context.state.user.id, requestBody.id); + await BudgetModel.delete(context.state.user.id, requestBody.id); } catch (error) { console.error(error); return new Response(`${error}`, { status: 500 }); } - const newBudgets = await getBudgets(context.state.user.id, requestBody.currentMonth); + const newBudgets = await BudgetModel.list(context.state.user.id, requestBody.currentMonth); const responseBody: ResponseBody = { success: true, newBudgets }; diff --git a/routes/api/expenses/delete-expense.tsx b/routes/api/expenses/delete-expense.tsx index 0c880e7..928e800 100644 --- a/routes/api/expenses/delete-expense.tsx +++ b/routes/api/expenses/delete-expense.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { Budget, Expense, FreshContextState } from '/lib/types.ts'; -import { deleteExpense, getBudgets, getExpenseById, getExpenses } from '/lib/data/expenses.ts'; +import { BudgetModel, ExpenseModel } from '/lib/models/expenses.ts'; interface Data {} @@ -30,22 +30,22 @@ export const handler: Handlers = { return new Response('Bad request', { status: 400 }); } - const expense = await getExpenseById(context.state.user.id, requestBody.id); + const expense = await ExpenseModel.getById(context.state.user.id, requestBody.id); if (!expense) { return new Response('Not found', { status: 404 }); } try { - await deleteExpense(context.state.user.id, requestBody.id); + await ExpenseModel.delete(context.state.user.id, requestBody.id); } catch (error) { console.error(error); return new Response(`${error}`, { status: 500 }); } - const newExpenses = await getExpenses(context.state.user.id, requestBody.month); + const newExpenses = await ExpenseModel.list(context.state.user.id, requestBody.month); - const newBudgets = await getBudgets(context.state.user.id, requestBody.month); + const newBudgets = await BudgetModel.list(context.state.user.id, requestBody.month); const responseBody: ResponseBody = { success: true, newExpenses, newBudgets }; diff --git a/routes/api/expenses/export-expenses.tsx b/routes/api/expenses/export-expenses.tsx index 591d64f..6728c60 100644 --- a/routes/api/expenses/export-expenses.tsx +++ b/routes/api/expenses/export-expenses.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { Budget, Expense, FreshContextState } from '/lib/types.ts'; -import { getAllBudgetsForExport, getAllExpensesForExport } from '/lib/data/expenses.ts'; +import { BudgetModel, ExpenseModel } from '/lib/models/expenses.ts'; interface Data {} @@ -21,9 +21,9 @@ export const handler: Handlers = { return new Response('Unauthorized', { status: 401 }); } - const newExpenses = await getAllExpensesForExport(context.state.user.id); + const newExpenses = await ExpenseModel.getAllForExport(context.state.user.id); - const newBudgets = await getAllBudgetsForExport(context.state.user.id); + const newBudgets = await BudgetModel.getAllForExport(context.state.user.id); const responseBody: ResponseBody = { success: true, jsonContents: { expenses: newExpenses, budgets: newBudgets } }; diff --git a/routes/api/expenses/import-expenses.tsx b/routes/api/expenses/import-expenses.tsx index be6c1ba..90a23b7 100644 --- a/routes/api/expenses/import-expenses.tsx +++ b/routes/api/expenses/import-expenses.tsx @@ -2,13 +2,7 @@ import { Handlers } from 'fresh/server.ts'; import { Budget, Expense, FreshContextState } from '/lib/types.ts'; import { concurrentPromises } from '/lib/utils/misc.ts'; -import { - createBudget, - createExpense, - deleteAllBudgetsAndExpenses, - getBudgets, - getExpenses, -} from '/lib/data/expenses.ts'; +import { BudgetModel, deleteAllBudgetsAndExpenses, ExpenseModel } from '/lib/models/expenses.ts'; interface Data {} @@ -47,14 +41,14 @@ export const handler: Handlers = { try { await concurrentPromises( requestBody.budgets.map((budget) => () => - createBudget(context.state.user!.id, budget.name, budget.month, budget.value) + BudgetModel.create(context.state.user!.id, budget.name, budget.month, budget.value) ), 5, ); await concurrentPromises( requestBody.expenses.map((expense) => () => - createExpense( + ExpenseModel.create( context.state.user!.id, expense.cost, expense.description, @@ -71,9 +65,9 @@ export const handler: Handlers = { return new Response(`${error}`, { status: 500 }); } - const newExpenses = await getExpenses(context.state.user.id, requestBody.month); + const newExpenses = await ExpenseModel.list(context.state.user.id, requestBody.month); - const newBudgets = await getBudgets(context.state.user.id, requestBody.month); + const newBudgets = await BudgetModel.list(context.state.user.id, requestBody.month); const responseBody: ResponseBody = { success: true, newExpenses, newBudgets }; diff --git a/routes/api/expenses/update-budget.tsx b/routes/api/expenses/update-budget.tsx index 8fd81b4..5a6cdd0 100644 --- a/routes/api/expenses/update-budget.tsx +++ b/routes/api/expenses/update-budget.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { Budget, FreshContextState } from '/lib/types.ts'; -import { getBudgetById, getBudgets, updateBudget } from '/lib/data/expenses.ts'; +import { BudgetModel } from '/lib/models/expenses.ts'; interface Data {} @@ -34,7 +34,7 @@ export const handler: Handlers = { return new Response('Bad request', { status: 400 }); } - const budget = await getBudgetById(context.state.user.id, requestBody.id); + const budget = await BudgetModel.getById(context.state.user.id, requestBody.id); if (!budget) { return new Response('Not found', { status: 404 }); @@ -45,13 +45,13 @@ export const handler: Handlers = { budget.value = requestBody.value; try { - await updateBudget(budget); + await BudgetModel.update(budget); } catch (error) { console.error(error); return new Response(`${error}`, { status: 500 }); } - const newBudgets = await getBudgets(context.state.user.id, requestBody.currentMonth); + const newBudgets = await BudgetModel.list(context.state.user.id, requestBody.currentMonth); const responseBody: ResponseBody = { success: true, newBudgets }; diff --git a/routes/api/expenses/update-expense.tsx b/routes/api/expenses/update-expense.tsx index 1a7268f..6c43de7 100644 --- a/routes/api/expenses/update-expense.tsx +++ b/routes/api/expenses/update-expense.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { Budget, Expense, FreshContextState } from '/lib/types.ts'; -import { getBudgets, getExpenseById, getExpenses, updateExpense } from '/lib/data/expenses.ts'; +import { BudgetModel, ExpenseModel } from '/lib/models/expenses.ts'; interface Data {} @@ -36,7 +36,7 @@ export const handler: Handlers = { return new Response('Bad request', { status: 400 }); } - const expense = await getExpenseById(context.state.user.id, requestBody.id); + const expense = await ExpenseModel.getById(context.state.user.id, requestBody.id); if (!expense) { return new Response('Not found', { status: 404 }); @@ -65,15 +65,15 @@ export const handler: Handlers = { expense.is_recurring = requestBody.is_recurring; try { - await updateExpense(expense); + await ExpenseModel.update(expense); } catch (error) { console.error(error); return new Response(`${error}`, { status: 500 }); } - const newExpenses = await getExpenses(context.state.user.id, requestBody.month); + const newExpenses = await ExpenseModel.list(context.state.user.id, requestBody.month); - const newBudgets = await getBudgets(context.state.user.id, requestBody.month); + const newBudgets = await BudgetModel.list(context.state.user.id, requestBody.month); const responseBody: ResponseBody = { success: true, newExpenses, newBudgets }; diff --git a/routes/api/files/create-directory.tsx b/routes/api/files/create-directory.tsx index 3a48bdf..302c467 100644 --- a/routes/api/files/create-directory.tsx +++ b/routes/api/files/create-directory.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { Directory, FreshContextState } from '/lib/types.ts'; -import { createDirectory, getDirectories } from '/lib/data/files.ts'; +import { DirectoryModel } from '/lib/models/files.ts'; interface Data {} @@ -30,13 +30,13 @@ export const handler: Handlers = { return new Response('Bad Request', { status: 400 }); } - const createdDirectory = await createDirectory( + const createdDirectory = await DirectoryModel.create( context.state.user.id, requestBody.parentPath, requestBody.name.trim(), ); - const newDirectories = await getDirectories(context.state.user.id, requestBody.parentPath); + const newDirectories = await DirectoryModel.list(context.state.user.id, requestBody.parentPath); const responseBody: ResponseBody = { success: createdDirectory, newDirectories }; diff --git a/routes/api/files/delete-directory.tsx b/routes/api/files/delete-directory.tsx index 3d19f3d..414c3bb 100644 --- a/routes/api/files/delete-directory.tsx +++ b/routes/api/files/delete-directory.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { Directory, FreshContextState } from '/lib/types.ts'; -import { deleteDirectoryOrFile, getDirectories } from '/lib/data/files.ts'; +import { DirectoryModel } from '/lib/models/files.ts'; interface Data {} @@ -30,13 +30,13 @@ export const handler: Handlers = { return new Response('Bad Request', { status: 400 }); } - const deletedDirectory = await deleteDirectoryOrFile( + const deletedDirectory = await DirectoryModel.delete( context.state.user.id, requestBody.parentPath, requestBody.name.trim(), ); - const newDirectories = await getDirectories(context.state.user.id, requestBody.parentPath); + const newDirectories = await DirectoryModel.list(context.state.user.id, requestBody.parentPath); const responseBody: ResponseBody = { success: deletedDirectory, newDirectories }; diff --git a/routes/api/files/delete.tsx b/routes/api/files/delete.tsx index 29f38fa..6d86a1d 100644 --- a/routes/api/files/delete.tsx +++ b/routes/api/files/delete.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { DirectoryFile, FreshContextState } from '/lib/types.ts'; -import { deleteDirectoryOrFile, getFiles } from '/lib/data/files.ts'; +import { FileModel } from '/lib/models/files.ts'; interface Data {} @@ -30,13 +30,13 @@ export const handler: Handlers = { return new Response('Bad Request', { status: 400 }); } - const deletedFile = await deleteDirectoryOrFile( + const deletedFile = await FileModel.delete( context.state.user.id, requestBody.parentPath, requestBody.name.trim(), ); - const newFiles = await getFiles(context.state.user.id, requestBody.parentPath); + const newFiles = await FileModel.list(context.state.user.id, requestBody.parentPath); const responseBody: ResponseBody = { success: deletedFile, newFiles }; diff --git a/routes/api/files/get-directories.tsx b/routes/api/files/get-directories.tsx index f61b3cc..57fb679 100644 --- a/routes/api/files/get-directories.tsx +++ b/routes/api/files/get-directories.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { Directory, FreshContextState } from '/lib/types.ts'; -import { getDirectories } from '/lib/data/files.ts'; +import { DirectoryModel } from '/lib/models/files.ts'; interface Data {} @@ -29,7 +29,7 @@ export const handler: Handlers = { return new Response('Bad Request', { status: 400 }); } - const directories = await getDirectories( + const directories = await DirectoryModel.list( context.state.user.id, requestBody.parentPath, ); diff --git a/routes/api/files/get.tsx b/routes/api/files/get.tsx index 9a986cc..1d81ae6 100644 --- a/routes/api/files/get.tsx +++ b/routes/api/files/get.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { DirectoryFile, FreshContextState } from '/lib/types.ts'; -import { getFiles } from '/lib/data/files.ts'; +import { FileModel } from '/lib/models/files.ts'; interface Data {} @@ -28,7 +28,7 @@ export const handler: Handlers = { return new Response('Bad Request', { status: 400 }); } - const files = await getFiles( + const files = await FileModel.list( context.state.user.id, requestBody.parentPath, ); diff --git a/routes/api/files/move-directory.tsx b/routes/api/files/move-directory.tsx index 2714e1f..9b5da7a 100644 --- a/routes/api/files/move-directory.tsx +++ b/routes/api/files/move-directory.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { Directory, FreshContextState } from '/lib/types.ts'; -import { getDirectories, renameDirectoryOrFile } from '/lib/data/files.ts'; +import { DirectoryModel } from '/lib/models/files.ts'; interface Data {} @@ -33,7 +33,7 @@ export const handler: Handlers = { return new Response('Bad Request', { status: 400 }); } - const movedDirectory = await renameDirectoryOrFile( + const movedDirectory = await DirectoryModel.rename( context.state.user.id, requestBody.oldParentPath, requestBody.newParentPath, @@ -41,7 +41,7 @@ export const handler: Handlers = { requestBody.name.trim(), ); - const newDirectories = await getDirectories(context.state.user.id, requestBody.oldParentPath); + const newDirectories = await DirectoryModel.list(context.state.user.id, requestBody.oldParentPath); const responseBody: ResponseBody = { success: movedDirectory, newDirectories }; diff --git a/routes/api/files/move.tsx b/routes/api/files/move.tsx index 5d33264..dd0156e 100644 --- a/routes/api/files/move.tsx +++ b/routes/api/files/move.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { DirectoryFile, FreshContextState } from '/lib/types.ts'; -import { getFiles, renameDirectoryOrFile } from '/lib/data/files.ts'; +import { FileModel } from '/lib/models/files.ts'; interface Data {} @@ -33,7 +33,7 @@ export const handler: Handlers = { return new Response('Bad Request', { status: 400 }); } - const movedFile = await renameDirectoryOrFile( + const movedFile = await FileModel.rename( context.state.user.id, requestBody.oldParentPath, requestBody.newParentPath, @@ -41,7 +41,7 @@ export const handler: Handlers = { requestBody.name.trim(), ); - const newFiles = await getFiles(context.state.user.id, requestBody.oldParentPath); + const newFiles = await FileModel.list(context.state.user.id, requestBody.oldParentPath); const responseBody: ResponseBody = { success: movedFile, newFiles }; diff --git a/routes/api/files/rename-directory.tsx b/routes/api/files/rename-directory.tsx index 8420194..3e10f14 100644 --- a/routes/api/files/rename-directory.tsx +++ b/routes/api/files/rename-directory.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { Directory, FreshContextState } from '/lib/types.ts'; -import { getDirectories, renameDirectoryOrFile } from '/lib/data/files.ts'; +import { DirectoryModel } from '/lib/models/files.ts'; interface Data {} @@ -32,7 +32,7 @@ export const handler: Handlers = { return new Response('Bad Request', { status: 400 }); } - const movedDirectory = await renameDirectoryOrFile( + const movedDirectory = await DirectoryModel.rename( context.state.user.id, requestBody.parentPath, requestBody.parentPath, @@ -40,7 +40,7 @@ export const handler: Handlers = { requestBody.newName.trim(), ); - const newDirectories = await getDirectories(context.state.user.id, requestBody.parentPath); + const newDirectories = await DirectoryModel.list(context.state.user.id, requestBody.parentPath); const responseBody: ResponseBody = { success: movedDirectory, newDirectories }; diff --git a/routes/api/files/rename.tsx b/routes/api/files/rename.tsx index ef0ac01..751bc0a 100644 --- a/routes/api/files/rename.tsx +++ b/routes/api/files/rename.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { DirectoryFile, FreshContextState } from '/lib/types.ts'; -import { getFiles, renameDirectoryOrFile } from '/lib/data/files.ts'; +import { FileModel } from '/lib/models/files.ts'; interface Data {} @@ -32,7 +32,7 @@ export const handler: Handlers = { return new Response('Bad Request', { status: 400 }); } - const movedFile = await renameDirectoryOrFile( + const movedFile = await FileModel.rename( context.state.user.id, requestBody.parentPath, requestBody.parentPath, @@ -40,7 +40,7 @@ export const handler: Handlers = { requestBody.newName.trim(), ); - const newFiles = await getFiles(context.state.user.id, requestBody.parentPath); + const newFiles = await FileModel.list(context.state.user.id, requestBody.parentPath); const responseBody: ResponseBody = { success: movedFile, newFiles }; diff --git a/routes/api/files/search.tsx b/routes/api/files/search.tsx index 4019d6c..9229941 100644 --- a/routes/api/files/search.tsx +++ b/routes/api/files/search.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts'; -import { searchFilesAndDirectories } from '/lib/data/files.ts'; +import { searchFilesAndDirectories } from '/lib/models/files.ts'; interface Data {} diff --git a/routes/api/files/upload.tsx b/routes/api/files/upload.tsx index b953cb4..790f50e 100644 --- a/routes/api/files/upload.tsx +++ b/routes/api/files/upload.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts'; -import { createFile, getDirectories, getFiles } from '/lib/data/files.ts'; +import { DirectoryModel, FileModel } from '/lib/models/files.ts'; interface Data {} @@ -33,10 +33,10 @@ export const handler: Handlers = { const fileContents = typeof contents === 'string' ? contents : await contents.arrayBuffer(); - const createdFile = await createFile(context.state.user.id, parentPath, name.trim(), fileContents); + const createdFile = await FileModel.create(context.state.user.id, parentPath, name.trim(), fileContents); - const newFiles = await getFiles(context.state.user.id, pathInView); - const newDirectories = await getDirectories(context.state.user.id, pathInView); + const newFiles = await FileModel.list(context.state.user.id, pathInView); + const newDirectories = await DirectoryModel.list(context.state.user.id, pathInView); const responseBody: ResponseBody = { success: createdFile, newFiles, newDirectories }; diff --git a/routes/api/news/add-feed.tsx b/routes/api/news/add-feed.tsx index c3bbd26..cfe08f0 100644 --- a/routes/api/news/add-feed.tsx +++ b/routes/api/news/add-feed.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { FreshContextState, NewsFeed } from '/lib/types.ts'; -import { createNewsFeed, getNewsFeeds } from '/lib/data/news.ts'; +import { FeedModel } from '/lib/models/news.ts'; import { fetchNewArticles } from '/crons/news.ts'; interface Data {} @@ -24,7 +24,7 @@ export const handler: Handlers = { const requestBody = await request.clone().json() as RequestBody; if (requestBody.feedUrl) { - const newFeed = await createNewsFeed(context.state.user.id, requestBody.feedUrl); + const newFeed = await FeedModel.create(context.state.user.id, requestBody.feedUrl); if (!newFeed) { return new Response('Not found', { status: 404 }); @@ -33,7 +33,7 @@ export const handler: Handlers = { await fetchNewArticles(); - const newFeeds = await getNewsFeeds(context.state.user.id); + const newFeeds = await FeedModel.list(context.state.user.id); const responseBody: ResponseBody = { success: true, newFeeds }; diff --git a/routes/api/news/delete-feed.tsx b/routes/api/news/delete-feed.tsx index c04475f..28f425a 100644 --- a/routes/api/news/delete-feed.tsx +++ b/routes/api/news/delete-feed.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { FreshContextState, NewsFeed } from '/lib/types.ts'; -import { deleteNewsFeed, getNewsFeed, getNewsFeeds } from '/lib/data/news.ts'; +import { FeedModel } from '/lib/models/news.ts'; interface Data {} @@ -23,16 +23,16 @@ export const handler: Handlers = { const requestBody = await request.clone().json() as RequestBody; if (requestBody.feedId) { - const newsFeed = await getNewsFeed(requestBody.feedId, context.state.user.id); + const newsFeed = await FeedModel.get(requestBody.feedId, context.state.user.id); if (!newsFeed) { return new Response('Not found', { status: 404 }); } - await deleteNewsFeed(requestBody.feedId, context.state.user.id); + await FeedModel.delete(requestBody.feedId, context.state.user.id); } - const newFeeds = await getNewsFeeds(context.state.user.id); + const newFeeds = await FeedModel.list(context.state.user.id); const responseBody: ResponseBody = { success: true, newFeeds }; diff --git a/routes/api/news/import-feeds.tsx b/routes/api/news/import-feeds.tsx index 4bfa72b..2716962 100644 --- a/routes/api/news/import-feeds.tsx +++ b/routes/api/news/import-feeds.tsx @@ -2,7 +2,7 @@ import { Handlers } from 'fresh/server.ts'; import { FreshContextState, NewsFeed } from '/lib/types.ts'; import { concurrentPromises } from '/lib/utils/misc.ts'; -import { createNewsFeed, getNewsFeeds } from '/lib/data/news.ts'; +import { FeedModel } from '/lib/models/news.ts'; import { fetchNewArticles } from '/crons/news.ts'; interface Data {} @@ -30,14 +30,14 @@ export const handler: Handlers = { } await concurrentPromises( - requestBody.feedUrls.map((feedUrl) => () => createNewsFeed(context.state.user!.id, feedUrl)), + requestBody.feedUrls.map((feedUrl) => () => FeedModel.create(context.state.user!.id, feedUrl)), 5, ); } await fetchNewArticles(); - const newFeeds = await getNewsFeeds(context.state.user.id); + const newFeeds = await FeedModel.list(context.state.user.id); const responseBody: ResponseBody = { success: true, newFeeds }; diff --git a/routes/api/news/mark-read.tsx b/routes/api/news/mark-read.tsx index a618b28..8c4a263 100644 --- a/routes/api/news/mark-read.tsx +++ b/routes/api/news/mark-read.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { FreshContextState } from '/lib/types.ts'; -import { getNewsArticle, markAllArticlesRead, updateNewsArticle } from '/lib/data/news.ts'; +import { ArticleModel } from '/lib/models/news.ts'; interface Data {} @@ -23,9 +23,9 @@ export const handler: Handlers = { if (requestBody.articleId) { if (requestBody.articleId === 'all') { - await markAllArticlesRead(context.state.user.id); + await ArticleModel.markAllRead(context.state.user.id); } else { - const article = await getNewsArticle(requestBody.articleId, context.state.user.id); + const article = await ArticleModel.get(requestBody.articleId, context.state.user.id); if (!article) { return new Response('Not found', { status: 404 }); @@ -33,7 +33,7 @@ export const handler: Handlers = { article.is_read = true; - await updateNewsArticle(article); + await ArticleModel.update(article); } } diff --git a/routes/api/news/refresh-articles.tsx b/routes/api/news/refresh-articles.tsx index d5b0acd..17ae173 100644 --- a/routes/api/news/refresh-articles.tsx +++ b/routes/api/news/refresh-articles.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { FreshContextState, NewsFeedArticle } from '/lib/types.ts'; -import { getNewsArticles, getNewsFeeds } from '/lib/data/news.ts'; +import { ArticleModel, FeedModel } from '/lib/models/news.ts'; import { fetchNewArticles } from '/crons/news.ts'; interface Data {} @@ -19,7 +19,7 @@ export const handler: Handlers = { return new Response('Unauthorized', { status: 401 }); } - const newsFeeds = await getNewsFeeds(context.state.user.id); + const newsFeeds = await FeedModel.list(context.state.user.id); if (!newsFeeds.length) { return new Response('Not found', { status: 404 }); @@ -27,7 +27,7 @@ export const handler: Handlers = { await fetchNewArticles(true); - const newArticles = await getNewsArticles(context.state.user.id); + const newArticles = await ArticleModel.list(context.state.user.id); const responseBody: ResponseBody = { success: true, newArticles }; diff --git a/routes/api/notes/save.tsx b/routes/api/notes/save.tsx index 7626cae..ca38159 100644 --- a/routes/api/notes/save.tsx +++ b/routes/api/notes/save.tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { FreshContextState } from '/lib/types.ts'; -import { getFile, updateFile } from '/lib/data/files.ts'; +import { FileModel } from '/lib/models/files.ts'; interface Data {} @@ -42,7 +42,7 @@ export const handler: Handlers = { return new Response('Not Found', { status: 404 }); } - const fileResult = await getFile( + const fileResult = await FileModel.get( context.state.user.id, requestBody.currentPath, decodeURIComponent(requestBody.fileName), @@ -52,7 +52,7 @@ export const handler: Handlers = { return new Response('Not Found', { status: 404 }); } - const updatedFile = await updateFile( + const updatedFile = await FileModel.update( context.state.user.id, requestBody.currentPath, decodeURIComponent(requestBody.fileName), diff --git a/routes/dashboard.tsx b/routes/dashboard.tsx index fb6f862..d56ca0a 100644 --- a/routes/dashboard.tsx +++ b/routes/dashboard.tsx @@ -1,7 +1,7 @@ import { Handlers, PageProps } from 'fresh/server.ts'; import { Dashboard, FreshContextState } from '/lib/types.ts'; -import { createDashboard, getDashboardByUserId } from '/lib/data/dashboard.ts'; +import { DashboardModel } from '/lib/models/dashboard.ts'; import Notes from '/islands/dashboard/Notes.tsx'; import Links from '/islands/dashboard/Links.tsx'; @@ -15,10 +15,10 @@ export const handler: Handlers = { return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); } - let userDashboard = await getDashboardByUserId(context.state.user.id); + let userDashboard = await DashboardModel.getByUserId(context.state.user.id); if (!userDashboard) { - userDashboard = await createDashboard(context.state.user.id); + userDashboard = await DashboardModel.create(context.state.user.id); } return await context.render({ userDashboard }); diff --git a/routes/dav.tsx b/routes/dav.tsx index ad9286f..a77765c 100644 --- a/routes/dav.tsx +++ b/routes/dav.tsx @@ -11,7 +11,7 @@ import { getProperDestinationPath, getPropertyNames, } from '/lib/utils/webdav.ts'; -import { ensureUserPathIsValidAndSecurelyAccessible, getFile } from '/lib/data/files.ts'; +import { ensureUserPathIsValidAndSecurelyAccessible, FileModel } from '/lib/models/files.ts'; interface Data {} @@ -53,7 +53,7 @@ export const handler: Handler = async (request, context if (request.method === 'GET') { try { - const fileResult = await getFile(userId, filePath); + const fileResult = await FileModel.get(userId, filePath); if (!fileResult.success) { return new Response('Not Found', { status: 404 }); diff --git a/routes/expenses.tsx b/routes/expenses.tsx index f43d7fa..24fdb3b 100644 --- a/routes/expenses.tsx +++ b/routes/expenses.tsx @@ -2,7 +2,7 @@ import { Handlers, PageProps } from 'fresh/server.ts'; import { Budget, Expense, FreshContextState, SupportedCurrencySymbol } from '/lib/types.ts'; import { isAppEnabled } from '/lib/config.ts'; -import { generateMonthlyBudgetsAndExpenses, getBudgets, getExpenses } from '/lib/data/expenses.ts'; +import { BudgetModel, ExpenseModel, generateMonthlyBudgetsAndExpenses } from '/lib/models/expenses.ts'; import ExpensesWrapper from '/islands/expenses/ExpensesWrapper.tsx'; interface Data { @@ -41,16 +41,16 @@ export const handler: Handlers = { initialMonth = nextMonth; } - let userBudgets = await getBudgets(context.state.user.id, initialMonth); + let userBudgets = await BudgetModel.list(context.state.user.id, initialMonth); - let userExpenses = await getExpenses(context.state.user.id, initialMonth); + let userExpenses = await ExpenseModel.list(context.state.user.id, initialMonth); // If there are no budgets or expenses, and the selected month is in the future, generate the month's budgets and expenses if (userBudgets.length === 0 && userExpenses.length === 0 && initialMonth >= currentMonth) { await generateMonthlyBudgetsAndExpenses(context.state.user.id, initialMonth); - userBudgets = await getBudgets(context.state.user.id, initialMonth); - userExpenses = await getExpenses(context.state.user.id, initialMonth); + userBudgets = await BudgetModel.list(context.state.user.id, initialMonth); + userExpenses = await ExpenseModel.list(context.state.user.id, initialMonth); } const currency = context.state.user.extra.expenses_currency || '$'; diff --git a/routes/files.tsx b/routes/files.tsx index 60aaabd..1967836 100644 --- a/routes/files.tsx +++ b/routes/files.tsx @@ -1,7 +1,7 @@ import { Handlers, PageProps } from 'fresh/server.ts'; import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts'; -import { getDirectories, getFiles } from '/lib/data/files.ts'; +import { DirectoryModel, FileModel } from '/lib/models/files.ts'; import FilesWrapper from '/islands/files/FilesWrapper.tsx'; interface Data { @@ -30,9 +30,9 @@ export const handler: Handlers = { currentPath = `${currentPath}/`; } - const userDirectories = await getDirectories(context.state.user.id, currentPath); + const userDirectories = await DirectoryModel.list(context.state.user.id, currentPath); - const userFiles = await getFiles(context.state.user.id, currentPath); + const userFiles = await FileModel.list(context.state.user.id, currentPath); return await context.render({ userDirectories, userFiles, currentPath }); }, diff --git a/routes/files/open/[fileName].tsx b/routes/files/open/[fileName].tsx index c277268..4e3b7d4 100644 --- a/routes/files/open/[fileName].tsx +++ b/routes/files/open/[fileName].tsx @@ -1,7 +1,7 @@ import { Handlers } from 'fresh/server.ts'; import { FreshContextState } from '/lib/types.ts'; -import { getFile } from '/lib/data/files.ts'; +import { FileModel } from '/lib/models/files.ts'; interface Data {} @@ -31,7 +31,7 @@ export const handler: Handlers = { currentPath = `${currentPath}/`; } - const fileResult = await getFile(context.state.user.id, currentPath, decodeURIComponent(fileName)); + const fileResult = await FileModel.get(context.state.user.id, currentPath, decodeURIComponent(fileName)); if (!fileResult.success) { return new Response('Not Found', { status: 404 }); diff --git a/routes/login.tsx b/routes/login.tsx index 195d112..46860fc 100644 --- a/routes/login.tsx +++ b/routes/login.tsx @@ -3,7 +3,7 @@ import { Handlers, PageProps } from 'fresh/server.ts'; import { generateHash, helpEmail, validateEmail } from '/lib/utils/misc.ts'; import { createSessionResponse, PASSWORD_SALT } from '/lib/auth.ts'; import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx'; -import { createVerificationCode, getUserByEmail, updateUser, validateVerificationCode } from '/lib/data/user.ts'; +import { UserModel, VerificationCodeModel } from '/lib/models/user.ts'; import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts'; import { FreshContextState } from '/lib/types.ts'; import { isEmailEnabled } from '/lib/config.ts'; @@ -61,7 +61,7 @@ export const handler: Handlers = { const hashedPassword = await generateHash(`${password}:${PASSWORD_SALT}`, 'SHA-256'); - const user = await getUserByEmail(email); + const user = await UserModel.getByEmail(email); if (!user || user.hashed_password !== hashedPassword) { throw new Error('Email not found or invalid password.'); @@ -70,24 +70,24 @@ export const handler: Handlers = { if (!isEmailEnabled() && !user.extra.is_email_verified) { user.extra.is_email_verified = true; - await updateUser(user); + await UserModel.update(user); } if (!user.extra.is_email_verified) { const code = getFormDataField(formData, 'verification-code'); if (!code) { - const verificationCode = await createVerificationCode(user, user.email, 'email'); + const verificationCode = await VerificationCodeModel.create(user, user.email, 'email'); await sendVerifyEmailEmail(user.email, verificationCode); throw new Error('Email not verified. New code sent to verify your email.'); } else { - await validateVerificationCode(user, user.email, code, 'email'); + await VerificationCodeModel.validate(user, user.email, code, 'email'); user.extra.is_email_verified = true; - await updateUser(user); + await UserModel.update(user); } } diff --git a/routes/news.tsx b/routes/news.tsx index babe418..923c364 100644 --- a/routes/news.tsx +++ b/routes/news.tsx @@ -2,7 +2,7 @@ import { Handlers, PageProps } from 'fresh/server.ts'; import { FreshContextState, NewsFeedArticle } from '/lib/types.ts'; import { isAppEnabled } from '/lib/config.ts'; -import { getNewsArticles } from '/lib/data/news.ts'; +import { ArticleModel } from '/lib/models/news.ts'; import Articles from '/islands/news/Articles.tsx'; interface Data { @@ -19,7 +19,7 @@ export const handler: Handlers = { return new Response('Redirect', { status: 303, headers: { 'Location': `/dashboard` } }); } - const userArticles = await getNewsArticles(context.state.user.id); + const userArticles = await ArticleModel.list(context.state.user.id); return await context.render({ userArticles }); }, diff --git a/routes/news/feeds.tsx b/routes/news/feeds.tsx index c32888b..f6edbed 100644 --- a/routes/news/feeds.tsx +++ b/routes/news/feeds.tsx @@ -1,7 +1,7 @@ import { Handlers, PageProps } from 'fresh/server.ts'; import { FreshContextState, NewsFeed } from '/lib/types.ts'; -import { getNewsFeeds } from '/lib/data/news.ts'; +import { FeedModel } from '/lib/models/news.ts'; import Feeds from '/islands/news/Feeds.tsx'; interface Data { @@ -14,7 +14,7 @@ export const handler: Handlers = { return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); } - const userFeeds = await getNewsFeeds(context.state.user.id); + const userFeeds = await FeedModel.list(context.state.user.id); return await context.render({ userFeeds }); }, diff --git a/routes/notes.tsx b/routes/notes.tsx index 2520a16..cc56728 100644 --- a/routes/notes.tsx +++ b/routes/notes.tsx @@ -2,7 +2,7 @@ import { Handlers, PageProps } from 'fresh/server.ts'; import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts'; import { isAppEnabled } from '/lib/config.ts'; -import { getDirectories, getFiles } from '/lib/data/files.ts'; +import { DirectoryModel, FileModel } from '/lib/models/files.ts'; import NotesWrapper from '/islands/notes/NotesWrapper.tsx'; interface Data { @@ -35,9 +35,9 @@ export const handler: Handlers = { currentPath = `${currentPath}/`; } - const userDirectories = await getDirectories(context.state.user.id, currentPath); + const userDirectories = await DirectoryModel.list(context.state.user.id, currentPath); - const userFiles = await getFiles(context.state.user.id, currentPath); + const userFiles = await FileModel.list(context.state.user.id, currentPath); const userNotes = userFiles.filter((file) => file.file_name.endsWith('.md')); diff --git a/routes/notes/open/[fileName].tsx b/routes/notes/open/[fileName].tsx index 12ef522..b409a5f 100644 --- a/routes/notes/open/[fileName].tsx +++ b/routes/notes/open/[fileName].tsx @@ -1,7 +1,7 @@ import { Handlers, PageProps } from 'fresh/server.ts'; import { FreshContextState } from '/lib/types.ts'; -import { getFile } from '/lib/data/files.ts'; +import { FileModel } from '/lib/models/files.ts'; import Note from '/islands/notes/Note.tsx'; interface Data { @@ -41,7 +41,7 @@ export const handler: Handlers = { return new Response('Not Found', { status: 404 }); } - const fileResult = await getFile(context.state.user.id, currentPath, decodeURIComponent(fileName)); + const fileResult = await FileModel.get(context.state.user.id, currentPath, decodeURIComponent(fileName)); if (!fileResult.success) { return new Response('Not Found', { status: 404 }); diff --git a/routes/photos.tsx b/routes/photos.tsx index 9e43f60..cba2986 100644 --- a/routes/photos.tsx +++ b/routes/photos.tsx @@ -2,7 +2,7 @@ import { Handlers, PageProps } from 'fresh/server.ts'; import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts'; import { isAppEnabled } from '/lib/config.ts'; -import { getDirectories, getFiles } from '/lib/data/files.ts'; +import { DirectoryModel, FileModel } from '/lib/models/files.ts'; import { PHOTO_EXTENSIONS } from '/lib/utils/photos.ts'; import PhotosWrapper from '/islands/photos/PhotosWrapper.tsx'; @@ -36,9 +36,9 @@ export const handler: Handlers = { currentPath = `${currentPath}/`; } - const userDirectories = await getDirectories(context.state.user.id, currentPath); + const userDirectories = await DirectoryModel.list(context.state.user.id, currentPath); - const userFiles = await getFiles(context.state.user.id, currentPath); + const userFiles = await FileModel.list(context.state.user.id, currentPath); const userPhotos = userFiles.filter((file) => { const lowercaseFileName = file.file_name.toLowerCase(); diff --git a/routes/photos/thumbnail/[fileName].tsx b/routes/photos/thumbnail/[fileName].tsx index de0d3fa..e8f7cea 100644 --- a/routes/photos/thumbnail/[fileName].tsx +++ b/routes/photos/thumbnail/[fileName].tsx @@ -2,7 +2,7 @@ import { Handlers } from 'fresh/server.ts'; import { resize } from 'https://deno.land/x/deno_image@0.0.4/mod.ts'; import { FreshContextState } from '/lib/types.ts'; -import { getFile } from '/lib/data/files.ts'; +import { FileModel } from '/lib/models/files.ts'; interface Data {} @@ -37,7 +37,7 @@ export const handler: Handlers = { currentPath = `${currentPath}/`; } - const fileResult = await getFile(context.state.user.id, currentPath, decodeURIComponent(fileName)); + const fileResult = await FileModel.get(context.state.user.id, currentPath, decodeURIComponent(fileName)); if (!fileResult.success) { return new Response('Not Found', { status: 404 }); diff --git a/routes/settings.tsx b/routes/settings.tsx index 351a753..0b9896e 100644 --- a/routes/settings.tsx +++ b/routes/settings.tsx @@ -2,13 +2,7 @@ import { Handlers, PageProps } from 'fresh/server.ts'; import { currencyMap, FreshContextState, SupportedCurrencySymbol } from '/lib/types.ts'; import { PASSWORD_SALT } from '/lib/auth.ts'; -import { - createVerificationCode, - deleteUser, - getUserByEmail, - updateUser, - validateVerificationCode, -} from '/lib/data/user.ts'; +import { UserModel, VerificationCodeModel } from '/lib/models/user.ts'; import { convertFormDataToObject, generateHash, validateEmail } from '/lib/utils/misc.ts'; import { getFormDataField } from '/lib/form-utils.tsx'; import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts'; @@ -72,14 +66,14 @@ export const handler: Handlers = { throw new Error(`New email is the same as the current email.`); } - const matchingUser = await getUserByEmail(email); + const matchingUser = await UserModel.getByEmail(email); if (matchingUser) { throw new Error('Email is already in use.'); } if (action === 'change-email' && isEmailEnabled()) { - const verificationCode = await createVerificationCode(user, email, 'email'); + const verificationCode = await VerificationCodeModel.create(user, email, 'email'); await sendVerifyEmailEmail(email, verificationCode); @@ -89,12 +83,12 @@ export const handler: Handlers = { if (isEmailEnabled()) { const code = getFormDataField(formData, 'verification-code'); - await validateVerificationCode(user, email, code, 'email'); + await VerificationCodeModel.validate(user, email, code, 'email'); } user.email = email; - await updateUser(user); + await UserModel.update(user); successTitle = 'Email updated!'; successMessage = 'Email updated successfully.'; @@ -120,7 +114,7 @@ export const handler: Handlers = { user.hashed_password = hashedNewPassword; - await updateUser(user); + await UserModel.update(user); successTitle = 'Password changed!'; successMessage = 'Password changed successfully.'; @@ -139,7 +133,7 @@ export const handler: Handlers = { user.extra.dav_hashed_password = hashedNewDavPassword; - await updateUser(user); + await UserModel.update(user); successTitle = 'DAV Password changed!'; successMessage = 'DAV Password changed successfully.'; @@ -152,7 +146,7 @@ export const handler: Handlers = { throw new Error('Invalid current password.'); } - await deleteUser(user.id); + await UserModel.delete(user.id); return new Response('Account deleted successfully', { status: 303, @@ -167,7 +161,7 @@ export const handler: Handlers = { user.extra.expenses_currency = newCurrencySymbol; - await updateUser(user); + await UserModel.update(user); successTitle = 'Currency changed!'; successMessage = 'Currency changed successfully.'; diff --git a/routes/signup.tsx b/routes/signup.tsx index 0ed8492..1365698 100644 --- a/routes/signup.tsx +++ b/routes/signup.tsx @@ -3,7 +3,7 @@ import { Handlers, PageProps } from 'fresh/server.ts'; import { generateHash, helpEmail, validateEmail } from '/lib/utils/misc.ts'; import { PASSWORD_SALT } from '/lib/auth.ts'; import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx'; -import { createUser, createVerificationCode, getUserByEmail, updateUser } from '/lib/data/user.ts'; +import { UserModel, VerificationCodeModel } from '/lib/models/user.ts'; import { sendVerifyEmailEmail } from '/lib/providers/brevo.ts'; import { isEmailEnabled, isSignupAllowed } from '/lib/config.ts'; import { FreshContextState } from '/lib/types.ts'; @@ -54,7 +54,7 @@ export const handler: Handlers = { throw new Error(`Password is too short.`); } - const existingUser = await getUserByEmail(email); + const existingUser = await UserModel.getByEmail(email); if (existingUser) { throw new Error('Email is already in use. Perhaps you want to login instead?'); @@ -62,10 +62,10 @@ export const handler: Handlers = { const hashedPassword = await generateHash(`${password}:${PASSWORD_SALT}`, 'SHA-256'); - const user = await createUser(email, hashedPassword); + const user = await UserModel.create(email, hashedPassword); if (isEmailEnabled()) { - const verificationCode = await createVerificationCode(user, user.email, 'email'); + const verificationCode = await VerificationCodeModel.create(user, user.email, 'email'); await sendVerifyEmailEmail(user.email, verificationCode); }