diff --git a/app/components/Icons.tsx b/app/components/Icons.tsx index 28166cd..eebb185 100644 --- a/app/components/Icons.tsx +++ b/app/components/Icons.tsx @@ -19,4 +19,18 @@ export const icons = { cog: , download: , refresh: , + users: , + trash: , + key: , + upload: , + calculator: ( + + + + + + + + + ), } as const; diff --git a/app/const/localization.ts b/app/const/localization.ts index 3d876d6..3fab37d 100644 --- a/app/const/localization.ts +++ b/app/const/localization.ts @@ -67,7 +67,14 @@ export const localization = { deleteDialogTitle: 'Delete Database?', deleteDialogMessage: (database: string) => `Are you sure you want to delete "${database}" database?`, - makeSuperUser: 'Make super user', + makeSuperUser: 'Make Institution Admin', + resetPasswords: 'Reset Passwords', + resetPasswordsDialogTitle: 'Reset All Passwords?', + resetPasswordsDialogMessage: (database: string) => + `Are you sure you want to reset all user passwords in the "${database}" database?`, + resettingPasswords: 'Resetting passwords...', + passwordsReset: 'All passwords have been reset successfully', + failedToResetPasswords: 'Failed to reset passwords', calculateSizes: 'Calculate sizes', uploadNewDatabase: 'Upload new database', databaseName: 'Database Name', @@ -79,6 +86,7 @@ export const localization = { mb: 'MB', nameConflict: 'Database with this name already exists. Please delete it first', + finalDatabaseName: 'Final database name:', container: 'Container', noLogsAvailable: 'No logs available for this container', failedToDownload: 'Failed to download', diff --git a/app/lib/databaseNameHelper.ts b/app/lib/databaseNameHelper.ts new file mode 100644 index 0000000..0d699fa --- /dev/null +++ b/app/lib/databaseNameHelper.ts @@ -0,0 +1,11 @@ +/** + * Generate a database name with today's date suffix + * Format: {baseName}_YYYY_MM_DD + */ +export function generateDatabaseNameWithDate(baseName: string): string { + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, '0'); + const day = String(today.getDate()).padStart(2, '0'); + return `${baseName}_${year}_${month}_${day}`; +} diff --git a/app/lib/passwordUtils.ts b/app/lib/passwordUtils.ts new file mode 100644 index 0000000..fb3b818 --- /dev/null +++ b/app/lib/passwordUtils.ts @@ -0,0 +1,19 @@ +import type { Connection } from 'mysql2/promise'; + +const testPassword = 'EC62DEF08F5E4FD556DAA86AEC5F3FB0390EF8A862A41ECA'; + +/** + * Resets all user passwords in the specified database + */ +export async function resetDatabasePasswords( + connection: Connection, + databaseName: string +): Promise { + await connection.execute( + `UPDATE \`${databaseName}\`.specifyuser + SET Password=?;`, + [testPassword] + ); +} + +export { testPassword }; diff --git a/app/pages/api/databases/[name]/export.ts b/app/pages/api/databases/[name]/export.ts index 4d423f5..d8d304c 100644 --- a/app/pages/api/databases/[name]/export.ts +++ b/app/pages/api/databases/[name]/export.ts @@ -24,17 +24,12 @@ export default async function handler( const sanitizedFilename = String(databaseName).replace(/[^\w.-]/g, '_'); try { - res.setHeader( - 'Content-Disposition', - `attachment; filename="${sanitizedFilename}.sql"` - ); - - const child = spawn( - 'mysqldump', + const result = await spawn( + 'mariadb-dump', [ - `-u${process.env.MYSQL_USERNAME}`, - `-p${process.env.MYSQL_PASSWORD}`, - `-h${process.env.MYSQL_HOST}`, + `--user=${process.env.MYSQL_USERNAME}`, + `--password=${process.env.MYSQL_PASSWORD}`, + `--host=${process.env.MYSQL_HOST}`, `--databases ${databaseName}`, '--no-create-db', ], @@ -44,11 +39,16 @@ export default async function handler( } ); - child.stdout.pipe(res); - child.stderr.on('data', (error) => { + res.setHeader( + 'Content-Disposition', + `attachment; filename="${sanitizedFilename}.sql"` + ); + + result.stdout.pipe(res); + result.stderr.on('data', (error) => { throw new Error(error); }); - await new Promise((resolve) => child.stdout.on('exit', resolve)); + await new Promise((resolve) => result.stdout.on('exit', resolve)); } catch (error) { res.status(500).json({ error: (error as object).toString(), diff --git a/app/pages/api/databases/[name]/reset-passwords.ts b/app/pages/api/databases/[name]/reset-passwords.ts new file mode 100644 index 0000000..96d555b --- /dev/null +++ b/app/pages/api/databases/[name]/reset-passwords.ts @@ -0,0 +1,40 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + +import { getUser } from '../../../../lib/apiUtils'; +import { connectToDatabase } from '../../../../lib/database'; +import { resetDatabasePasswords } from '../../../../lib/passwordUtils'; + +export default async function handler( + request: NextApiRequest, + res: NextApiResponse +) { + if (request.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const user = await getUser(request, res); + if (typeof user === 'undefined') return; + + const databaseName = request.query.name as string; + + if (!databaseName) { + return res.status(400).json({ error: 'Database name is required' }); + } + + // Validate database name to prevent SQL injection + if (databaseName.match(/^\w+$/) === null) { + return res.status(400).json({ error: 'Database name is invalid' }); + } + + try { + const connection = await connectToDatabase(); + + // Reset all user passwords to the test password + await resetDatabasePasswords(connection, databaseName); + + res.status(200).json({ success: true }); + } catch (error: any) { + console.error('Failed to reset passwords:', error); + res.status(500).json({ error: error.toString() }); + } +} diff --git a/app/pages/api/databases/upload.ts b/app/pages/api/databases/upload.ts index 1465c3c..b153786 100644 --- a/app/pages/api/databases/upload.ts +++ b/app/pages/api/databases/upload.ts @@ -6,6 +6,8 @@ import path from 'node:path'; import { getUser, run } from '../../../lib/apiUtils'; import { connectToDatabase } from '../../../lib/database'; +import { generateDatabaseNameWithDate } from '../../../lib/databaseNameHelper'; +import { resetDatabasePasswords } from '../../../lib/passwordUtils'; // First we need to disable the default body parser export const config = { @@ -14,8 +16,6 @@ export const config = { }, }; -const testuserPassword = 'EC62DEF08F5E4FD556DAA86AEC5F3FB0390EF8A862A41ECA'; - export default async function handler( request: NextApiRequest, res: NextApiResponse @@ -45,14 +45,16 @@ export default async function handler( if (typeof data === 'string') return res.status(400).json({ error: data }); - const databaseName = data.fields.databaseName as string | undefined; + const databaseNameForm = data.fields.databaseName as string | undefined; - if (!databaseName) + if (!databaseNameForm) return res.status(400).json({ error: 'Database name is required' }); - if (databaseName.match(/^\w+$/) === null) + if (databaseNameForm.match(/^\w+$/) === null) return res.status(400).json({ error: 'Database name is invalid' }); + const databaseName = generateDatabaseNameWithDate(databaseNameForm); + const file = data.files.file as File | undefined; if (typeof file === 'undefined' || file.newFilename === null) @@ -111,17 +113,13 @@ export default async function handler( await connection.execute(`CREATE DATABASE \`${databaseName}\``); await run( [ - `mysql -u${process.env.MYSQL_USERNAME} `, + `mariadb -u${process.env.MYSQL_USERNAME} `, `-p${process.env.MYSQL_PASSWORD} `, `-h${process.env.MYSQL_HOST} `, `--database "${databaseName}" < ${filePath}`, ].join('') ); - await connection.execute( - `UPDATE \`${databaseName}\`.specifyuser - SET Password=?;`, - [testuserPassword] - ); + await resetDatabasePasswords(connection, databaseName); res.writeHead(302, { Location: '/databases/', }); diff --git a/app/pages/api/dockerhub/[image].ts b/app/pages/api/dockerhub/[image].ts index aedd0f5..caa4dbf 100644 --- a/app/pages/api/dockerhub/[image].ts +++ b/app/pages/api/dockerhub/[image].ts @@ -19,6 +19,9 @@ type Response = { readonly name: string; readonly last_updated: string; readonly digest: string; + readonly images: RA<{ + readonly architecture: string; + }>; }>; readonly next: string | undefined; }; @@ -35,7 +38,12 @@ const processTagsResponse = (tags: Response['results']): IR => Object.fromEntries( tags // Latest is an unpredictable branch, thus will exclude it - .filter(({ name }) => !name.startsWith('sha-') && name !== 'latest') + .filter( + ({ name, images }) => + !name.startsWith('sha-') && + name !== 'latest' && + images.some(({ architecture }) => architecture === 'arm64') + ) .map(({ name, last_updated, digest }) => [ name, { diff --git a/app/pages/databases/index.tsx b/app/pages/databases/index.tsx index c6bf437..0083e64 100644 --- a/app/pages/databases/index.tsx +++ b/app/pages/databases/index.tsx @@ -2,11 +2,12 @@ import Link from 'next/link'; import React from 'react'; import { - dangerButtonClassName, infoButtonClassName, - primaryButtonClassName, successButtonClassName, + dangerButtonClassName, + primaryButtonClassName, } from '../../components/InteractivePrimitives'; +import { icons } from '../../components/Icons'; import Layout from '../../components/Layout'; import { Loading, ModalDialog } from '../../components/ModalDialog'; import { fetchApi, useApi } from '../../components/useApi'; @@ -37,6 +38,10 @@ export default function Index(): JSX.Element { string | undefined >(undefined); + const [resetPasswordsDatabase, setResetPasswordsDatabase] = React.useState< + string | undefined + >(undefined); + const [showSizes, setShowSizes] = React.useState(false); const [sizes, setSizes] = React.useState< { readonly data: IR } | string | undefined @@ -74,12 +79,27 @@ export default function Index(): JSX.Element {

{localization.pageTitle}

{localization.dashboard}

-
    +
    - + + {icons.upload} {localization.uploadNew} - - ))} -
+
    + {Object.entries(users.data).map(([id, name]) => ( +
  • + {name} + +
  • + ))} +
)} ); @@ -212,16 +240,17 @@ function DeleteDatabase({ buttons={ <> + {icons.trash} {localization.delete} @@ -233,3 +262,65 @@ function DeleteDatabase({ ); } + +function ResetPasswordsDatabase({ + database, + onClose: handleClose, +}: { + readonly database: string; + readonly onClose: () => void; +}): JSX.Element { + const [isResetting, setIsResetting] = React.useState(false); + + const handleResetPasswords = async (): Promise => { + setIsResetting(true); + try { + const response = await fetch(`/api/databases/${database}/reset-passwords`, { + method: 'POST', + }); + + if (response.ok) { + alert(localization.passwordsReset); + handleClose(); + } else { + const error = await response.json(); + alert(`${localization.failedToResetPasswords}: ${error.error || response.statusText}`); + } + } catch (error) { + console.error('Failed to reset passwords:', error); + alert(`${localization.failedToResetPasswords}: ${error}`); + } finally { + setIsResetting(false); + } + }; + + return ( + + + + + } + title={localization.resetPasswordsDialogTitle} + onClose={handleClose} + > + {localization.resetPasswordsDialogMessage(database)} + + ); +} diff --git a/app/pages/databases/upload.tsx b/app/pages/databases/upload.tsx index ad60d89..b991824 100644 --- a/app/pages/databases/upload.tsx +++ b/app/pages/databases/upload.tsx @@ -6,6 +6,7 @@ import Layout from '../../components/Layout'; import { useApi } from '../../components/useApi'; import { useDatabases } from '../index'; import { localization } from '../../const/localization'; +import { generateDatabaseNameWithDate } from '../../lib/databaseNameHelper'; const bytesToMb = (size: number): number => Math.round((size / 1024 / 1024) * 100) / 100; @@ -22,7 +23,8 @@ export default function Index(): JSX.Element { const isConflict = typeof databases === 'object' && - databases.some(({ name }) => name === databaseName); + Boolean(databaseName) && + databases.some(({ name }) => name === generateDatabaseNameWithDate(databaseName)); return ( @@ -91,6 +93,16 @@ export default function Index(): JSX.Element { diskUsage.data.free + fileSize >= diskUsage.data.size * 0.99 ? (

{localization.notEnoughSpace}

) : undefined} + {databaseName && ( +
+ +
+ {generateDatabaseNameWithDate(databaseName)} +
+
+ )}