diff --git a/README.md b/README.md index 1cef038..54c3415 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,17 @@ # ClosureBox a cloud service platform for serverless databases. -## Install +## Install and set up ```bash composer install npm run build # or dev php artisan migrate:fresh --seed # for testing envorinemnt php artisan serve + +#stripe +stripe login +stripe listen --forward-to http://closurebox.test/stripe/webhook +php artisan cashier:webhook ``` ## Project Layout @@ -22,3 +27,4 @@ php artisan serve /servers/PGDBServer # docker image to run the postgres database server/cluster /docs # for user manual and project documentation ``` + diff --git a/app/Http/Controllers/PostgresAdminController.php b/app/Http/Controllers/PostgresAdminController.php new file mode 100644 index 0000000..912b528 --- /dev/null +++ b/app/Http/Controllers/PostgresAdminController.php @@ -0,0 +1,364 @@ + extension_loaded('pdo'), + 'pdo_pgsql' => extension_loaded('pdo_pgsql'), + 'pgsql' => extension_loaded('pgsql'), + ]; + + $drivers = PDO::getAvailableDrivers(); + + return response()->json([ + 'success' => true, + 'extensions' => $extensions, + 'pdo_drivers' => $drivers, + 'php_version' => PHP_VERSION, + 'os' => PHP_OS, + ]); + } + + private function assertOwnedDatabase(string $databaseName): void + { + $owned = PGDBProduct::where('instance_id', $databaseName) + ->where('user_id', Auth::id()) + ->exists(); + if (!$owned) { + abort(403, 'You do not have access to this database.'); + } + } + + private function enforceAllowedHost(string $host): void + { + $allowed = array_map('trim', explode(',', (string) env('PG_ALLOWED_HOSTS', 'localhost,127.0.0.1'))); + if (!in_array($host, $allowed, true)) { + abort(403, 'Host is not allowed.'); + } + } + + private function buildPdoDsn(string $connectionString, ?array &$parsedOut = null): string + { + $dsn = $connectionString; + $parsed = parse_url($connectionString); + if ($parsed && isset($parsed['scheme']) && $parsed['scheme'] === 'postgresql') { + $dsn = sprintf( + 'pgsql:host=%s;port=%s;dbname=%s;user=%s;password=%s', + $parsed['host'] ?? 'localhost', + $parsed['port'] ?? '5432', + ltrim($parsed['path'] ?? '', '/'), + $parsed['user'] ?? '', + $parsed['pass'] ?? '' + ); + } + if ($parsedOut !== null) { + $parsedOut = $parsed ?: []; + } + return $dsn; + } + + private function extractConnectionMeta(string $connectionString): array + { + // Returns ['host' => string, 'port' => string, 'dbname' => string] + $parsed = parse_url($connectionString); + if ($parsed && isset($parsed['scheme']) && $parsed['scheme'] === 'postgresql') { + return [ + 'host' => $parsed['host'] ?? 'localhost', + 'port' => (string) ($parsed['port'] ?? '5432'), + 'dbname' => ltrim($parsed['path'] ?? '', '/'), + ]; + } + // Handle DSN format: pgsql:host=...;port=...;dbname=...;user=...;password=... + if (is_string($connectionString) && str_starts_with($connectionString, 'pgsql:')) { + $kv = substr($connectionString, strlen('pgsql:')); + $parts = array_filter(array_map('trim', explode(';', $kv))); + $data = []; + foreach ($parts as $part) { + [$k, $v] = array_pad(explode('=', $part, 2), 2, null); + if ($k !== null && $v !== null) { + $data[strtolower($k)] = $v; + } + } + return [ + 'host' => $data['host'] ?? 'localhost', + 'port' => (string) ($data['port'] ?? '5432'), + 'dbname' => (string) ($data['dbname'] ?? ''), + ]; + } + // Fallback + return [ + 'host' => 'localhost', + 'port' => '5432', + 'dbname' => '', + ]; + } + + private function applySessionGuards(PDO $pdo): void + { + $timeoutMs = (int) env('PG_STMT_TIMEOUT_MS', '5000'); + if ($timeoutMs > 0) { + $pdo->exec('SET statement_timeout TO ' . $timeoutMs); + } + } + + public function connect(Request $request): JsonResponse + { + $request->validate([ + 'connectionString' => 'required|string', + ]); + + try { + $connectionString = $request->input('connectionString'); + + if (!extension_loaded('pdo_pgsql')) { + return response()->json([ + 'success' => false, + 'message' => 'PostgreSQL PDO driver not available. Please install pdo_pgsql extension.' + ], 500); + } + + $parsed = []; + $pdoConnectionString = $this->buildPdoDsn($connectionString, $parsed); + + $meta = $this->extractConnectionMeta($connectionString); + $host = $meta['host']; + $dbName = $meta['dbname']; + $this->enforceAllowedHost($host); + $this->assertOwnedDatabase($dbName); + + $pdo = new PDO($pdoConnectionString); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->applySessionGuards($pdo); + + $stmt = $pdo->query('SELECT 1 as test'); + $result = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($result && $result['test'] == 1) { + return response()->json([ + 'success' => true, + 'message' => 'Connection successful to ' . $host . ':' . ($meta['port'] ?? '5432') . '/' . $dbName + ]); + } else { + return response()->json([ + 'success' => false, + 'message' => 'Connection test failed - unexpected response' + ], 500); + } + } catch (PDOException $e) { + $errorMessage = $e->getMessage(); + if (str_contains($errorMessage, 'could not find driver')) { + return response()->json([ + 'success' => false, + 'message' => 'PostgreSQL driver not found. Please ensure pdo_pgsql extension is installed and enabled.' + ], 500); + } + return response()->json([ + 'success' => false, + 'message' => 'Connection failed: ' . $errorMessage + ], 500); + } catch (\Exception $e) { + return response()->json([ + 'success' => false, + 'message' => 'Unexpected error: ' . $e->getMessage() + ], 500); + } + } + + public function executeQuery(Request $request): JsonResponse + { + $request->validate([ + 'connectionString' => 'required|string', + 'query' => 'required|string', + 'allowWrite' => 'sometimes|boolean', + 'maxRows' => 'sometimes|integer|min:1|max:' . (int) env('PG_MAX_ROWS', 2000), + ]); + + try { + $connectionString = $request->input('connectionString'); + $query = $request->input('query'); + $allowWrite = (bool) $request->boolean('allowWrite', true); + $maxRows = (int) ($request->input('maxRows', (int) env('PG_DEFAULT_ROWS', 500))); + $maxRows = max(1, min($maxRows, (int) env('PG_MAX_ROWS', 2000))); + + $pdoConnectionString = $this->buildPdoDsn($connectionString, $parsed); + $meta = $this->extractConnectionMeta($connectionString); + $host = $meta['host']; + $dbName = $meta['dbname']; + $this->enforceAllowedHost($host); + $this->assertOwnedDatabase($dbName); + + $isWrite = (bool) preg_match('/^\s*(INSERT|UPDATE|DELETE|ALTER|DROP|CREATE|TRUNCATE|GRANT|REVOKE|REINDEX|VACUUM|ANALYZE|COMMENT|REFRESH|CLUSTER|COPY|CALL|DO)\b/i', $query); + if ($isWrite && !$allowWrite) { + return response()->json([ + 'success' => false, + 'message' => 'Write operations are disabled in safe mode. Enable write to proceed.' + ], 403); + } + + $isSelect = (bool) preg_match('/^\s*(WITH\s+.*?\)\s*)?SELECT\b/is', $query); + if ($isSelect && !preg_match('/\blimit\s+\d+/i', $query) && !preg_match('/\bfetch\s+first\s+\d+/i', $query)) { + $trimmed = rtrim($query, "; \t\n\r"); + $query = $trimmed . ' LIMIT ' . $maxRows; + } + + $pdo = new PDO($pdoConnectionString); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->applySessionGuards($pdo); + + $start = microtime(true); + $stmt = $pdo->query($query); + $durationMs = (int) round((microtime(true) - $start) * 1000); + + Log::info('PG query executed', [ + 'user_id' => Auth::id(), + 'database' => $dbName, + 'is_write' => $isWrite, + 'duration_ms' => $durationMs, + ]); + + if ($stmt->columnCount() > 0) { + $results = $stmt->fetchAll(PDO::FETCH_ASSOC); + $columns = []; + if (count($results) > 0) { + $columns = array_keys($results[0]); + } + return response()->json([ + 'success' => true, + 'type' => 'select', + 'columns' => $columns, + 'results' => $results, + 'rowCount' => count($results) + ]); + } else { + $rowCount = $stmt->rowCount(); + return response()->json([ + 'success' => true, + 'type' => 'modify', + 'rowCount' => $rowCount, + 'message' => "Query executed successfully. {$rowCount} row(s) affected." + ]); + } + } catch (PDOException $e) { + return response()->json([ + 'success' => false, + 'message' => 'Query execution failed: ' . $e->getMessage() + ], 500); + } + } + + public function getTables(Request $request): JsonResponse + { + $request->validate([ + 'connectionString' => 'required|string', + ]); + + try { + $connectionString = $request->input('connectionString'); + + $pdoConnectionString = $this->buildPdoDsn($connectionString, $parsed); + $meta = $this->extractConnectionMeta($connectionString); + $host = $meta['host']; + $dbName = $meta['dbname']; + $this->enforceAllowedHost($host); + $this->assertOwnedDatabase($dbName); + + $pdo = new PDO($pdoConnectionString); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->applySessionGuards($pdo); + + $query = " + SELECT + table_name, + table_type, + table_schema + FROM information_schema.tables + WHERE table_schema NOT IN ('information_schema', 'pg_catalog') + ORDER BY table_schema, table_name + "; + + $stmt = $pdo->query($query); + $tables = $stmt->fetchAll(PDO::FETCH_ASSOC); + + return response()->json([ + 'success' => true, + 'tables' => $tables + ]); + } catch (PDOException $e) { + return response()->json([ + 'success' => false, + 'message' => 'Failed to fetch tables: ' . $e->getMessage() + ], 500); + } + } + + public function getTableStructure(Request $request): JsonResponse + { + $request->validate([ + 'connectionString' => 'required|string', + 'tableName' => 'required|string', + ]); + + try { + $connectionString = $request->input('connectionString'); + $tableName = $request->input('tableName'); + + $pdoConnectionString = $this->buildPdoDsn($connectionString, $parsed); + $meta = $this->extractConnectionMeta($connectionString); + $host = $meta['host']; + $dbName = $meta['dbname']; + $this->enforceAllowedHost($host); + $this->assertOwnedDatabase($dbName); + + $pdo = new PDO($pdoConnectionString); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->applySessionGuards($pdo); + + $query = " + SELECT + column_name, + data_type, + is_nullable, + column_default, + character_maximum_length, + numeric_precision, + numeric_scale + FROM information_schema.columns + WHERE table_name = ? + ORDER BY ordinal_position + "; + + $stmt = $pdo->prepare($query); + $stmt->execute([$tableName]); + $columns = $stmt->fetchAll(PDO::FETCH_ASSOC); + + return response()->json([ + 'success' => true, + 'columns' => $columns + ]); + } catch (PDOException $e) { + return response()->json([ + 'success' => false, + 'message' => 'Failed to fetch table structure: ' . $e->getMessage() + ], 500); + } + } +} diff --git a/app/Jobs/CreatePGDBRole.php b/app/Jobs/CreatePGDBRole.php deleted file mode 100644 index b7669d9..0000000 --- a/app/Jobs/CreatePGDBRole.php +++ /dev/null @@ -1,65 +0,0 @@ -user_id . '_' . $this->username; - $pg_manager->createNewLoginRole($role_name, $this->password, $this->permissions); - - PGDBRole::create([ - 'user_id' => $this->user_id, - 'username' => $role_name, - 'password' => $this->password, - ]); - } - catch (Exception $e) { - Log::error("Error creating role: " . $e->getMessage()); - throw $e; - } - } - - public function failed(?\Throwable $exception): void { - //delete the role from cluster and database - $pg_manager = new PGDBManagerService(); - $pg_manager->deleteRole($this->user_id . '_' . $this->username); - PGDBRole::where('username', $this->user_id . '_' . $this->username)->delete(); - } - - public function middleware() { - return [ - new IsAuthorized, - // new RateLimited('create-product'), - // new WithoutOverlapping($this->orginization->id), - ]; - } -} diff --git a/app/Jobs/DeletePGDBProduct.php b/app/Jobs/DeletePGDBProduct.php new file mode 100644 index 0000000..75383cb --- /dev/null +++ b/app/Jobs/DeletePGDBProduct.php @@ -0,0 +1,71 @@ +deleteDatabase($this->pgdb_product->instance_id); + } catch (Exception $e) { + Log::error("Error deleting database in handle method: " . $e->getMessage()); + } + + try { + $pg_manager->deleteRole($this->pgdb_role->username); + } catch (Exception $e) { + Log::error("Error deleting role in handle method: " . $e->getMessage()); + } + + // Always attempt to delete app records, regardless of cluster deletion outcome + try { + DB::transaction(function () { + PGDBRole::where('id', $this->pgdb_role->id)->delete(); + PGDBProduct::where('id', $this->pgdb_product->id)->delete(); + }, attempts: 3); + } catch (Exception $e) { + Log::error("Error deleting application records in handle method: " . $e->getMessage()); + } + } + + public function failed(?\Throwable $exception): void { + Log::error("Error during cleanup in failed method: " . $exception->getMessage()); + } + + public function middleware() { + return [ + new IsAuthorized, + // new RateLimited('create-product'), + // new WithoutOverlapping($this->orginization->id), + ]; + } +} diff --git a/app/Jobs/SyncPGwithUOD.php b/app/Jobs/SyncPGwithUOD.php index 575e228..7af83fb 100644 --- a/app/Jobs/SyncPGwithUOD.php +++ b/app/Jobs/SyncPGwithUOD.php @@ -5,7 +5,9 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use App\Services\PGDBManagerService; -use App\Models\User; +use App\Models\PGDBProduct; +use App\Models\PGDBRole; +use Illuminate\Support\Facades\Log; /** * Sync postgres server/cluster roles and databases with UOD PGDB_Products and PGDB_Roles */ @@ -25,58 +27,96 @@ public function __construct() */ public function handle(): void { - $service = new PGDBManagerService(); - - $databases = $service->getAllDatabases(); - $roles = $service->getUsers(); + $manager = new PGDBManagerService(); - if ($databases !== null) { - foreach ($databases as $database) { - $roleName = $role->rolname; - $rolePassword = $role->rolpassword; - $roleIsSuperuser = $role->rolsuper; - $roleIsLogin = $role->rolcanlogin; + // 1) Ensure all app roles exist on the cluster (loginable, non-superuser) + $appRoles = PGDBRole::all(); + foreach ($appRoles as $appRole) { + $roleName = $appRole->username; + if (!$manager->doesRoleExists($roleName)) { + try { + $manager->createNewLoginRole($roleName, $appRole->password, []); + Log::info("Created missing cluster role for app role: {$roleName}"); + } catch (\Throwable $e) { + Log::error("Failed creating cluster role {$roleName}: " . $e->getMessage()); + } + } + } + + // 2) Ensure all app databases exist on the cluster with correct owner + $appProducts = PGDBProduct::all(); + foreach ($appProducts as $product) { + $databaseName = $product->instance_id; + if (!$databaseName) { + continue; + } - if($roleIsLogin && $roleIsSuperuser){ - $user_id = explode('_', $roleName)[0]; - $user = User::where('id', $user_id)->first(); - - if($user){ - DB::table("pgdb_roles")->insert([ - "user_id" => $user_id, - "role_name" => $roleName, - "role_password" => $rolePassword, - "role_is_superuser" => $roleIsSuperuser, - "role_is_login" => $roleIsLogin, - ]); - + if (!$manager->doesDatabaseExists($databaseName)) { + // Determine owner role: first role linked to this product + $ownerRole = PGDBRole::where('pgdb_product_id', $product->id)->first(); + if (!$ownerRole) { + Log::warning("No owner role found for product {$product->id} ({$databaseName}); skipping DB creation"); + continue; + } + + // Ensure owner role exists before DB creation + if (!$manager->doesRoleExists($ownerRole->username)) { + try { + $manager->createNewLoginRole($ownerRole->username, $ownerRole->password, []); + Log::info("Created missing cluster role {$ownerRole->username} before creating database {$databaseName}"); + } catch (\Throwable $e) { + Log::error("Failed creating owner role {$ownerRole->username} for database {$databaseName}: " . $e->getMessage()); + continue; } } + + try { + $manager->createNewDatabaseForUser($databaseName, $ownerRole->username); + Log::info("Created missing database {$databaseName} with owner {$ownerRole->username}"); + } catch (\Throwable $e) { + Log::error("Failed creating database {$databaseName}: " . $e->getMessage()); + } } + } - // if ($roles !== null) { - // foreach ($roles as $role) { - // $roleName = $role->rolname; - // $rolePassword = $role->rolpassword; - // $roleIsSuperuser = $role->rolsuper; - // $roleIsLogin = $role->rolcanlogin; + // 3) Optional visibility: log cluster items that aren't in app UoD (only customer roles/dbs) + try { + $clusterRoles = $manager->getUsers(); + $clusterCustomerRoleNames = []; + foreach ((array) $clusterRoles as $role) { + // Only loginable, non-superuser roles + if (($role->rolcanlogin ?? false) && !($role->rolsuper ?? false)) { + $clusterCustomerRoleNames[] = $role->rolname; + } + } + + $appRoleNames = PGDBRole::pluck('username')->all(); + $extraClusterRoles = array_values(array_diff($clusterCustomerRoleNames, $appRoleNames)); + if (!empty($extraClusterRoles)) { + Log::warning('Cluster has loginable non-superuser roles not present in app UoD', ['roles' => $extraClusterRoles]); + } + } catch (\Throwable $e) { + Log::error('Failed while listing/logging cluster roles: ' . $e->getMessage()); + } - // if($roleIsLogin && $roleIsSuperuser){ - // $user_id = explode('_', $roleName)[0]; - // $user = User::where('id', $user_id)->first(); - - // if($user){ - // DB::table("pgdb_roles")->insert([ - // "user_id" => $user_id, - // "role_name" => $roleName, - // "role_password" => $rolePassword, - // "role_is_superuser" => $roleIsSuperuser, - // "role_is_login" => $roleIsLogin, - // ]); - - // } - // } - // } - // } + try { + $clusterDatabases = $manager->getAllDatabases(); + $clusterDbNames = []; + foreach ((array) $clusterDatabases as $db) { + $name = $db->datname ?? null; + if (!$name) { continue; } + // Exclude system templates/common DBs + if (in_array($name, ['template0', 'template1', 'postgres'])) { continue; } + $clusterDbNames[] = $name; + } + + $appDbNames = PGDBProduct::pluck('instance_id')->filter()->all(); + $extraClusterDbs = array_values(array_diff($clusterDbNames, $appDbNames)); + if (!empty($extraClusterDbs)) { + Log::warning('Cluster has databases not present in app UoD', ['databases' => $extraClusterDbs]); + } + } catch (\Throwable $e) { + Log::error('Failed while listing/logging cluster databases: ' . $e->getMessage()); + } } } diff --git a/app/Listeners/StripeEventListener.php b/app/Listeners/StripeEventListener.php new file mode 100644 index 0000000..ebaa048 --- /dev/null +++ b/app/Listeners/StripeEventListener.php @@ -0,0 +1,25 @@ +quoteIdentifier($username); $this->connection->select(" - DROP ROLE $username + DROP ROLE $quotedUsername "); } catch(Exception $e){ @@ -111,20 +113,22 @@ public function deleteRole( } } + /** + * deletes a database and the role that owns it + * @param string $databaseName + * @return void + */ public function deleteDatabase(string $databaseName){ try{ // Check if database exists before trying to delete it $dbExists = $this->connection->select("SELECT 1 FROM pg_database WHERE datname = ?", [$databaseName]); - if (empty($dbExists)) { Log::info("Database {$databaseName} does not exist, skipping deletion"); return; } - - // Use proper quoting for database names that might contain special characters $quotedDbName = $this->quoteIdentifier($databaseName); $this->connection->select("DROP DATABASE $quotedDbName"); - } + } catch(Exception $e){ Log::error("Error deleting database: " . $e->getMessage()); throw $e; @@ -151,9 +155,6 @@ public function getAllDatabases(): array { return $databases; } - public function getDatabasesForUser() {} - - public function doesDatabaseExists(string $databaseName): bool { $databaseExists = $this->connection->select("SELECT 1 FROM pg_database WHERE datname = ?", [$databaseName]); return !empty($databaseExists); diff --git a/app/Services/StripeService.php b/app/Services/StripeService.php new file mode 100644 index 0000000..326b4a1 --- /dev/null +++ b/app/Services/StripeService.php @@ -0,0 +1,13 @@ +charge($amount, $paymentMethodId, $options); + } +} \ No newline at end of file diff --git a/bootstrap/app.php b/bootstrap/app.php index 90f0567..6a816fa 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -22,7 +22,12 @@ HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, ]); - + + }) + ->withMiddleware(function (Middleware $middleware) { + $middleware->validateCsrfTokens(except: [ + 'stripe/*', + ]); }) ->withExceptions(function (Exceptions $exceptions) { // diff --git a/config/cashier.php b/config/cashier.php new file mode 100644 index 0000000..4a9b024 --- /dev/null +++ b/config/cashier.php @@ -0,0 +1,127 @@ + env('STRIPE_KEY'), + + 'secret' => env('STRIPE_SECRET'), + + /* + |-------------------------------------------------------------------------- + | Cashier Path + |-------------------------------------------------------------------------- + | + | This is the base URI path where Cashier's views, such as the payment + | verification screen, will be available from. You're free to tweak + | this path according to your preferences and application design. + | + */ + + 'path' => env('CASHIER_PATH', 'stripe'), + + /* + |-------------------------------------------------------------------------- + | Stripe Webhooks + |-------------------------------------------------------------------------- + | + | Your Stripe webhook secret is used to prevent unauthorized requests to + | your Stripe webhook handling controllers. The tolerance setting will + | check the drift between the current time and the signed request's. + | + */ + + 'webhook' => [ + 'secret' => env('STRIPE_WEBHOOK_SECRET'), + 'tolerance' => env('STRIPE_WEBHOOK_TOLERANCE', 300), + 'events' => WebhookCommand::DEFAULT_EVENTS, + ], + + /* + |-------------------------------------------------------------------------- + | Currency + |-------------------------------------------------------------------------- + | + | This is the default currency that will be used when generating charges + | from your application. Of course, you are welcome to use any of the + | various world currencies that are currently supported via Stripe. + | + */ + + 'currency' => env('CASHIER_CURRENCY', 'usd'), + + /* + |-------------------------------------------------------------------------- + | Currency Locale + |-------------------------------------------------------------------------- + | + | This is the default locale in which your money values are formatted in + | for display. To utilize other locales besides the default en locale + | verify you have the "intl" PHP extension installed on the system. + | + */ + + 'currency_locale' => env('CASHIER_CURRENCY_LOCALE', 'en'), + + /* + |-------------------------------------------------------------------------- + | Payment Confirmation Notification + |-------------------------------------------------------------------------- + | + | If this setting is enabled, Cashier will automatically notify customers + | whose payments require additional verification. You should listen to + | Stripe's webhooks in order for this feature to function correctly. + | + */ + + 'payment_notification' => env('CASHIER_PAYMENT_NOTIFICATION'), + + /* + |-------------------------------------------------------------------------- + | Invoice Settings + |-------------------------------------------------------------------------- + | + | The following options determine how Cashier invoices are converted from + | HTML into PDFs. You're free to change the options based on the needs + | of your application or your preferences regarding invoice styling. + | + */ + + 'invoices' => [ + 'renderer' => env('CASHIER_INVOICE_RENDERER', DompdfInvoiceRenderer::class), + + 'options' => [ + // Supported: 'letter', 'legal', 'A4' + 'paper' => env('CASHIER_PAPER', 'letter'), + + 'remote_enabled' => env('CASHIER_REMOTE_ENABLED', false), + ], + ], + + /* + |-------------------------------------------------------------------------- + | Stripe Logger + |-------------------------------------------------------------------------- + | + | This setting defines which logging channel will be used by the Stripe + | library to write log messages. You are free to specify any of your + | logging channels listed inside the "logging" configuration file. + | + */ + + 'logger' => env('CASHIER_LOGGER'), + +]; diff --git a/database/migrations/2025_08_15_022308_create_customer_columns.php b/database/migrations/2025_08_15_022308_create_customer_columns.php index 2af3af2..0db88b3 100644 --- a/database/migrations/2025_08_15_022308_create_customer_columns.php +++ b/database/migrations/2025_08_15_022308_create_customer_columns.php @@ -20,13 +20,12 @@ public function up(): void }); //org billing account details - Schema::table('organizations', function (Blueprint $table) { - $table->string('stripe_id')->nullable()->index(); - $table->string('pm_type')->nullable(); - $table->string('pm_last_four', 4)->nullable(); - $table->timestamp('trial_ends_at')->nullable(); - }); - + // Schema::table('organizations', function (Blueprint $table) { + // $table->string('stripe_id')->nullable()->index(); + // $table->string('pm_type')->nullable(); + // $table->string('pm_last_four', 4)->nullable(); + // $table->timestamp('trial_ends_at')->nullable(); + // }); } /** @@ -47,21 +46,19 @@ public function down(): void ]); }); - Schema::table('organizations', function (Blueprint $table) { - $table->dropIndex([ - 'stripe_id', - ]); - }); - - Schema::table('organizations', function (Blueprint $table) { - $table->dropColumn([ - 'stripe_id', - 'pm_type', - 'pm_last_four', - 'trial_ends_at', - ]); - }); + // Schema::table('organizations', function (Blueprint $table) { + // $table->dropIndex([ + // 'stripe_id', + // ]); + // }); - + // Schema::table('organizations', function (Blueprint $table) { + // $table->dropColumn([ + // 'stripe_id', + // 'pm_type', + // 'pm_last_four', + // 'trial_ends_at', + // ]); + // }); } }; diff --git a/docs/internal/todo.md b/docs/internal/todo.md index 34d8948..09d0151 100644 --- a/docs/internal/todo.md +++ b/docs/internal/todo.md @@ -1,19 +1,17 @@ -1. database as a service - - /databases page - - create database model - - crud - - /databases/id page - - details - - tables - - settings tab +- database features + - rate limiting + - details page + - settings tab + - delete database -2. stripe intergration +- stripe intergration - create account with email verification link (mail) + oauth - link with stripe account - billing/payment system - invoice system - -3. polish - -4. ship \ No newline at end of file +- ui polish + - react ui for landing, pricing, account, databases, dashboard, database indivudal +- ship + - host + - domain name \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index db7fae3..d1550c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "titanbox", + "name": "closurebox", "lockfileVersion": 3, "requires": true, "packages": { @@ -17,10 +17,13 @@ "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", "@tailwindcss/vite": "^4.1.11", + "@tanstack/react-query": "^5.85.3", + "@types/pg": "^8.15.5", "@types/react": "^19.0.3", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.6.0", @@ -34,6 +37,7 @@ "globals": "^15.14.0", "laravel-vite-plugin": "^2.0", "lucide-react": "^0.475.0", + "pg": "^8.16.3", "react": "^19.1.1", "react-dom": "^19.1.1", "tailwind-merge": "^3.0.1", @@ -1519,6 +1523,36 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toggle": { "version": "1.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", @@ -2366,6 +2400,32 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@tanstack/query-core": { + "version": "5.85.3", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.3.tgz", + "integrity": "sha512-9Ne4USX83nHmRuEYs78LW+3lFEEO2hBDHu7mrdIgAFx5Zcrs7ker3n/i8p4kf6OgKExmaDN5oR0efRD7i2J0DQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.85.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.3.tgz", + "integrity": "sha512-AqU8TvNh5GVIE8I+TUU0noryBRy7gOY0XhSayVXmOPll4UkZeLWKDwi0rtWOZbwLRCbyxorfJ5DIjDqE7GXpcQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.85.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-virtual": { "version": "3.13.12", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", @@ -2433,6 +2493,26 @@ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" }, + "node_modules/@types/node": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, + "node_modules/@types/pg": { + "version": "8.15.5", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz", + "integrity": "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.1.10", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz", @@ -3580,6 +3660,95 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3628,6 +3797,45 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3906,6 +4114,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -4044,6 +4261,12 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -4231,6 +4454,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 3808702..9642a8b 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,13 @@ "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", "@tailwindcss/vite": "^4.1.11", + "@tanstack/react-query": "^5.85.3", + "@types/pg": "^8.15.5", "@types/react": "^19.0.3", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.6.0", @@ -35,6 +38,7 @@ "globals": "^15.14.0", "laravel-vite-plugin": "^2.0", "lucide-react": "^0.475.0", + "pg": "^8.16.3", "react": "^19.1.1", "react-dom": "^19.1.1", "tailwind-merge": "^3.0.1", diff --git a/resources/css/app.css b/resources/css/app.css index e1ddc5a..cc21715 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -34,6 +34,7 @@ --color-accent-content: var(--color-white); --color-accent-foreground: var(--color-neutral-800); } + } @layer base { diff --git a/resources/inertia/components/app-sidebar.tsx b/resources/inertia/components/app-sidebar.tsx index 7f7be69..1f789be 100644 --- a/resources/inertia/components/app-sidebar.tsx +++ b/resources/inertia/components/app-sidebar.tsx @@ -19,6 +19,16 @@ const mainNavItems: NavItem[] = [ href: '/servers', icon: Server, }, + { + title: 'Databases', + href: '/databases', + icon: Folder, + }, + { + title: 'PostgreSQL Admin', + href: '/postgres-admin', + icon: Server, + }, ]; const footerNavItems: NavItem[] = [ diff --git a/resources/inertia/components/cn/tabs.tsx b/resources/inertia/components/cn/tabs.tsx new file mode 100644 index 0000000..9eef97f --- /dev/null +++ b/resources/inertia/components/cn/tabs.tsx @@ -0,0 +1,53 @@ +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/resources/inertia/pages/PostgresAdmin/Index.tsx b/resources/inertia/pages/PostgresAdmin/Index.tsx new file mode 100644 index 0000000..c7dfce4 --- /dev/null +++ b/resources/inertia/pages/PostgresAdmin/Index.tsx @@ -0,0 +1,593 @@ +import React, { useState, useRef } from 'react'; +import { Head } from '@inertiajs/react'; +import axios from 'axios'; +import { Button } from '@/components/cn/button'; +import { Input } from '@/components/cn/input'; +import { Label } from '@/components/cn/label'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/cn/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/cn/tabs'; +import { Badge } from '@/components/cn/badge'; +import { + Database, + Play, + Table, + Columns, + CheckCircle, + XCircle, + Loader2, + RefreshCw, + ShieldAlert +} from 'lucide-react'; + +interface QueryResult { + type: 'select' | 'modify'; + columns?: string[]; + results?: any[]; + rowCount?: number; + message?: string; +} + +interface TableInfo { + table_name: string; + table_type: string; + table_schema: string; +} + +interface ColumnInfo { + column_name: string; + data_type: string; + is_nullable: string; + column_default: string | null; + character_maximum_length: number | null; + numeric_precision: number | null; + numeric_scale: number | null; +} + +const WRITE_REGEX = /^\s*(INSERT|UPDATE|DELETE|ALTER|DROP|CREATE|TRUNCATE|GRANT|REVOKE|REINDEX|VACUUM|ANALYZE|COMMENT|REFRESH|CLUSTER|COPY|CALL|DO)\b/i; + +export default function PostgresAdmin() { + const [connectionString, setConnectionString] = useState('pgsql:host=localhost;port=5432;dbname=user_1_iwzKZvLVbF5v;user=user_1_t8IRM6hPPEyZ;password=Tt3yKoobvcZq'); + const [isConnected, setIsConnected] = useState(false); + const [connectionStatus, setConnectionStatus] = useState<'idle' | 'connecting' | 'connected' | 'error'>('idle'); + const [connectionMessage, setConnectionMessage] = useState(''); + + // Safety controls + const [safeMode, setSafeMode] = useState(true); // read-only by default + const [maxRows, setMaxRows] = useState(500); + + const [query, setQuery] = useState('SELECT * FROM information_schema.tables LIMIT 10'); + const [queryResult, setQueryResult] = useState(null); + const [isExecuting, setIsExecuting] = useState(false); + const [executionMessage, setExecutionMessage] = useState(''); + + const [tables, setTables] = useState([]); + const [selectedTable, setSelectedTable] = useState(''); + const [tableStructure, setTableStructure] = useState([]); + const [isLoadingTables, setIsLoadingTables] = useState(false); + const [isLoadingStructure, setIsLoadingStructure] = useState(false); + const [environmentInfo, setEnvironmentInfo] = useState(null); + + const queryInputRef = useRef(null); + + const isWriteQuery = (sql: string) => WRITE_REGEX.test(sql); + + const testConnection = async () => { + setConnectionStatus('connecting'); + setConnectionMessage(''); + + try { + const response = await axios.post('/postgres-admin/connect', { + connectionString + }); + + if (response.data.success) { + setIsConnected(true); + setConnectionStatus('connected'); + setConnectionMessage(response.data.message); + loadTables(); + } else { + setConnectionStatus('error'); + setConnectionMessage(response.data.message); + } + } catch (error: any) { + setConnectionStatus('error'); + setConnectionMessage(error.response?.data?.message || 'Connection failed'); + } + }; + + const testEnvironment = async () => { + try { + const response = await axios.get('/postgres-admin/test-env'); + if (response.data.success) { + setEnvironmentInfo(response.data); + } + } catch (error: any) { + console.error('Failed to test environment:', error); + } + }; + + const executeQuery = async () => { + if (!query.trim()) return; + + const willWrite = isWriteQuery(query); + if (willWrite && safeMode) { + setExecutionMessage('Write operations are disabled in Safe Mode. Disable Safe Mode to proceed.'); + return; + } + if (willWrite && !safeMode) { + const confirmed = window.confirm('This query will modify your database. Do you want to proceed?'); + if (!confirmed) return; + } + + setIsExecuting(true); + setExecutionMessage(''); + setQueryResult(null); + + try { + const response = await axios.post('/postgres-admin/execute-query', { + connectionString, + query: query.trim(), + allowWrite: !safeMode, + maxRows: maxRows, + }); + + if (response.data.success) { + setQueryResult(response.data); + setExecutionMessage(response.data.message || 'Query executed successfully'); + } else { + setExecutionMessage(response.data.message || 'Query execution failed'); + } + } catch (error: any) { + setExecutionMessage(error.response?.data?.message || 'Query execution failed'); + } finally { + setIsExecuting(false); + } + }; + + const loadTables = async () => { + setIsLoadingTables(true); + try { + const response = await axios.post('/postgres-admin/tables', { + connectionString + }); + + if (response.data.success) { + setTables(response.data.tables); + } + } catch (error: any) { + console.error('Failed to load tables:', error); + } finally { + setIsLoadingTables(false); + } + }; + + const loadTableStructure = async (tableName: string) => { + setSelectedTable(tableName); + setIsLoadingStructure(true); + + try { + const response = await axios.post('/postgres-admin/table-structure', { + connectionString, + tableName + }); + + if (response.data.success) { + setTableStructure(response.data.columns); + } + } catch (error: any) { + console.error('Failed to load table structure:', error); + } finally { + setIsLoadingStructure(false); + } + }; + + const formatValue = (value: any): string => { + if (value === null || value === undefined) return 'NULL'; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); + }; + + return ( + <> + + + +
+
+ +

PostgreSQL Admin

+
+ + {/* Connection & Safety Section */} + + + + + Database Connection + + + Connect to your PostgreSQL database using a DSN connection string + + + +
+ +
+ setConnectionString(e.target.value)} + placeholder="pgsql:host=...;port=5432;dbname=...;user=...;password=..." + className="flex-1" + /> + + +
+
+ + {/* Safe mode controls */} +
+
+
+
+ +
+
Safe Mode (Read-only)
+
Blocks destructive queries unless disabled
+
+
+
+ + { + const enableWrites = e.target.checked; + if (enableWrites) { + const ok = window.confirm('Enabling write operations allows queries that modify your database (e.g., DROP/DELETE/ALTER). Proceed?'); + if (!ok) return; + } + setSafeMode(!enableWrites ? true : false); + }} + /> +
+
+
+
+ + setMaxRows(Number(e.target.value) || 1)} + /> +
+
+ + {connectionStatus !== 'idle' && ( +
+ {connectionStatus === 'connected' ? ( + + ) : connectionStatus === 'error' ? ( + + ) : ( + + )} + + {connectionMessage || 'Testing connection...'} + +
+ )} + + {environmentInfo && ( +
+

Environment Information

+
+
PHP Version: {environmentInfo.php_version}
+
OS: {environmentInfo.os}
+
PDO Extension: {environmentInfo.extensions.pdo ? '✅' : '❌'}
+
PDO PostgreSQL: {environmentInfo.extensions.pdo_pgsql ? '✅' : '❌'}
+
PostgreSQL Extension: {environmentInfo.extensions.pgsql ? '✅' : '❌'}
+
Available PDO Drivers: {environmentInfo.pdo_drivers.join(', ') || 'None'}
+
+
+ )} +
+
+ + {isConnected && ( + + + Query Editor + Tables + Table Structure + + + {/* Query Editor Tab */} + + + + + + SQL Query Editor + + + Execute SQL queries against your database + + + + {isWriteQuery(query) && safeMode && ( +
+ This query appears to modify data. Safe Mode is ON and will block this execution. Disable Safe Mode to proceed. +
+ )} + +
+ +