diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 2eee7570d8..bcc89631f7 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,87 +1,87 @@ name: Bug Report -description: Something isn't working quite right in the software. -labels: [not confirmed] +description: Something isn't working quite right in M12Labs. +labels: [bug, not confirmed] body: -- type: markdown - attributes: - value: | - Bug reports should only be used for reporting issues with how the software works. For assistance installing this software, as well as debugging issues with dependencies, please use our [Discord server](https://discord.gg/qttGR4Z5Pk). + - type: markdown + attributes: + value: | + > [!IMPORTANT] + > Bug reports should only be used for reporting issues with how the panel works. + > For general help or support, please visit our [Discord](https://discord.gg/fVJZtqKYrc). -- type: textarea - attributes: - label: Current Behavior - description: Please provide a clear & concise description of the issue. - validations: - required: true + - type: checkboxes + attributes: + label: Pre-flight Checklist + options: + - label: I have searched the existing issues and confirmed this has not been reported before. + required: true + - label: I believe this is a bug with the software, not a configuration issue with my system. + required: true -- type: textarea - attributes: - label: Expected Behavior - description: Please describe what you expected to happen. - validations: - required: true + - type: textarea + attributes: + label: What's Happening + description: A clear and concise description of the bug you are experiencing. + validations: + required: true -- type: textarea - attributes: - label: Steps to Reproduce - description: Please be as detailed as possible when providing steps to reproduce, failure to provide steps will result in this issue being closed. - validations: - required: true + - type: textarea + attributes: + label: Steps to Reproduce + description: > + Provide detailed step-by-step instructions to reproduce the issue. + Issues without clear reproduction steps may be closed without investigation. + validations: + required: true -- type: input - id: panel-version - attributes: - label: Panel Version - description: Version number of your Panel (latest is not a version) - placeholder: 1.4.0 - validations: - required: true + - type: input + id: panel-version + attributes: + label: Panel Version + description: The version of M12Labs you are running. "Latest" is not a version. + placeholder: 2.0.0-alpha-2.7 + validations: + required: true -- type: input - id: wings-version - attributes: - label: Wings Version - description: Version number of your Wings (latest is not a version) - placeholder: 1.4.2 - validations: - required: true - -- type: input - id: egg-details - attributes: - label: Games and/or Eggs Affected - description: Please include the specific game(s) or egg(s) you are running into this bug with. - placeholder: Minecraft (Paper), Minecraft (Forge) - -- type: input - id: docker-image - attributes: - label: Docker Image - description: The specific Docker image you are using for the game(s) above. - placeholder: ghcr.io/pterodactyl/yolks:java_21 + - type: input + id: wings-version + attributes: + label: Wings Version + description: The version of Wings you are running. "Latest" is not a version. + placeholder: 1.4.2 + validations: + required: false -- type: textarea - id: panel-logs - attributes: - label: Error Logs - description: | - Run the following command to collect logs on your system. - - Wings: `sudo wings diagnostics` - Panel: `tail -n 150 /var/www/jexactyl/storage/logs/laravel-$(date +%F).log | nc pteropaste.com 99` - placeholder: "https://pteropaste.com/a1h6z" - render: bash - validations: - required: false - -- type: checkboxes - attributes: - label: Is there an existing issue for this? - description: Please [search here](https://github.com/jexcactyl/jexactyl/issues) to see if an issue already exists for your problem. - options: - - label: I have searched the existing issues before opening this issue. - required: true - - label: I have provided all relevant details, including the specific game and Docker images I am using if this issue is related to running a server. - required: true - - label: I have checked in the Discord server and believe this is a bug with the software, and not a configuration issue with my specific system. - required: true + - type: checkboxes + id: extensions-installed + attributes: + label: Extensions Installed + description: Are you running any M12Labs extensions? + options: + - label: "Yes, I have extensions installed." + + - type: textarea + id: extensions-list + attributes: + label: Extensions List + description: If yes above, please list the extensions and their versions. + placeholder: "ExtensionA v1.0, ExtensionB v2.3" + validations: + required: false + + - type: textarea + id: error-logs + attributes: + label: Error Logs + description: Paste any relevant error logs here. + render: bash + validations: + required: false + + - type: textarea + id: extra-comments + attributes: + label: Additional Comments + description: Anything else that may help diagnose this issue? + validations: + required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 7e91bf0b01..3d41ed677b 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,8 @@ blank_issues_enabled: true contact_links: - name: Installation Help - url: https://discord.gg/qttGR4Z5Pk + url: https://discord.gg/fVJZtqKYrc about: Please visit our Discord for help with your installation. - name: General Question - url: https://discord.gg/qttGR4Z5Pk + url: https://discord.gg/fVJZtqKYrc about: Please visit our Discord for general questions about Jexactyl. diff --git a/.gitignore b/.gitignore index 7e347647e6..a7cb960992 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ docker-compose.yaml Caddyfile *.pem package-lock.json +wings/wings-rs/ \ No newline at end of file diff --git a/Containerfile b/Containerfile deleted file mode 100644 index bc922b22e8..0000000000 --- a/Containerfile +++ /dev/null @@ -1,77 +0,0 @@ -# Stage 1 - Builder -FROM --platform=$TARGETOS/$TARGETARCH registry.access.redhat.com/ubi9/nodejs-18-minimal AS builder - -USER 0 -RUN npm install -g pnpm - -WORKDIR /var/www/pterodactyl - -COPY --chown=1001:0 public ./public -COPY --chown=1001:0 resources/scripts ./resources/scripts -COPY --chown=1001:0 .eslintignore .eslintrc.js .npmrc .prettierrc.json package.json pnpm-lock.yaml tailwind.config.js tsconfig.json vite.config.ts . - -RUN /opt/app-root/src/.npm-global/bin/pnpm install \ - && /opt/app-root/src/.npm-global/bin/pnpm build \ - && rm -rf resources/scripts .eslintignore .eslintrc.yml .npmrc package.json pnpm-lock.yaml tailwind.config.js tsconfig.json vite.config.ts node_modules - -USER 1001 - -COPY --chown=1001:0 app ./app -COPY --chown=1001:0 bootstrap ./bootstrap -COPY --chown=1001:0 config ./config -COPY --chown=1001:0 database ./database -COPY --chown=1001:0 resources/lang ./resources/lang -COPY --chown=1001:0 resources/views ./resources/views -COPY --chown=1001:0 routes ./routes -COPY --chown=1001:0 .env.example ./.env -COPY --chown=1001:0 artisan CHANGELOG.md composer.json composer.lock LICENSE.md README.md SECURITY.md . - -# Stage 2 - Final -FROM --platform=$TARGETOS/$TARGETARCH registry.access.redhat.com/ubi9/ubi-minimal - -RUN microdnf update -y \ - && rpm --install https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm \ - && rpm --install https://rpms.remirepo.net/enterprise/remi-release-9.rpm \ - && microdnf update -y \ - && microdnf install -y ca-certificates shadow-utils tar tzdata unzip wget \ -# ref; https://bugzilla.redhat.com/show_bug.cgi?id=1870814 - && microdnf reinstall -y tzdata \ - && microdnf module -y reset php \ - && microdnf module -y enable php:remi-8.2 \ - && microdnf install -y composer cronie php-{bcmath,cli,common,fpm,gd,gmp,intl,json,mbstring,mysqlnd,opcache,pdo,pecl-redis5,pecl-zip,phpiredis,pgsql,process,sodium,xml,zstd} supervisor \ - && rm /etc/php-fpm.d/www.conf \ - && useradd --home-dir /var/lib/caddy --create-home caddy \ - && mkdir /etc/caddy \ - && wget -O /usr/local/bin/yacron https://github.com/gjcarneiro/yacron/releases/download/0.17.0/yacron-0.17.0-x86_64-unknown-linux-gnu \ - && chmod 755 /usr/local/bin/yacron \ - && microdnf remove -y tar wget \ - && microdnf clean all - -COPY --chown=caddy:caddy --from=builder /var/www/pterodactyl /var/www/pterodactyl - -WORKDIR /var/www/pterodactyl - -RUN mkdir -p /tmp/pterodactyl/cache /tmp/pterodactyl/framework/{cache,sessions,views} storage/framework \ - && rm -rf bootstrap/cache storage/framework/sessions storage/framework/views storage/framework/cache \ - && ln -s /tmp/pterodactyl/cache /var/www/pterodactyl/bootstrap/cache \ - && ln -s /tmp/pterodactyl/framework/cache /var/www/pterodactyl/storage/framework/cache \ - && ln -s /tmp/pterodactyl/framework/sessions /var/www/pterodactyl/storage/framework/sessions \ - && ln -s /tmp/pterodactyl/framework/views /var/www/pterodactyl/storage/framework/views \ - && chmod -R 755 /var/www/pterodactyl/storage/* /tmp/pterodactyl/cache \ - && chown -R caddy:caddy /var/www/pterodactyl /tmp/pterodactyl/{cache,framework} - -USER caddy -ENV USER=caddy - -RUN composer install --no-dev --optimize-autoloader \ - && rm -rf bootstrap/cache/*.php \ - && rm -rf .env storage/logs/*.log - -COPY --from=docker.io/library/caddy:latest /usr/bin/caddy /usr/local/bin/caddy -COPY .github/docker/Caddyfile /etc/caddy/Caddyfile -COPY .github/docker/php-fpm.conf /etc/php-fpm.conf -COPY .github/docker/supervisord.conf /etc/supervisord.conf -COPY .github/docker/yacron.yaml /etc/yacron.yaml - -EXPOSE 8080 -CMD ["/usr/bin/supervisord", "--configuration=/etc/supervisord.conf"] diff --git a/README.md b/README.md index 027ab9ef41..cdba19407f 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ It extends the original Jexpanel foundation with more features, deeper customiza - Advanced authentication and security setups - Integrated billing system (Stripe + PayPal + mollie) - Clean, user-friendly administrative interface -- Built with modern tech: PHP, Laravel, TypeScript, React, Docker +- Built with modern tech: PHP, Laravel, TypeScript, React - Fully open-source, community-driven ## Useful Links diff --git a/TODO.md b/TODO.md deleted file mode 100644 index e84bcca67d..0000000000 --- a/TODO.md +++ /dev/null @@ -1,2 +0,0 @@ -cleanup frontend api stack -lock down billing routes/pages when module is disabled (e.g. renewals) diff --git a/app/Console/Commands/Extensions/Concerns/HandlesExtensionPackages.php b/app/Console/Commands/Extensions/Concerns/HandlesExtensionPackages.php new file mode 100644 index 0000000000..71b7797731 --- /dev/null +++ b/app/Console/Commands/Extensions/Concerns/HandlesExtensionPackages.php @@ -0,0 +1,294 @@ + + */ + protected function resolveResolution(string $source, string $action): array + { + /** @var ExtensionPackageArtifactService $artifactService */ + $artifactService = app(ExtensionPackageArtifactService::class); + + $cwd = getcwd() ?: base_path(); + $discoveredArtifacts = $artifactService->discoverArchives($cwd); + $validArtifacts = array_values(array_filter($discoveredArtifacts, fn (array $a): bool => !isset($a['error']))); + $explicitPath = $this->option('path') ? trim((string) $this->option('path')) : null; + + if ($explicitPath !== null && $explicitPath !== '') { + return $this->createFileResolution($explicitPath, $cwd, $discoveredArtifacts, $source); + } + + if ($this->option('file')) { + return $this->resolveFileModeSelection($source, $cwd, $discoveredArtifacts, $validArtifacts, $action); + } + + if ($source !== '' && $artifactService->looksLikeArchiveReference($source, $cwd)) { + return $this->createFileResolution($source, $cwd, $discoveredArtifacts, $source); + } + + if ($source !== '') { + $matchingLocal = array_values(array_filter( + $validArtifacts, + fn (array $a): bool => ($a['extensionId'] ?? null) === $source + )); + + if (count($matchingLocal) === 1) { + if ($this->option('yes') || $this->confirm(sprintf( + 'Found local package %s (%s) in %s. %s from that file instead of using the repository?', + $matchingLocal[0]['name'], + $matchingLocal[0]['version'], + $cwd, + ucfirst($action) + ), true)) { + return $this->createFileResolution($matchingLocal[0]['archivePath'], $cwd, $discoveredArtifacts, $source); + } + } elseif ($validArtifacts !== [] && !$this->option('yes')) { + $choice = $this->choice( + sprintf('Found %d local extension package file(s) in %s while you asked for "%s". What do you want to do?', count($validArtifacts), $cwd, $source), + ['Use repository ' . $action, 'Select a discovered package', 'Enter a path', 'Cancel'], + 'Use repository ' . $action + ); + + if ($choice === 'Select a discovered package') { + return $this->selectDiscoveredArtifact($validArtifacts, $cwd, $discoveredArtifacts, $source, $action); + } + + if ($choice === 'Enter a path') { + $path = trim((string) $this->ask('Enter the path to the .M12LabsExtension file')); + + return $this->createFileResolution($path, $cwd, $discoveredArtifacts, $source); + } + + if ($choice === 'Cancel') { + throw new DisplayException(ucfirst($action) . ' cancelled.'); + } + } + } + + if ($source === '') { + if ($validArtifacts === []) { + throw new DisplayException('No extension id or local package file was provided. Run this command in a directory containing a .M12LabsExtension file, pass --path, or provide an extension id.'); + } + + if (count($validArtifacts) === 1) { + if ($this->option('yes') || $this->confirm(sprintf( + ucfirst($action) . ' from the local package %s (%s) found in %s?', + $validArtifacts[0]['name'], + $validArtifacts[0]['version'], + $cwd, + ), true)) { + return $this->createFileResolution($validArtifacts[0]['archivePath'], $cwd, $discoveredArtifacts, $validArtifacts[0]['extensionId']); + } + + throw new DisplayException(ucfirst($action) . ' cancelled.'); + } + + if ($this->option('yes')) { + throw new DisplayException(sprintf( + 'Multiple local extension packages were found. Re-run without --yes to select one, or pass --path explicitly.' + )); + } + + return $this->selectDiscoveredArtifact($validArtifacts, $cwd, $discoveredArtifacts, null, $action); + } + + $repository = $this->resolveRepository($this->option('repository')); + + return [ + 'mode' => 'repository', + 'extensionId' => $source, + 'repository' => $repository, + 'release' => $this->option('release') ? trim((string) $this->option('release')) : null, + 'discoveredArtifacts' => $discoveredArtifacts, + 'cwd' => $cwd, + ]; + } + + /** + * @param array> $discoveredArtifacts + * @param array> $validArtifacts + * @return array + */ + private function resolveFileModeSelection(string $source, string $cwd, array $discoveredArtifacts, array $validArtifacts, string $action): array + { + if ($source !== '') { + return $this->createFileResolution($source, $cwd, $discoveredArtifacts, $source); + } + + if ($validArtifacts === []) { + throw new DisplayException(sprintf('No local .M12LabsExtension files were found in the current directory. Pass a path or omit --file to %s from a repository.', $action)); + } + + if (count($validArtifacts) === 1) { + return $this->createFileResolution($validArtifacts[0]['archivePath'], $cwd, $discoveredArtifacts, $validArtifacts[0]['extensionId']); + } + + if ($this->option('yes')) { + throw new DisplayException('Multiple local extension packages were found. Pass --path or re-run without --yes to choose one interactively.'); + } + + return $this->selectDiscoveredArtifact($validArtifacts, $cwd, $discoveredArtifacts, null, $action); + } + + /** + * @param array> $validArtifacts + * @param array> $discoveredArtifacts + * @return array + */ + private function selectDiscoveredArtifact(array $validArtifacts, string $cwd, array $discoveredArtifacts, ?string $requestedSource, string $action): array + { + $choices = []; + foreach ($validArtifacts as $index => $artifact) { + $choices[] = sprintf('%d. %s (%s) [%s]', $index + 1, $artifact['name'], $artifact['version'], $artifact['archiveName']); + } + + $choices[] = 'Enter a path'; + $choices[] = 'Cancel'; + + $choice = $this->choice( + sprintf('Found %d local extension package file(s) in %s. Select one to %s from.', count($validArtifacts), $cwd, $action), + $choices, + $choices[0] + ); + + if ($choice === 'Enter a path') { + $path = trim((string) $this->ask('Enter the path to the .M12LabsExtension file')); + + return $this->createFileResolution($path, $cwd, $discoveredArtifacts, $requestedSource); + } + + if ($choice === 'Cancel') { + throw new DisplayException(ucfirst($action) . ' cancelled.'); + } + + $selectedIndex = max(0, ((int) Str::before($choice, '.')) - 1); + $selectedArtifact = $validArtifacts[$selectedIndex] ?? null; + if (!$selectedArtifact) { + throw new DisplayException('Unable to resolve the selected local package.'); + } + + return $this->createFileResolution($selectedArtifact['archivePath'], $cwd, $discoveredArtifacts, $requestedSource ?: $selectedArtifact['extensionId']); + } + + /** + * @param array> $discoveredArtifacts + * @return array + */ + private function createFileResolution(string $path, string $cwd, array $discoveredArtifacts, ?string $requestedSource): array + { + /** @var ExtensionPackageArtifactService $artifactService */ + $artifactService = app(ExtensionPackageArtifactService::class); + $artifact = $artifactService->inspectArchive($path, $cwd); + + return [ + 'mode' => 'file', + 'extensionId' => $artifact['extensionId'], + 'archivePath' => $artifact['archivePath'], + 'label' => $this->option('label') ? trim((string) $this->option('label')) : sprintf('Manual package file (%s)', $artifact['archiveName']), + 'artifact' => $artifact, + 'requestedSource' => $requestedSource, + 'discoveredArtifacts' => $discoveredArtifacts, + 'cwd' => $cwd, + ]; + } + + /** + * @param array $resolution + */ + protected function renderDebugResolution(array $resolution): void + { + $this->newLine(); + $this->components->twoColumnDetail('Mode', (string) $resolution['mode']); + $this->components->twoColumnDetail('Working directory', (string) ($resolution['cwd'] ?? base_path())); + + if (!empty($resolution['discoveredArtifacts'])) { + $rows = []; + foreach ($resolution['discoveredArtifacts'] as $artifact) { + $rows[] = [ + $artifact['extensionId'] ?? 'invalid', + $artifact['version'] ?? '-', + $artifact['archiveName'] ?? basename((string) ($artifact['archivePath'] ?? 'unknown')), + $artifact['error'] ?? 'ok', + ]; + } + + $this->table(['Extension', 'Version', 'Artifact', 'Status'], $rows); + } + + if ($resolution['mode'] === 'file' && isset($resolution['artifact'])) { + $artifact = $resolution['artifact']; + $this->table(['Field', 'Value'], [ + ['Extension', $artifact['extensionId']], + ['Version', $artifact['version']], + ['Name', $artifact['name']], + ['Archive', $artifact['archivePath']], + ['Files', (string) $artifact['fileCount']], + ]); + + return; + } + + /** @var ExtensionRepository $repository */ + $repository = $resolution['repository']; + $this->table(['Field', 'Value'], [ + ['Extension', $resolution['extensionId']], + ['Repository', $repository->name], + ['Repository slug', $repository->slug], + ['Release', $resolution['release'] ?? 'latest'], + ]); + } + + protected function renderDebugException(\Throwable $exception): void + { + $this->newLine(); + $this->line(sprintf('Debug: %s', $exception::class)); + + $previous = $exception->getPrevious(); + while ($previous) { + $this->line(sprintf('Caused by: %s - %s', $previous::class, $previous->getMessage())); + $previous = $previous->getPrevious(); + } + } + + /** + * @param array $report + */ + protected function renderOwnershipReport(array $report): void + { + if ($report === [] || empty($report['paths'])) { + return; + } + + $this->newLine(); + $this->components->info(sprintf( + 'Repaired ownership for extension paths using %s:%s (%s).', + $report['user'], + $report['group'], + $report['sourcePath'] + )); + + if ($this->isDebug()) { + $this->table(['Path'], array_map(fn (string $path) => [$path], $report['paths'])); + } + } + + protected function isDebug(): bool + { + return (bool) $this->option('debug'); + } +} diff --git a/app/Console/Commands/Extensions/Concerns/InteractsWithExtensionRepositories.php b/app/Console/Commands/Extensions/Concerns/InteractsWithExtensionRepositories.php new file mode 100644 index 0000000000..613f9ed41a --- /dev/null +++ b/app/Console/Commands/Extensions/Concerns/InteractsWithExtensionRepositories.php @@ -0,0 +1,29 @@ +ensureOfficialRepository(); + } + + $identifier = trim((string) $identifier); + + $repository = ctype_digit($identifier) + ? ExtensionRepository::query()->find((int) $identifier) + : ExtensionRepository::query()->where('slug', $identifier)->orWhere('name', $identifier)->first(); + + if ($repository) { + return $repository; + } + + throw new DisplayException(sprintf('No extension repository matched "%s".', $identifier)); + } +} \ No newline at end of file diff --git a/app/Console/Commands/Extensions/EasyInstallCommand.php b/app/Console/Commands/Extensions/EasyInstallCommand.php new file mode 100644 index 0000000000..cc197d7caf --- /dev/null +++ b/app/Console/Commands/Extensions/EasyInstallCommand.php @@ -0,0 +1,18 @@ +argument('source') ?? '')); + $resolution = null; + + try { + $resolution = $this->resolveResolution($source, 'install'); + + if ($this->isDebug()) { + $this->renderDebugResolution($resolution); + } + + if ($resolution['mode'] === 'file') { + // Run security scan before installation unless explicitly skipped. + // The scan runs interactively here so we can prompt on warnings. + // We then tell the service to skip its own scan to avoid double-scanning. + if (!$this->option('skip-scan')) { + $scanPassed = $this->runSecurityScan($resolution['archivePath']); + if (!$scanPassed) { + return self::FAILURE; + } + } + + $package = $this->installService->installFromArchive( + $resolution['archivePath'], + $resolution['label'], + skipScan: true, // interactive scan already ran above + ); + } else { + /** @var ExtensionRepository $repository */ + $repository = $resolution['repository']; + if (!$this->option('skip-scan')) { + $this->components->info('Security scan will run automatically after the archive is downloaded.'); + } + $package = $this->installService->install( + $resolution['extensionId'], + $repository->id, + $resolution['release'], + ); + } + } catch (\Throwable $exception) { + $this->components->error($exception->getMessage()); + + if ($this->isDebug()) { + $this->renderDebugException($exception); + } + + return self::FAILURE; + } finally { + $ownershipReport = $this->ownershipService->repairStandardPaths($resolution['extensionId'] ?? null); + if ($ownershipReport !== [] && ($this->isDebug() || $this->ownershipService->isRunningAsRoot())) { + $this->renderOwnershipReport($ownershipReport); + } + } + + $this->components->info(sprintf('Installed %s (%s).', $package->extension_id, $package->installed_version)); + $this->table(['Field', 'Value'], [ + ['Extension', $package->extension_id], + ['Version', $package->installed_version], + ['Source', $package->source_repository_name ?? 'Repository'], + ['Archive', $package->source_archive_url ?? 'n/a'], + ['Files', (string) $package->files->count()], + ]); + + return self::SUCCESS; + } + + /** + * Run the security scanner on the given archive path. + * Returns true if install should proceed, false if it should be aborted. + */ + private function runSecurityScan(string $archivePath): bool + { + $this->components->info('Running security scan…'); + + try { + $result = $this->scanner->scan($archivePath); + } catch (\Throwable $e) { + $this->components->warn('Security scan could not be completed: ' . $e->getMessage()); + if ($this->option('yes')) { + return true; + } + + return (bool) $this->confirm('Scan failed — proceed with installation anyway?', false); + } + + $summary = $result->toArray()['summary']; + $this->components->twoColumnDetail('Scan outcome', strtoupper($result->outcome)); + $this->components->twoColumnDetail('High-severity findings', (string) $summary['high']); + $this->components->twoColumnDetail('Warnings', (string) $summary['warnings']); + + if ($result->isBlocked()) { + $this->renderScanFindings($result->phpFindings, $result->jsFindings, $result->semgrepFindings); + $this->components->error('Installation BLOCKED — high-severity security findings detected.'); + + return false; + } + + if ($result->hasSevereFindings()) { + $this->renderScanFindings($result->phpFindings, $result->jsFindings, $result->semgrepFindings); + $this->components->warn('Security warnings were found in this extension.'); + + if ($this->option('yes')) { + return true; + } + + return (bool) $this->confirm('Proceed with installation despite warnings?', false); + } + + $this->components->info('Security scan passed.'); + + return true; + } + + /** + * @param array> $phpFindings + * @param array> $jsFindings + * @param array> $semgrepFindings + */ + private function renderScanFindings(array $phpFindings, array $jsFindings, array $semgrepFindings): void + { + $allFindings = array_merge($phpFindings, $jsFindings, $semgrepFindings); + if ($allFindings === []) { + return; + } + + $rows = array_map(function (array $f): array { + $sev = $f['severity'] ?? 'UNKNOWN'; + + return [ + is_int($sev) ? ($sev >= 2 ? 'ERROR' : 'WARNING') : strtoupper((string) $sev), + basename((string) ($f['file'] ?? '')), + (string) ($f['line'] ?? 0), + mb_strimwidth((string) ($f['message'] ?? ''), 0, 80, '…'), + ]; + }, $allFindings); + + $this->table(['Severity', 'File', 'Line', 'Message'], $rows); + } +} diff --git a/app/Console/Commands/Extensions/ScanExtensionCommand.php b/app/Console/Commands/Extensions/ScanExtensionCommand.php new file mode 100644 index 0000000000..fdbe10d8d9 --- /dev/null +++ b/app/Console/Commands/Extensions/ScanExtensionCommand.php @@ -0,0 +1,100 @@ +argument('path'); + + if (!is_file($archivePath)) { + $this->components->error(sprintf('File not found: %s', $archivePath)); + return self::FAILURE; + } + + $this->components->info(sprintf('Scanning extension archive: %s', basename($archivePath))); + + try { + $result = $this->scanner->scan($archivePath); + } catch (\Throwable $e) { + $this->components->error('Scan failed: ' . $e->getMessage()); + return self::FAILURE; + } + + $this->renderFindings('PHP findings', $result->phpFindings); + $this->renderFindings('JS/TS findings', $result->jsFindings); + $this->renderFindings('Semgrep findings', $result->semgrepFindings); + + $summary = $result->toArray()['summary']; + $this->newLine(); + $this->table( + ['Outcome', 'High-severity', 'Warnings', 'Report'], + [[ + strtoupper($result->outcome), + (string) $summary['high'], + (string) $summary['warnings'], + $result->reportPath, + ]] + ); + + if ($result->isBlocked()) { + $this->components->error('Installation BLOCKED: high-severity findings detected.'); + + if ($this->option('report-only')) { + $this->components->warn('--report-only flag set; not exiting with failure.'); + return self::SUCCESS; + } + + return self::FAILURE; + } + + if ($result->hasSevereFindings()) { + $this->components->warn('Scan completed with warnings. Review findings before installing.'); + } else { + $this->components->info('Scan passed — no issues found.'); + } + + return self::SUCCESS; + } + + /** + * @param array> $findings + */ + private function renderFindings(string $label, array $findings): void + { + if ($findings === []) { + return; + } + + $this->components->info($label . ':'); + $rows = array_map(function (array $f): array { + $sev = $f['severity'] ?? 'UNKNOWN'; + return [ + is_int($sev) ? ($sev >= 2 ? 'ERROR' : 'WARNING') : (string) $sev, + basename((string) ($f['file'] ?? '')), + (string) ($f['line'] ?? 0), + mb_strimwidth((string) ($f['message'] ?? ''), 0, 80, '…'), + ]; + }, $findings); + + $this->table(['Severity', 'File', 'Line', 'Message'], $rows); + } +} diff --git a/app/Console/Commands/Extensions/UninstallExtensionCommand.php b/app/Console/Commands/Extensions/UninstallExtensionCommand.php new file mode 100644 index 0000000000..f7a01f1a28 --- /dev/null +++ b/app/Console/Commands/Extensions/UninstallExtensionCommand.php @@ -0,0 +1,60 @@ +argument('extensionId')); + + if (!$this->option('force') && !$this->confirm(sprintf('Uninstall extension "%s"?', $extensionId))) { + $this->components->warn('Cancelled.'); + + return self::SUCCESS; + } + + try { + $this->uninstallService->uninstall($extensionId); + } catch (\Throwable $exception) { + $this->components->error($exception->getMessage()); + + if ($this->isDebug()) { + $this->renderDebugException($exception); + } + + return self::FAILURE; + } finally { + $ownershipReport = $this->ownershipService->repairStandardPaths($extensionId); + if ($ownershipReport !== [] && ($this->isDebug() || $this->ownershipService->isRunningAsRoot())) { + $this->renderOwnershipReport($ownershipReport); + } + } + + $this->components->info(sprintf('Uninstalled %s.', $extensionId)); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/Extensions/UpdateExtensionCommand.php b/app/Console/Commands/Extensions/UpdateExtensionCommand.php new file mode 100644 index 0000000000..62b3752479 --- /dev/null +++ b/app/Console/Commands/Extensions/UpdateExtensionCommand.php @@ -0,0 +1,175 @@ +argument('source') ?? '')); + $resolution = ['extensionId' => null]; + + try { + $resolution = $this->resolveResolution($source, 'update'); + + if ($this->isDebug()) { + $this->renderDebugResolution($resolution); + } + + if ($resolution['mode'] === 'file') { + // Run security scan interactively before the update unless explicitly skipped. + if (!$this->option('skip-scan')) { + $scanPassed = $this->runSecurityScan($resolution['archivePath']); + if (!$scanPassed) { + return self::FAILURE; + } + } + + $package = $this->updateService->updateFromArchive( + $resolution['archivePath'], + $resolution['label'], + skipScan: true, // interactive scan already ran above + ); + } else { + /** @var ExtensionRepository $repository */ + $repository = $resolution['repository']; + if (!$this->option('skip-scan')) { + $this->components->info('Security scan will run automatically after the archive is downloaded.'); + } + $package = $this->updateService->update( + $resolution['extensionId'], + $repository->id, + $resolution['release'], + ); + } + } catch (\Throwable $exception) { + $this->components->error($exception->getMessage()); + + if ($this->isDebug()) { + $this->renderDebugException($exception); + } + + return self::FAILURE; + } finally { + $ownershipReport = $this->ownershipService->repairStandardPaths($resolution['extensionId'] ?? null); + if ($ownershipReport !== [] && ($this->isDebug() || $this->ownershipService->isRunningAsRoot())) { + $this->renderOwnershipReport($ownershipReport); + } + } + + $this->components->info(sprintf('Updated %s to %s.', $package->extension_id, $package->installed_version)); + $this->table(['Field', 'Value'], [ + ['Extension', $package->extension_id], + ['Version', $package->installed_version], + ['Source', $package->source_repository_name ?? 'Repository'], + ['Archive', $package->source_archive_url ?? 'n/a'], + ['Files', (string) $package->files->count()], + ]); + + return self::SUCCESS; + } + + /** + * Run the security scanner on the given archive path. + * Returns true if the update should proceed, false to abort. + */ + private function runSecurityScan(string $archivePath): bool + { + $this->components->info('Running security scan…'); + + try { + $result = $this->scanner->scan($archivePath); + } catch (\Throwable $e) { + $this->components->warn('Security scan could not be completed: ' . $e->getMessage()); + if ($this->option('yes')) { + return true; + } + + return (bool) $this->confirm('Scan failed — proceed with update anyway?', false); + } + + $summary = $result->toArray()['summary']; + $this->components->twoColumnDetail('Scan outcome', strtoupper($result->outcome)); + $this->components->twoColumnDetail('High-severity findings', (string) $summary['high']); + $this->components->twoColumnDetail('Warnings', (string) $summary['warnings']); + + if ($result->isBlocked()) { + $this->renderScanFindings($result->phpFindings, $result->jsFindings, $result->semgrepFindings); + $this->components->error('Update BLOCKED — high-severity security findings detected.'); + + return false; + } + + if ($result->hasSevereFindings()) { + $this->renderScanFindings($result->phpFindings, $result->jsFindings, $result->semgrepFindings); + $this->components->warn('Security warnings were found in this extension package.'); + + if ($this->option('yes')) { + return true; + } + + return (bool) $this->confirm('Proceed with update despite warnings?', false); + } + + $this->components->info('Security scan passed.'); + + return true; + } + + /** + * @param array> $phpFindings + * @param array> $jsFindings + * @param array> $semgrepFindings + */ + private function renderScanFindings(array $phpFindings, array $jsFindings, array $semgrepFindings): void + { + $allFindings = array_merge($phpFindings, $jsFindings, $semgrepFindings); + if ($allFindings === []) { + return; + } + + $rows = array_map(function (array $f): array { + $sev = $f['severity'] ?? 'UNKNOWN'; + + return [ + is_int($sev) ? ($sev >= 2 ? 'ERROR' : 'WARNING') : strtoupper((string) $sev), + basename((string) ($f['file'] ?? '')), + (string) ($f['line'] ?? 0), + mb_strimwidth((string) ($f['message'] ?? ''), 0, 80, '…'), + ]; + }, $allFindings); + + $this->table(['Severity', 'File', 'Line', 'Message'], $rows); + } +} diff --git a/app/Http/Controllers/Api/Application/Billing/CustomDomainController.php b/app/Http/Controllers/Api/Application/Billing/CustomDomainController.php new file mode 100644 index 0000000000..485b925393 --- /dev/null +++ b/app/Http/Controllers/Api/Application/Billing/CustomDomainController.php @@ -0,0 +1,217 @@ +with('apiKey')->orderBy('domain')->get(); + + return response()->json([ + 'data' => $domains->map(function (CustomDomain $domain) { + return [ + 'id' => $domain->id, + 'domain' => $domain->domain, + 'cloudflare_zone_id' => $domain->cloudflare_zone_id, + 'api_key_id' => $domain->api_key_id, + 'api_key_name' => $domain->apiKey?->name, + 'allowed_nest_ids' => $domain->allowed_nest_ids ?? [], + 'allowed_egg_ids' => $domain->allowed_egg_ids ?? [], + 'service_tag' => $domain->service_tag, + 'egg_service_tags' => $domain->egg_service_tags ?? (object) [], + 'wildcard_enabled' => $domain->wildcard_enabled, + 'enabled' => $domain->enabled, + 'created_at' => $domain->created_at, + 'updated_at' => $domain->updated_at, + ]; + })->values(), + ]); + } + + public function store(StoreCustomDomainRequest $request): JsonResponse + { + $domain = CustomDomain::query()->create([ + 'domain' => strtolower($request->input('domain')), + 'cloudflare_zone_id' => $request->input('cloudflare_zone_id'), + 'api_key_id' => $request->integer('api_key_id') ?: null, + 'allowed_nest_ids' => array_values(array_unique(array_map('intval', (array) $request->input('allowed_nest_ids', [])))), + 'allowed_egg_ids' => array_values(array_unique(array_map('intval', (array) $request->input('allowed_egg_ids', [])))), + 'service_tag' => $request->filled('service_tag') ? strtolower((string) $request->input('service_tag')) : null, + 'egg_service_tags' => $this->sanitizeEggServiceTags((array) $request->input('egg_service_tags', [])), + 'wildcard_enabled' => $request->boolean('wildcard_enabled', false), + 'enabled' => $request->boolean('enabled', true), + ]); + + return response()->json(['data' => $domain], Response::HTTP_CREATED); + } + + public function update(UpdateCustomDomainRequest $request, CustomDomain $customDomain): JsonResponse + { + $customDomain->update([ + 'domain' => strtolower($request->input('domain', $customDomain->domain)), + 'cloudflare_zone_id' => $request->input('cloudflare_zone_id', $customDomain->cloudflare_zone_id), + 'api_key_id' => $request->has('api_key_id') ? ($request->integer('api_key_id') ?: null) : $customDomain->api_key_id, + 'allowed_nest_ids' => $request->has('allowed_nest_ids') + ? array_values(array_unique(array_map('intval', (array) $request->input('allowed_nest_ids', [])))) + : ($customDomain->allowed_nest_ids ?? []), + 'allowed_egg_ids' => $request->has('allowed_egg_ids') + ? array_values(array_unique(array_map('intval', (array) $request->input('allowed_egg_ids', [])))) + : ($customDomain->allowed_egg_ids ?? []), + 'service_tag' => $request->has('service_tag') + ? ($request->filled('service_tag') ? strtolower((string) $request->input('service_tag')) : null) + : $customDomain->service_tag, + 'egg_service_tags' => $request->has('egg_service_tags') + ? $this->sanitizeEggServiceTags((array) $request->input('egg_service_tags', [])) + : ($customDomain->egg_service_tags ?? (object) []), + 'wildcard_enabled' => $request->boolean('wildcard_enabled', $customDomain->wildcard_enabled), + 'enabled' => $request->boolean('enabled', $customDomain->enabled), + ]); + + return response()->json(['data' => $customDomain->fresh()]); + } + + public function destroy(DeleteCustomDomainRequest $request, CustomDomain $customDomain): Response + { + $customDomain->delete(); + + return $this->returnNoContent(); + } + + public function apiKeys(GetCustomDomainApiKeysRequest $request): JsonResponse + { + $keys = CustomDomainApiKey::query()->orderBy('name')->get()->map(function (CustomDomainApiKey $key) { + return [ + 'id' => $key->id, + 'name' => $key->name, + 'enabled' => $key->enabled, + 'created_at' => $key->created_at, + 'updated_at' => $key->updated_at, + ]; + })->values(); + + return response()->json(['data' => $keys]); + } + + public function storeApiKey(StoreCustomDomainApiKeyRequest $request): JsonResponse + { + $validated = $request->validated(); + + $key = CustomDomainApiKey::query()->create([ + 'name' => trim((string) $validated['name']), + 'token' => trim((string) $validated['token']), + 'enabled' => (bool) ($validated['enabled'] ?? true), + ]); + + return response()->json([ + 'data' => [ + 'id' => $key->id, + 'name' => $key->name, + 'enabled' => $key->enabled, + 'created_at' => $key->created_at, + 'updated_at' => $key->updated_at, + ], + ], Response::HTTP_CREATED); + } + + public function updateApiKey(UpdateCustomDomainApiKeyRequest $request, CustomDomainApiKey $apiKey): JsonResponse + { + $validated = $request->validated(); + + $payload = []; + if (array_key_exists('name', $validated)) { + $payload['name'] = trim((string) $validated['name']); + } + if (!empty($validated['token'])) { + $payload['token'] = trim((string) $validated['token']); + } + if (array_key_exists('enabled', $validated)) { + $payload['enabled'] = (bool) $validated['enabled']; + } + + if (!empty($payload)) { + $apiKey->update($payload); + } + + return response()->json([ + 'data' => [ + 'id' => $apiKey->id, + 'name' => $apiKey->name, + 'enabled' => $apiKey->enabled, + 'created_at' => $apiKey->created_at, + 'updated_at' => $apiKey->updated_at, + ], + ]); + } + + public function deleteApiKey(DeleteCustomDomainApiKeyRequest $request, CustomDomainApiKey $apiKey): Response + { + if (CustomDomain::query()->where('api_key_id', $apiKey->id)->exists()) { + abort(422, 'This API key is assigned to one or more custom domains.'); + } + + $apiKey->delete(); + + return $this->returnNoContent(); + } + + public function options(GetCustomDomainsRequest $request, CustomDomainProvisioningService $service): JsonResponse + { + $nests = Nest::query()->orderBy('name')->get(['id', 'uuid', 'name', 'description']); + $eggs = Egg::query()->with('nest:id,name')->orderBy('name')->get(['id', 'uuid', 'nest_id', 'name', 'description']); + + return response()->json([ + 'data' => [ + 'nests' => $nests, + 'eggs' => $eggs->map(function (Egg $egg) use ($service) { + return [ + 'id' => $egg->id, + 'uuid' => $egg->uuid, + 'nest_id' => $egg->nest_id, + 'nest_name' => $egg->nest?->name, + 'name' => $egg->name, + 'description' => $egg->description, + 'default_service_tag' => $service->getDefaultServiceTagForEgg($egg->name, $egg->nest?->name), + ]; + })->values(), + ], + ]); + } + + private function sanitizeEggServiceTags(array $eggServiceTags): array + { + $result = []; + foreach ($eggServiceTags as $eggId => $tag) { + $id = (int) $eggId; + if ($id < 1 || !is_string($tag)) { + continue; + } + + $normalized = strtolower(trim($tag)); + if ($normalized === '') { + continue; + } + + $result[(string) $id] = $normalized; + } + + return $result; + } +} diff --git a/app/Http/Controllers/Api/Application/Billing/ProductController.php b/app/Http/Controllers/Api/Application/Billing/ProductController.php index a090c56650..4b72fa5509 100644 --- a/app/Http/Controllers/Api/Application/Billing/ProductController.php +++ b/app/Http/Controllers/Api/Application/Billing/ProductController.php @@ -75,6 +75,7 @@ public function store(StoreBillingProductRequest $request, string $category): Js 'backup_limit' => $request['limits']['backup'], 'database_limit' => $request['limits']['database'], 'allocation_limit' => $request['limits']['allocation'], + 'subdomain_limit' => $request['limits']['subdomain'] ?? 1, ]); // Create default billing cycles if provided @@ -115,6 +116,7 @@ public function update(UpdateBillingProductRequest $request, string $category, s 'backup_limit' => $request['limits']['backup'], 'database_limit' => $request['limits']['database'], 'allocation_limit' => $request['limits']['allocation'], + 'subdomain_limit' => $request['limits']['subdomain'] ?? 1, ]); // Update billing cycles if provided diff --git a/app/Http/Controllers/Api/Application/CustomDomains/SettingsController.php b/app/Http/Controllers/Api/Application/CustomDomains/SettingsController.php new file mode 100644 index 0000000000..b4aa57ca84 --- /dev/null +++ b/app/Http/Controllers/Api/Application/CustomDomains/SettingsController.php @@ -0,0 +1,47 @@ +json([ + 'data' => [ + 'cloudflare_token' => (string) config('modules.custom_domains.cloudflare.token', ''), + 'allow_wildcard' => (bool) config('modules.custom_domains.security.allow_wildcard', false), + 'max_wildcards_per_user' => (int) config('modules.custom_domains.security.max_wildcards_per_user', 1), + 'rate_limit_create_per_minute' => (int) config('modules.custom_domains.rate_limits.create_per_minute', 10), + 'rate_limit_sync_per_minute' => (int) config('modules.custom_domains.rate_limits.sync_per_minute', 5), + 'rate_limit_billing_options_per_minute' => (int) config('modules.custom_domains.rate_limits.billing_options_per_minute', 20), + ], + ]); + } + + public function update(UpdateCustomDomainSettingsRequest $request): Response + { + if ($request->has('cloudflare_token')) { + Setting::set('settings::modules:custom_domains:cloudflare:token', (string) $request->input('cloudflare_token', '')); + } + + Setting::set('settings::modules:custom_domains:security:allow_wildcard', $request->boolean('allow_wildcard', false)); + Setting::set('settings::modules:custom_domains:security:max_wildcards_per_user', (int) $request->input('max_wildcards_per_user', 1)); + Setting::set('settings::modules:custom_domains:rate_limits:create_per_minute', (int) $request->input('rate_limit_create_per_minute', 10)); + Setting::set('settings::modules:custom_domains:rate_limits:sync_per_minute', (int) $request->input('rate_limit_sync_per_minute', 5)); + Setting::set('settings::modules:custom_domains:rate_limits:billing_options_per_minute', (int) $request->input('rate_limit_billing_options_per_minute', 20)); + + Activity::event('admin:custom-domains:update-settings') + ->description('Custom domain settings were updated') + ->log(); + + return $this->returnNoContent(); + } +} diff --git a/app/Http/Controllers/Api/Application/Extensions/ExtensionsController.php b/app/Http/Controllers/Api/Application/Extensions/ExtensionsController.php new file mode 100644 index 0000000000..a7506c786c --- /dev/null +++ b/app/Http/Controllers/Api/Application/Extensions/ExtensionsController.php @@ -0,0 +1,542 @@ + 'list', + 'data' => $this->catalogService->getCatalog()['extensions'], + ]); + } + + /** + * Get all configured repositories and their current health. + */ + public function repositories(GetExtensionsRequest $request): JsonResponse + { + return new JsonResponse([ + 'object' => 'list', + 'data' => $this->catalogService->getRepositories(), + ]); + } + + /** + * Force-refresh all repository manifests (bust cache) and return the updated extension list. + */ + public function refresh(GetExtensionsRequest $request): JsonResponse + { + $catalog = $this->catalogService->getCatalog(forceRefresh: true); + + return new JsonResponse([ + 'object' => 'list', + 'data' => $catalog['extensions'], + ]); + } + + /** + * Get a single extension configuration. + */ + public function view(GetExtensionsRequest $request, string $extensionId): JsonResponse + { + $extension = $this->catalogService->getExtension($extensionId); + if (!$extension) { + return new JsonResponse(['error' => 'Extension not found'], 404); + } + + return new JsonResponse([ + 'object' => 'extension', + 'attributes' => $extension, + ]); + } + + /** + * Update an extension configuration. + */ + public function update(UpdateExtensionRequest $request, string $extensionId): JsonResponse + { + $extension = $this->getManageableExtension($extensionId); + if (!$extension) { + return new JsonResponse(['error' => 'Extension not found'], 404); + } + + $existing = ExtensionConfig::getByExtensionId($extensionId); + + $payload = [ + 'allowed_nests' => $request->input('allowed_nests', []), + 'allowed_eggs' => $request->input('allowed_eggs', []), + 'settings' => $request->input('settings', []), + ]; + + if ($request->has('enabled')) { + $payload['enabled'] = (bool) $request->input('enabled'); + } elseif ($existing) { + $payload['enabled'] = (bool) $existing->enabled; + } + + $config = ExtensionConfig::updateOrCreateConfig($extensionId, $payload); + + Activity::event('admin:extensions:update') + ->property('extension_id', $extensionId) + ->property('enabled', $config->enabled) + ->log(); + + return new JsonResponse([ + 'object' => 'extension', + 'attributes' => $this->catalogService->getExtension($extensionId, true), + ]); + } + + /** + * Toggle an extension's enabled state. + */ + public function toggle(UpdateExtensionRequest $request, string $extensionId): JsonResponse + { + $extension = $this->getManageableExtension($extensionId); + if (!$extension) { + return new JsonResponse(['error' => 'Extension not found'], 404); + } + + $dbConfig = ExtensionConfig::getByExtensionId($extensionId); + $newEnabled = $dbConfig ? !$dbConfig->enabled : true; + + $config = ExtensionConfig::updateOrCreateConfig($extensionId, [ + 'enabled' => $newEnabled, + ]); + + Activity::event('admin:extensions:toggle') + ->property('extension_id', $extensionId) + ->property('enabled', $config->enabled) + ->log(); + + return new JsonResponse([ + 'object' => 'extension', + 'attributes' => $this->catalogService->getExtension($extensionId, true), + ]); + } + + /** + * Install a repository-backed extension package. + */ + public function install(InstallExtensionRequest $request, string $extensionId): JsonResponse + { + $this->abortIfOperationRunning(); + + $package = $this->installService->install( + $extensionId, + (int) $request->input('repository_id'), + $request->input('version') + ); + + Activity::event('admin:extensions:install') + ->property('extension_id', $extensionId) + ->property('version', $package->installed_version) + ->property('repository', $package->source_repository_name) + ->log(); + + return new JsonResponse([ + 'object' => 'extension', + 'attributes' => $this->catalogService->getExtension($extensionId, true), + ], Response::HTTP_CREATED); + } + + /** + * Remove an installed repository-backed extension package. + */ + public function uninstall(UninstallExtensionRequest $request, string $extensionId): JsonResponse + { + $this->abortIfOperationRunning(); + + $this->uninstallService->uninstall($extensionId); + + Activity::event('admin:extensions:uninstall') + ->property('extension_id', $extensionId) + ->log(); + + return new JsonResponse([ + 'object' => 'extension', + 'attributes' => $this->catalogService->getExtension($extensionId, true) ?? [ + 'id' => $extensionId, + 'installed' => false, + ], + ]); + } + + /** + * Update an already-installed repository-backed extension package to a newer version. + */ + public function updatePackage(InstallExtensionRequest $request, string $extensionId): JsonResponse + { + $this->abortIfOperationRunning(); + + $package = $this->updateService->update( + $extensionId, + (int) $request->input('repository_id'), + $request->input('version') + ); + + Activity::event('admin:extensions:update-package') + ->property('extension_id', $extensionId) + ->property('version', $package->installed_version) + ->property('repository', $package->source_repository_name) + ->log(); + + return new JsonResponse([ + 'object' => 'extension', + 'attributes' => $this->catalogService->getExtension($extensionId, true), + ]); + } + + /** + * Return the current install/uninstall/update progress stage for polling by the frontend. + * Returns null when no operation is in progress. + */ + public function progress(GetExtensionsRequest $request): JsonResponse + { + return new JsonResponse([ + 'progress' => $this->progressService->current(), + ]); + } + + /** + * Update the extensions module settings. + */ + public function settings(UpdateExtensionSettingsRequest $request): Response + { + Setting::set('settings::modules:extensions:' . $request->input('key'), $request->input('value')); + + Activity::event('admin:extensions:settings') + ->property('key', $request->input('key')) + ->property('value', $request->input('value')) + ->log(); + + return $this->returnNoContent(); + } + + /** + * Add a new extension repository. + */ + public function storeRepository(StoreExtensionRepositoryRequest $request): JsonResponse + { + if (ExtensionRepository::query()->where('manifest_url', trim($request->input('manifest_url')))->exists()) { + return new JsonResponse(['error' => 'A repository with that manifest location already exists.'], 422); + } + + $repository = ExtensionRepository::query()->create([ + 'slug' => $this->generateRepositorySlug($request->input('name')), + 'name' => trim($request->input('name')), + 'manifest_url' => trim($request->input('manifest_url')), + 'homepage_url' => $request->filled('homepage_url') ? trim((string) $request->input('homepage_url')) : null, + 'enabled' => $request->boolean('enabled', true), + 'is_official' => false, + 'risk_acknowledged_at' => now(), + ]); + + try { + $this->catalogService->validateRepository($repository); + } catch (\Throwable $exception) { + $repository->delete(); + throw $exception; + } + + Activity::event('admin:extensions:repository:create') + ->property('repository', $repository->name) + ->property('manifest_url', $repository->manifest_url) + ->log(); + + return new JsonResponse([ + 'object' => 'extension_repository', + 'attributes' => $this->findRepositorySummary($repository->id), + ], Response::HTTP_CREATED); + } + + /** + * Update an existing extension repository. + */ + public function updateRepository(UpdateExtensionRepositoryRequest $request, ExtensionRepository $repository): JsonResponse + { + $previous = $repository->replicate(); + + if ($request->filled('manifest_url')) { + $duplicate = ExtensionRepository::query() + ->where('id', '!=', $repository->id) + ->where('manifest_url', trim((string) $request->input('manifest_url'))) + ->exists(); + + if ($duplicate) { + return new JsonResponse(['error' => 'A repository with that manifest location already exists.'], 422); + } + } + + $repository->fill([ + 'name' => $request->filled('name') ? trim((string) $request->input('name')) : $repository->name, + 'manifest_url' => $request->filled('manifest_url') ? trim((string) $request->input('manifest_url')) : $repository->manifest_url, + 'homepage_url' => $request->exists('homepage_url') + ? ($request->filled('homepage_url') ? trim((string) $request->input('homepage_url')) : null) + : $repository->homepage_url, + ]); + + if ($request->has('enabled')) { + $repository->enabled = $request->boolean('enabled'); + } + + $repository->save(); + + try { + $this->catalogService->validateRepository($repository); + } catch (\Throwable $exception) { + $repository->forceFill($previous->getAttributes())->save(); + throw $exception; + } + + Activity::event('admin:extensions:repository:update') + ->property('repository', $repository->name) + ->property('manifest_url', $repository->manifest_url) + ->property('enabled', $repository->enabled) + ->log(); + + return new JsonResponse([ + 'object' => 'extension_repository', + 'attributes' => $this->findRepositorySummary($repository->id), + ]); + } + + /** + * Delete a custom extension repository. + */ + public function deleteRepository(GetExtensionsRequest $request, ExtensionRepository $repository): Response + { + if ($repository->is_official) { + return new JsonResponse(['error' => 'The official M12Labs repository cannot be deleted. Disable it instead.'], 422); + } + + $repositoryName = $repository->name; + $repository->delete(); + + Activity::event('admin:extensions:repository:delete') + ->property('repository', $repositoryName) + ->log(); + + return $this->returnNoContent(); + } + + /** + * Get available nests and eggs for extension configuration. + */ + public function getNestsAndEggs(GetExtensionsRequest $request): JsonResponse + { + $nests = Nest::with('eggs')->get(); + + $nestsData = $nests->map(function ($nest) { + return [ + 'id' => $nest->id, + 'uuid' => $nest->uuid, + 'name' => $nest->name, + 'description' => $nest->description, + ]; + }); + + $eggsData = []; + foreach ($nests as $nest) { + foreach ($nest->eggs as $egg) { + $eggsData[] = [ + 'id' => $egg->id, + 'uuid' => $egg->uuid, + 'name' => $egg->name, + 'description' => $egg->description, + 'nestId' => $nest->id, + 'nestName' => $nest->name, + ]; + } + } + + return new JsonResponse([ + 'nests' => $nestsData, + 'eggs' => $eggsData, + ]); + } + + /** + * Install multiple extensions as a batch, performing all file operations first + * and rebuilding the panel only once after all files are in place. + */ + public function batchInstall(BatchInstallExtensionRequest $request): JsonResponse + { + $this->abortIfOperationRunning(); + $items = array_map(fn (array $item) => [ + 'extensionId' => $item['extension_id'], + 'repositoryId' => (int) $item['repository_id'], + 'version' => $item['version'] ?? null, + ], $request->input('extensions', [])); + + $this->batchService->batchInstall($items); + + foreach ($items as $item) { + Activity::event('admin:extensions:install') + ->property('extension_id', $item['extensionId']) + ->property('batch', true) + ->log(); + } + + return new JsonResponse([ + 'object' => 'list', + 'data' => $this->catalogService->getCatalog()['extensions'], + ], Response::HTTP_CREATED); + } + + /** + * Uninstall multiple extensions as a batch, removing all files first + * and rebuilding the panel only once after all files are removed. + */ + public function batchUninstall(BatchUninstallExtensionRequest $request): JsonResponse + { + $this->abortIfOperationRunning(); + $extensionIds = $request->input('extension_ids', []); + + $this->batchService->batchUninstall($extensionIds); + + foreach ($extensionIds as $extensionId) { + Activity::event('admin:extensions:uninstall') + ->property('extension_id', $extensionId) + ->property('batch', true) + ->log(); + } + + return new JsonResponse([ + 'object' => 'list', + 'data' => $this->catalogService->getCatalog()['extensions'], + ]); + } + + /** + * Update multiple extensions as a batch, performing all file operations first + * and rebuilding the panel only once after all files are in place. + */ + public function batchUpdate(BatchUpdateExtensionRequest $request): JsonResponse + { + $this->abortIfOperationRunning(); + $items = array_map(fn (array $item) => [ + 'extensionId' => $item['extension_id'], + 'repositoryId' => (int) $item['repository_id'], + 'version' => $item['version'] ?? null, + ], $request->input('extensions', [])); + + $this->batchService->batchUpdate($items); + + foreach ($items as $item) { + Activity::event('admin:extensions:update-package') + ->property('extension_id', $item['extensionId']) + ->property('batch', true) + ->log(); + } + + return new JsonResponse([ + 'object' => 'list', + 'data' => $this->catalogService->getCatalog()['extensions'], + ]); + } + + /** + */ + private function getManageableExtension(string $extensionId): ?array + { + foreach ($this->catalogService->getLocalExtensions() as $extension) { + if ($extension['id'] === $extensionId) { + return $extension; + } + } + + return null; + } + + /** + * @return array + */ + private function findRepositorySummary(int $repositoryId): array + { + foreach ($this->catalogService->getRepositories(true) as $repository) { + if ($repository['id'] === $repositoryId) { + return $repository; + } + } + + return [ + 'id' => $repositoryId, + ]; + } + + private function generateRepositorySlug(string $name): string + { + $baseSlug = Str::slug($name); + $slug = $baseSlug === '' ? 'repository' : $baseSlug; + + while (ExtensionRepository::query()->where('slug', $slug)->exists()) { + $slug = sprintf('%s-%s', $baseSlug === '' ? 'repository' : $baseSlug, Str::lower(Str::random(4))); + } + + return $slug; + } + + /** + * Abort with 409 Conflict if a progress file indicates an operation is already in progress. + * This is a fast, file-based guard that runs before the cache lock is acquired. + */ + private function abortIfOperationRunning(): void + { + $current = $this->progressService->current(); + + if ($current !== null && ($current['stage'] ?? '') !== 'completed') { + $action = $current['action'] ?? 'unknown'; + $subject = $current['extension_id'] ?? ''; + + abort(Response::HTTP_CONFLICT, sprintf( + 'Another extension operation (%s%s) is already running. Wait for it to finish before starting a new install, update, or uninstall.', + $action, + $subject !== '' ? ": {$subject}" : '' + )); + } + } +} diff --git a/app/Http/Controllers/Api/Application/Nodes/NodeInformationController.php b/app/Http/Controllers/Api/Application/Nodes/NodeInformationController.php index 3e64466073..cfd82bd719 100644 --- a/app/Http/Controllers/Api/Application/Nodes/NodeInformationController.php +++ b/app/Http/Controllers/Api/Application/Nodes/NodeInformationController.php @@ -5,6 +5,7 @@ use Everest\Models\Node; use Illuminate\Support\Str; use Illuminate\Http\JsonResponse; +use Everest\Services\Nodes\WingsDetectionService; use Everest\Repositories\Wings\DaemonConfigurationRepository; use Everest\Http\Controllers\Api\Application\ApplicationApiController; use Everest\Http\Requests\Api\Application\Nodes\GetNodeInformationRequest; @@ -14,7 +15,10 @@ class NodeInformationController extends ApplicationApiController /** * NodeInformationController constructor. */ - public function __construct(private DaemonConfigurationRepository $repository) + public function __construct( + private DaemonConfigurationRepository $repository, + private WingsDetectionService $detectionService + ) { parent::__construct(); } @@ -26,8 +30,15 @@ public function __construct(private DaemonConfigurationRepository $repository) */ public function information(GetNodeInformationRequest $request, Node $node): JsonResponse { + if (!$node->isSupercharged()) { + $this->detectionService->detect($node); + $node->refresh(); + } + $data = $this->repository->setNode($node)->getSystemInformation(); + $isSupercharged = $node->isSupercharged() || !empty($data['supercharged']); + return new JsonResponse([ 'version' => $data['version'] ?? null, 'system' => [ @@ -35,7 +46,7 @@ public function information(GetNodeInformationRequest $request, Node $node): Jso 'arch' => $data['architecture'] ?? null, 'release' => $data['kernel_version'] ?? null, 'cpus' => $data['cpu_count'] ?? null, - 'supercharged' => $data['supercharged'] ?? false, + 'supercharged' => $isSupercharged, ], ]); } diff --git a/app/Http/Controllers/Api/Application/Nodes/NodeWingsRsController.php b/app/Http/Controllers/Api/Application/Nodes/NodeWingsRsController.php new file mode 100644 index 0000000000..ebb1f09e33 --- /dev/null +++ b/app/Http/Controllers/Api/Application/Nodes/NodeWingsRsController.php @@ -0,0 +1,129 @@ +detectionService->detect($node); + $node->refresh(); + + return new JsonResponse([ + 'detected' => $isSupercharged, + 'supercharged' => $isSupercharged, + 'wings_type' => $node->wings_type, + 'wings_version' => $node->wings_version, + 'detected_at' => $node->wings_detected_at?->toIso8601String(), + ]); + } + + /** + * GET /api/application/nodes/{node}/overview — Wings-RS system overview. + */ + public function overview(WingsRsNodeReadRequest $request, Node $node): JsonResponse + { + if (!$node->isSupercharged()) { + return new JsonResponse(['error' => 'This node is not running Wings-RS.'], 400); + } + + $data = $this->wingsRsRepository->setNode($node)->getSystemOverview(); + + return new JsonResponse($data); + } + + /** + * GET /api/application/nodes/{node}/stats — Wings-RS real-time stats. + */ + public function stats(WingsRsNodeReadRequest $request, Node $node): JsonResponse + { + if (!$node->isSupercharged()) { + return new JsonResponse(['error' => 'This node is not running Wings-RS.'], 400); + } + + $data = $this->wingsRsRepository->setNode($node)->getSystemStats(); + + return new JsonResponse($data); + } + + /** + * GET /api/application/nodes/{node}/logs — List Wings-RS log files. + */ + public function logs(WingsRsNodeReadRequest $request, Node $node): JsonResponse + { + if (!$node->isSupercharged()) { + return new JsonResponse(['error' => 'This node is not running Wings-RS.'], 400); + } + + $data = $this->wingsRsRepository->setNode($node)->getSystemLogs(); + + return new JsonResponse($data); + } + + /** + * GET /api/application/nodes/{node}/logs/{file} — Read specific log file. + */ + public function logContents(WingsRsNodeReadRequest $request, Node $node, string $file): JsonResponse + { + if (!$node->isSupercharged()) { + return new JsonResponse(['error' => 'This node is not running Wings-RS.'], 400); + } + + $lines = (int) $request->query('lines', 200); + $lines = max(1, min($lines, 5000)); + + $content = $this->wingsRsRepository->setNode($node)->getSystemLogContents($file, $lines); + + return new JsonResponse([ + 'file' => $file, + 'content' => $content, + ]); + } + + /** + * POST /api/application/nodes/{node}/upgrade — Trigger Wings-RS self-upgrade. + * + * The daemon is responsible for executing the upgrade. To prevent arbitrary command + * injection the panel no longer accepts a caller-controlled restart command; the daemon + * must use its own hardcoded restart mechanism. The "headers" field is also removed so + * callers cannot inject credentials or bypass daemon-side download security. + */ + public function upgrade(WingsRsNodeUpdateRequest $request, Node $node): JsonResponse + { + if (!$node->isSupercharged()) { + return new JsonResponse(['error' => 'This node is not running Wings-RS.'], 400); + } + + $request->validate([ + // Must be HTTPS to prevent MITM on the binary download. + 'url' => ['required', 'url', 'regex:/^https:\/\//i'], + // Must be a 64-character lowercase hex string (SHA-256). + 'sha256' => ['required', 'string', 'size:64', 'regex:/^[0-9a-f]{64}$/'], + ]); + + $this->wingsRsRepository->setNode($node)->upgradeSystem( + $request->input('url'), + $request->input('sha256') + ); + + return new JsonResponse(['success' => true], 202); + } +} diff --git a/app/Http/Controllers/Api/Application/Servers/ServerWingsRsController.php b/app/Http/Controllers/Api/Application/Servers/ServerWingsRsController.php new file mode 100644 index 0000000000..0020b667d3 --- /dev/null +++ b/app/Http/Controllers/Api/Application/Servers/ServerWingsRsController.php @@ -0,0 +1,69 @@ +node; + + return new JsonResponse([ + 'supercharged' => $node->isSupercharged(), + 'wings_type' => $node->wings_type, + 'wings_version' => $node->wings_version, + ]); + } + + public function stats(WingsRsServerReadRequest $request, Server $server): JsonResponse + { + if (!$server->node->isSupercharged()) { + return new JsonResponse(['error' => 'This server node is not running Wings-RS.'], 400); + } + + $data = $this->wingsRsRepository->setServer($server)->getSystemStats(); + + return new JsonResponse($data); + } + + public function installLogs(WingsRsServerReadRequest $request, Server $server): JsonResponse + { + if (!$server->node->isSupercharged()) { + return new JsonResponse(['error' => 'This server node is not running Wings-RS.'], 400); + } + + $lines = (int) $request->query('lines', 100); + $lines = max(1, min($lines, 5000)); + + try { + $content = $this->wingsRsRepository->setServer($server)->getInstallLogs($lines); + } catch (DaemonConnectionException $exception) { + if ($exception->getStatusCode() === 404) { + return new JsonResponse([ + 'content' => [], + 'missing' => true, + ]); + } + + throw $exception; + } + + return new JsonResponse([ + 'content' => $content, + 'missing' => false, + ]); + } +} diff --git a/app/Http/Controllers/Api/Client/Billing/CheckoutController.php b/app/Http/Controllers/Api/Client/Billing/CheckoutController.php index d0a530f9c6..44c3494c25 100644 --- a/app/Http/Controllers/Api/Client/Billing/CheckoutController.php +++ b/app/Http/Controllers/Api/Client/Billing/CheckoutController.php @@ -95,6 +95,7 @@ public function processFree(Request $request): array // Process the order $variables = $request->input('variables', []); + $domainPayload = $request->input('domain_payload', []); $result = $this->processorService->createServerOrder( $request, $user, @@ -105,7 +106,8 @@ public function processFree(Request $request): array $variables, null, // No payment intent ID for free orders $serverName, - $billingDays + $billingDays, + is_array($domainPayload) ? $domainPayload : [] ); return $this->fractal->item($result['server']) @@ -338,6 +340,8 @@ public function updateIntent(Request $request, ?int $id = null): Response $variables = $request->input('variables') ?? []; $metadata['variables'] = !empty($variables) ? json_encode($variables) : ''; + $domainPayload = $request->input('domain_payload') ?? []; + $metadata['domain_payload'] = !empty($domainPayload) ? json_encode($domainPayload) : ''; $intent->metadata = $metadata; $intent->save(); @@ -354,6 +358,7 @@ public function updateIntent(Request $request, ?int $id = null): Response [ 'billing_days' => $billingDays, 'server_id' => $request->input('server_id') ? (int) $request->input('server_id') : null, + 'domain_payload' => is_array($domainPayload) ? $domainPayload : [], ] ); diff --git a/app/Http/Controllers/Api/Client/Billing/CustomDomainOptionsController.php b/app/Http/Controllers/Api/Client/Billing/CustomDomainOptionsController.php new file mode 100644 index 0000000000..cb576cd279 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Billing/CustomDomainOptionsController.php @@ -0,0 +1,52 @@ +query('egg_id', 0); + $egg = $eggId > 0 ? Egg::query()->with('nest:id,name')->find($eggId) : null; + $recommendation = $this->service->getDnsRecommendationForEgg($egg?->name, $egg?->nest?->name); + + $domains = collect($this->service->getAvailableDomains())->map(function ($domain) use ($egg, $recommendation) { + $eggTag = null; + if ($egg) { + $eggServiceTags = (array) ($domain->egg_service_tags ?? []); + $eggTag = $eggServiceTags[(string) $egg->id] ?? null; + } + + return [ + 'id' => $domain->id, + 'domain' => $domain->domain, + 'wildcard_enabled' => $domain->wildcard_enabled, + 'default_service_tag' => $eggTag + ? strtolower((string) $eggTag) + : ($domain->service_tag + ? strtolower((string) $domain->service_tag) + : $this->service->getDefaultServiceTagForEgg($egg?->name, $egg?->nest?->name)), + 'recommended_record_type' => $recommendation['recommended_record_type'], + 'srv_supported' => $recommendation['srv_supported'], + 'allow_record_type_selection' => $recommendation['allow_record_type_selection'], + 'forced_record_type' => $recommendation['forced_record_type'], + 'dns_mode' => $recommendation['mode'], + 'recommendation_notice' => $recommendation['notice'], + 'connection_hint' => $recommendation['connection_hint'], + ]; + })->values(); + + return response()->json(['data' => $domains]); + } +} diff --git a/app/Http/Controllers/Api/Client/Billing/MollieCheckoutController.php b/app/Http/Controllers/Api/Client/Billing/MollieCheckoutController.php index c3e232d72c..f50d9b8b86 100644 --- a/app/Http/Controllers/Api/Client/Billing/MollieCheckoutController.php +++ b/app/Http/Controllers/Api/Client/Billing/MollieCheckoutController.php @@ -98,6 +98,7 @@ public function createPayment(Request $request, int $id): JsonResponse 'server_id' => $isRenewal ? $serverId : null, 'billing_days' => $billingDays, 'variables' => [], + 'domain_payload' => [], ]; // Store the token mapping in an order record (pending state) @@ -179,6 +180,7 @@ public function updatePayment(Request $request, int $id): Response $orderType = $this->getOrderType($request); $couponId = $request->input('coupon_id') ? (int) $request->input('coupon_id') : null; $variables = $request->input('variables', []); + $domainPayload = $request->input('domain_payload', []); $serverId = $request->input('server_id') ? (int) $request->input('server_id') : null; // Find the existing pending order and update it @@ -196,6 +198,7 @@ public function updatePayment(Request $request, int $id): Response 'coupon_id' => $couponId, 'billing_days' => $billingDays, 'variables' => $variables, + 'domain_payload' => is_array($domainPayload) ? $domainPayload : [], ]); return $this->returnNoContent(); diff --git a/app/Http/Controllers/Api/Client/Billing/PayPalCheckoutController.php b/app/Http/Controllers/Api/Client/Billing/PayPalCheckoutController.php index 151c8a3598..a686ca6d70 100644 --- a/app/Http/Controllers/Api/Client/Billing/PayPalCheckoutController.php +++ b/app/Http/Controllers/Api/Client/Billing/PayPalCheckoutController.php @@ -96,6 +96,7 @@ public function createOrder(Request $request, int $id): JsonResponse 'server_id' => $isRenewal ? $serverId : null, 'billing_days' => $billingDays, 'variables' => [], + 'domain_payload' => [], ]; $this->orderService->create( @@ -184,6 +185,7 @@ public function updateOrder(Request $request, int $id): Response $orderType = $this->getOrderType($request); $couponId = $request->input('coupon_id') ? (int) $request->input('coupon_id') : null; $variables = $request->input('variables', []); + $domainPayload = $request->input('domain_payload', []); $serverId = $request->input('server_id') ? (int) $request->input('server_id') : null; // Find the existing pending order and update it @@ -201,6 +203,7 @@ public function updateOrder(Request $request, int $id): Response 'coupon_id' => $couponId, 'billing_days' => $billingDays, 'variables' => $variables, + 'domain_payload' => is_array($domainPayload) ? $domainPayload : [], ]); Log::info('PayPal order updated successfully', [ diff --git a/app/Http/Controllers/Api/Client/Billing/PlanChangeController.php b/app/Http/Controllers/Api/Client/Billing/PlanChangeController.php index 78563ef0f7..69dea9dadb 100644 --- a/app/Http/Controllers/Api/Client/Billing/PlanChangeController.php +++ b/app/Http/Controllers/Api/Client/Billing/PlanChangeController.php @@ -127,6 +127,7 @@ public function changePlan(GetServerRequest $request, Server $server, int $produ 'database' => $updatedServer->database_limit, 'backup' => $updatedServer->backup_limit, 'allocation' => $updatedServer->allocation_limit, + 'subdomain' => $updatedServer->subdomain_limit ?? $updatedServer->product?->subdomain_limit, ], ], ]); diff --git a/app/Http/Controllers/Api/Client/Extensions/DiscordSrvHelperController.php b/app/Http/Controllers/Api/Client/Extensions/DiscordSrvHelperController.php new file mode 100644 index 0000000000..35b902c71c --- /dev/null +++ b/app/Http/Controllers/Api/Client/Extensions/DiscordSrvHelperController.php @@ -0,0 +1,346 @@ +fileRepository->setServer($server)->getDirectory(self::PLUGINS_DIR); + + $pluginJar = null; + $hasDiscordSrvFolder = false; + + foreach ($plugins as $item) { + $name = (string) Arr::get($item, 'name', ''); + $isFile = (bool) Arr::get($item, 'file', true); + + if (!$isFile && $name === 'DiscordSRV') { + $hasDiscordSrvFolder = true; + } + + if ($isFile && str_ends_with(strtolower($name), '.jar') && str_contains($name, 'DiscordSRV')) { + $pluginJar = $name; + } + } + + $tokenPresent = false; + $configPresent = false; + + if ($hasDiscordSrvFolder) { + $discordSrvDir = $this->fileRepository->setServer($server)->getDirectory(self::DISCORDSRV_DIR); + foreach ($discordSrvDir as $item) { + $name = (string) Arr::get($item, 'name', ''); + $isFile = (bool) Arr::get($item, 'file', true); + + if ($isFile && $name === '.token') { + $tokenPresent = true; + } + if ($isFile && $name === 'config.yml') { + $configPresent = true; + } + } + } + + return new JsonResponse([ + 'installed' => !is_null($pluginJar), + 'plugin_jar' => $pluginJar, + 'plugin_folder_present' => $hasDiscordSrvFolder, + 'token_file_present' => $tokenPresent, + 'config_present' => $configPresent, + ]); + } + + public function install(DiscordSrvHelperInstallRequest $request, Server $server): JsonResponse + { + $jarUrl = $request->input('jar_url'); + if (!$jarUrl) { + $config = ExtensionConfig::getByExtensionId(self::EXTENSION_ID); + $jarUrl = is_array($config?->settings) ? Arr::get($config->settings, 'jar_url') : null; + } + if (!$jarUrl) { + $jarUrl = $this->getLatestDiscordSrvJarUrl(); + } + + $jarUrl = $this->resolveRedirectedUrl($jarUrl); + + $response = $this->fileRepository->setServer($server)->pull($jarUrl, self::PLUGINS_DIR, [ + 'filename' => self::JAR_FILENAME, + 'foreground' => true, + ]); + + $this->ensureDaemonSuccess($response, 'Failed to download DiscordSRV jar.'); + + return new JsonResponse([ + 'installed' => true, + 'jar' => self::JAR_FILENAME, + 'jar_url' => $jarUrl, + ]); + } + + private function ensureDaemonSuccess(ResponseInterface $response, string $message): void + { + $status = $response->getStatusCode(); + if ($status >= 200 && $status < 300) { + return; + } + + $body = trim((string) $response->getBody()); + throw new \RuntimeException($message . ($body ? " Wings response: {$body}" : '')); + } + + public function setToken(DiscordSrvHelperTokenRequest $request, Server $server): JsonResponse + { + $token = trim((string) $request->input('token')); + + $this->ensureDirectory($server, self::PLUGINS_DIR, 'DiscordSRV'); + + $before = $this->safeGetContent($server, self::TOKEN_FILE); + if (!is_null($before)) { + $this->snapshotService->create($server, self::EXTENSION_ID, $request->user(), 'set-token', [ + self::TOKEN_FILE => $before, + ]); + } + + $this->fileRepository->setServer($server)->putContent(self::TOKEN_FILE, $token); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); + } + + public function setGlobalChannel(DiscordSrvHelperChannelRequest $request, Server $server): JsonResponse + { + $channelId = (string) $request->input('channel_id'); + + $before = $this->safeGetContent($server, self::CONFIG_FILE); + if (is_null($before)) { + return new JsonResponse([ + 'error' => 'DiscordSRV config.yml was not found. Start the server once to let DiscordSRV generate its config, then try again.', + ], 409); + } + + $this->snapshotService->create($server, self::EXTENSION_ID, $request->user(), 'set-global-channel', [ + self::CONFIG_FILE => $before, + ]); + + try { + $config = Yaml::parse($before); + } catch (\Throwable $exception) { + return new JsonResponse([ + 'error' => 'DiscordSRV config.yml could not be parsed as YAML. Use the revert feature or fix the file manually, then try again.', + ], 422); + } + if (!is_array($config)) { + $config = []; + } + + $channels = Arr::get($config, 'Channels', []); + if (!is_array($channels)) { + $channels = []; + } + + $channels['global'] = $channelId; + $config['Channels'] = $channels; + + $yaml = Yaml::dump($config, 20, 2); + if (!str_ends_with($yaml, "\n")) { + $yaml .= "\n"; + } + + $this->fileRepository->setServer($server)->putContent(self::CONFIG_FILE, $yaml); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); + } + + public function history(DiscordSrvHelperOwnerRequest $request, Server $server): JsonResponse + { + $snapshots = ExtensionFileSnapshot::query() + ->where('server_id', $server->id) + ->where('extension_id', self::EXTENSION_ID) + ->with('actor') + ->orderByDesc('id') + ->limit(25) + ->get(); + + $data = $snapshots->map(fn (ExtensionFileSnapshot $s) => [ + 'id' => $s->id, + 'action' => $s->action, + 'created_at' => $s->created_at, + 'actor' => $s->actor ? [ + 'id' => $s->actor->id, + 'email' => $s->actor->email, + ] : null, + ])->values(); + + return new JsonResponse([ + 'object' => 'list', + 'data' => $data, + ]); + } + + public function revert(DiscordSrvHelperOwnerRequest $request, Server $server, int $snapshotId): JsonResponse + { + $snapshot = ExtensionFileSnapshot::query() + ->where('server_id', $server->id) + ->where('extension_id', self::EXTENSION_ID) + ->where('id', $snapshotId) + ->firstOrFail(); + + $files = $this->snapshotService->decryptFiles($snapshot); + foreach ($files as $path => $contents) { + $this->fileRepository->setServer($server)->putContent($path, $contents); + } + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); + } + + public function subusers(DiscordSrvHelperOwnerRequest $request, Server $server): JsonResponse + { + $subusers = Subuser::query() + ->with('user') + ->where('server_id', $server->id) + ->get(); + + $data = $subusers->map(fn (Subuser $s) => [ + 'uuid' => $s->user->uuid, + 'email' => $s->user->email, + 'username' => $s->user->username, + 'disabled' => in_array(self::EXTENSION_ID, $s->disabled_extensions ?? [], true), + ])->values(); + + return new JsonResponse([ + 'object' => 'list', + 'data' => $data, + ]); + } + + public function setSubuserAccess(DiscordSrvHelperSubuserAccessRequest $request, Server $server, string $subuserUuid): JsonResponse + { + $enabled = (bool) $request->input('enabled'); + + $subuser = Subuser::query() + ->where('server_id', $server->id) + ->whereHas('user', fn ($q) => $q->where('uuid', $subuserUuid)) + ->firstOrFail(); + + $disabled = $subuser->disabled_extensions ?? []; + $disabled = array_values(array_unique(array_filter($disabled, 'is_string'))); + + if ($enabled) { + $disabled = array_values(array_filter($disabled, fn ($id) => $id !== self::EXTENSION_ID)); + } else { + if (!in_array(self::EXTENSION_ID, $disabled, true)) { + $disabled[] = self::EXTENSION_ID; + } + } + + $subuser->update(['disabled_extensions' => $disabled]); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); + } + + private function ensureDirectory(Server $server, string $path, string $name): void + { + try { + $this->fileRepository->setServer($server)->createDirectory($name, $path); + } catch (\Throwable) { + // Directory likely already exists; ignore. + } + } + + private function safeGetContent(Server $server, string $file): ?string + { + try { + return $this->fileRepository->setServer($server)->getContent($file); + } catch (\Throwable) { + return null; + } + } + + private function getLatestDiscordSrvJarUrl(): string + { + $response = Http::timeout(15) + ->withHeaders([ + 'Accept' => 'application/vnd.github+json', + ]) + ->get('https://api.github.com/repos/DiscordSRV/DiscordSRV/releases/latest'); + + $response->throw(); + $json = $response->json(); + + $assets = $json['assets'] ?? []; + foreach ($assets as $asset) { + $name = (string) ($asset['name'] ?? ''); + $url = (string) ($asset['browser_download_url'] ?? ''); + + if ($url && str_ends_with(strtolower($name), '.jar')) { + return $url; + } + } + + throw new \RuntimeException('Could not locate a .jar asset in the latest DiscordSRV release.'); + } + + private function resolveRedirectedUrl(string $url): string + { + try { + $response = Http::timeout(15) + ->withOptions([ + 'allow_redirects' => [ + 'track_redirects' => true, + ], + ]) + ->head($url); + + $headers = $response->headers(); + $history = $headers['X-Guzzle-Redirect-History'] ?? []; + + if (is_string($history)) { + $history = array_filter(array_map('trim', explode(',', $history))); + } + + if (is_array($history) && count($history) > 0) { + $last = (string) $history[count($history) - 1]; + if ($last !== '') { + return $last; + } + } + + return $url; + } catch (\Throwable) { + return $url; + } + } +} diff --git a/app/Http/Controllers/Api/Client/Extensions/ExtensionScanController.php b/app/Http/Controllers/Api/Client/Extensions/ExtensionScanController.php new file mode 100644 index 0000000000..5c7131fb9f --- /dev/null +++ b/app/Http/Controllers/Api/Client/Extensions/ExtensionScanController.php @@ -0,0 +1,106 @@ +validate([ + 'extension_file' => [ + 'required', + 'file', + 'max:51200', // 50 MB + 'mimetypes:application/zip,application/octet-stream,application/x-zip-compressed', + ], + ]); + + /** @var \Illuminate\Http\UploadedFile $file */ + $file = $request->file('extension_file'); + + // Use the configured temp directory for uploads, relative to storage/app/. + $configuredTempDir = config('extensions.scan.temp_dir', storage_path('app/extension-scans')); + $relativeTempDir = 'extension-scans/uploads'; + if (str_starts_with($configuredTempDir, storage_path('app/'))) { + $relativeTempDir = trim(substr($configuredTempDir, strlen(storage_path('app/'))), '/') . '/uploads'; + } + + $tempPath = $file->storeAs( + $relativeTempDir, + $file->hashName() . '.M12LabsExtension', + 'local' + ); + + $absoluteTempPath = storage_path('app/' . $tempPath); + + try { + $result = $this->scanner->scan($absoluteTempPath); + } finally { + if (is_file($absoluteTempPath)) { + File::delete($absoluteTempPath); + } + } + + $data = $result->toArray(); + + if ($result->isBlocked()) { + return new JsonResponse($data, Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return new JsonResponse($data, Response::HTTP_OK); + } + + /** + * GET /api/client/extensions/{slug}/scan-report + * + * Returns the stored scan-report.json for an installed extension. + */ + public function report(string $slug): JsonResponse + { + // Sanitize slug to prevent path traversal. + $safeSlug = preg_replace('/[^a-zA-Z0-9_\-]/', '', $slug); + if ($safeSlug === '' || $safeSlug !== $slug) { + return new JsonResponse(['error' => 'Invalid extension slug.'], Response::HTTP_BAD_REQUEST); + } + + $reportPath = storage_path('app/extensions/installed/' . $safeSlug . '/scan-report.json'); + + if (!is_file($reportPath)) { + return new JsonResponse(['error' => 'Scan report not found.'], Response::HTTP_NOT_FOUND); + } + + $raw = file_get_contents($reportPath); + if ($raw === false) { + return new JsonResponse(['error' => 'Could not read scan report.'], Response::HTTP_INTERNAL_SERVER_ERROR); + } + + try { + $data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return new JsonResponse(['error' => 'Scan report is malformed.'], Response::HTTP_INTERNAL_SERVER_ERROR); + } + + return new JsonResponse($data, Response::HTTP_OK); + } +} diff --git a/app/Http/Controllers/Api/Client/Extensions/ExtensionsController.php b/app/Http/Controllers/Api/Client/Extensions/ExtensionsController.php new file mode 100644 index 0000000000..5f236abf0c --- /dev/null +++ b/app/Http/Controllers/Api/Client/Extensions/ExtensionsController.php @@ -0,0 +1,104 @@ +root_admin || $server->owner_id === $user->id) { + return false; + } + + $subuser = Subuser::query() + ->where('user_id', $user->id) + ->where('server_id', $server->id) + ->first(); + + return $subuser && in_array($extensionId, $subuser->disabled_extensions ?? [], true); + } + + /** + * Get all enabled extensions for a server. + */ + public function index(GetServerExtensionsRequest $request, Server $server): JsonResponse + { + $enabledConfigs = ExtensionConfig::getEnabledForServer($server); + $availableExtensions = []; + foreach ($this->catalogService->getLocalExtensions() as $extension) { + $availableExtensions[$extension['id']] = $extension; + } + + $user = $request->user(); + + $extensions = []; + foreach ($enabledConfigs as $config) { + if ($this->isExtensionDisabledForUser($server, $user, $config->extension_id)) { + continue; + } + + $extensionDef = $availableExtensions[$config->extension_id] ?? null; + if ($extensionDef) { + $extensions[] = [ + 'id' => $config->extension_id, + 'name' => $extensionDef['name'], + 'description' => $extensionDef['description'], + 'icon' => $extensionDef['icon'], + 'version' => $extensionDef['version'] ?? ($extensionDef['latestVersion'] ?? '1.0.0'), + 'route' => $extensionDef['route'] ?? $config->extension_id, + 'settings' => $config->settings ?? [], + ]; + } + } + + return new JsonResponse([ + 'object' => 'list', + 'data' => $extensions, + ]); + } + + /** + * Check if a specific extension is enabled for a server. + */ + public function check(GetServerExtensionsRequest $request, Server $server, string $extensionId): JsonResponse + { + if ($this->isExtensionDisabledForUser($server, $request->user(), $extensionId)) { + return new JsonResponse([ + 'enabled' => false, + ]); + } + + $config = ExtensionConfig::getByExtensionId($extensionId); + + if (!$config || !$config->isServerEligible($server)) { + return new JsonResponse([ + 'enabled' => false, + ]); + } + + $availableExtensions = []; + foreach ($this->catalogService->getLocalExtensions() as $extension) { + $availableExtensions[$extension['id']] = $extension; + } + + $extensionDef = $availableExtensions[$extensionId] ?? null; + + return new JsonResponse([ + 'enabled' => true, + 'route' => $extensionDef['route'] ?? $extensionId, + ]); + } +} diff --git a/app/Http/Controllers/Api/Client/Extensions/PlayerManagerController.php b/app/Http/Controllers/Api/Client/Extensions/PlayerManagerController.php new file mode 100644 index 0000000000..01517bba18 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Extensions/PlayerManagerController.php @@ -0,0 +1,1599 @@ + 16) { + throw new \InvalidArgumentException('Invalid player name format'); + } + + return $sanitized; + } + + /** + * Validate and sanitize IP address. + */ + private function sanitizeIpAddress(string $ip): string + { + // Validate as IPv4 or IPv6 + if (!filter_var($ip, FILTER_VALIDATE_IP)) { + throw new \InvalidArgumentException('Invalid IP address format'); + } + + return $ip; + } + + /** + * Sanitize reason/message to prevent command injection. + */ + private function sanitizeMessage(string $message): string + { + // Remove newlines and limit length + $sanitized = str_replace(["\r", "\n", "\t"], ' ', $message); + return substr(trim($sanitized), 0, 255); + } + + /** + * Check if the extension is enabled for this server. + */ + private function checkExtensionEnabled(Server $server): void + { + $config = ExtensionConfig::getByExtensionId('minecraft_player_manager'); + + if (!$config || !$config->isServerEligible($server)) { + throw new \Exception('Minecraft Player Manager is not enabled for this server.'); + } + } + + private function queryApi(Server $server): array + { + return Cache::remember("minecraftserver:query:{$server->id}", 10, function () use ($server) { + if ($this->isQueryEnabled($server)) { + $query = new MinecraftQuery(); + $query->Connect($server->allocation->alias ?? $server->allocation->ip, $server->allocation->port, 2, false); + + $data = $query->GetInfo(); + + if (!$data) { + throw new \Exception('Failed to query server'); + } + + $players = []; + $rawPlayers = $query->GetPlayers(); + if ($rawPlayers) { + foreach ($rawPlayers as $player) { + $userData = $this->lookupUserName($player, $server); + + if ($userData) { + $uuid = $userData['uuid']; + } + + if (!$uuid) { + continue; + } + + $players[] = [ + 'id' => $uuid, + 'name' => $player, + ]; + } + } + + return [ + 'players' => [ + 'online' => $data['Players'], + 'max' => $data['MaxPlayers'], + 'list' => $players, + ], + ]; + } else { + $query = new MinecraftPing($server->allocation->alias ?? $server->allocation->ip, $server->allocation->port, 2, false); + $query->Connect(); + + $data = $query->Query(); + + if (!$data) { + throw new \Exception('Failed to query server'); + } + + return [ + 'players' => [ + 'online' => $data['players']['online'], + 'max' => $data['players']['max'], + 'list' => $data['players']['sample'] ?? [], + ], + ]; + } + }); + } + + private function userCache(Server $server): array + { + return Cache::remember("minecraftserver:username-cache:{$server->id}", 30, function () use ($server) { + try { + $cache = $this->fileRepository->setServer($server)->getContent('/usercache.json'); + return json_decode($cache, true) ?? []; + } catch (\Throwable $e) { + return []; + } + }); + } + + private function formatUuid(string $uuid): string + { + $uuid = str_replace('-', '', $uuid); + return substr($uuid, 0, 8) . '-' . substr($uuid, 8, 4) . '-' . substr($uuid, 12, 4) . '-' . substr($uuid, 16, 4) . '-' . substr($uuid, 20); + } + + private function lookupUser(string $uuid, Server $server): array|null + { + $name = config('app.name', 'Jexactyl'); + $uuid = str_replace('-', '', $uuid); + $cache = $this->userCache($server); + + foreach ($cache as $player) { + if ($player['uuid'] === $this->formatUuid($uuid)) { + return [ + 'uuid' => $this->formatUuid($player['uuid']), + 'name' => $player['name'], + ]; + } + } + + $data = Cache::remember("minecraftplayer:$uuid", 1000, function () use ($name, $uuid) { + try { + $req = Http::withUserAgent("Jexactyl Player Manager @ $name") + ->timeout(5) + ->retry(2, 100, throw: true) + ->get("https://sessionserver.mojang.com/session/minecraft/profile/$uuid"); + + return json_decode($req->getBody()->getContents(), true); + } catch (\Throwable $e) { + return null; + } + }); + + if (is_null($data)) { + return null; + } + + return [ + 'uuid' => $this->formatUuid($data['id']), + 'name' => $data['name'], + ]; + } + + private function lookupUserName(string $name, Server $server): array|null + { + $app = config('app.name', 'Jexactyl'); + $offline = $this->isOfflineMode($server); + $cache = $this->userCache($server); + + foreach ($cache as $player) { + if ($player['name'] === $name) { + return [ + 'uuid' => $this->formatUuid($player['uuid']), + 'name' => $player['name'], + ]; + } + } + + if ($offline) { + $uuid = $this->formatUuid(md5("OfflinePlayer:$name")); + return [ + 'uuid' => $uuid, + 'name' => $name, + ]; + } + + $data = Cache::remember("minecraftplayername:$name", 1000, function () use ($app, $name) { + try { + $req = Http::withUserAgent("Jexactyl Player Manager @ $app") + ->timeout(5) + ->retry(2, 100, throw: true) + ->get("https://api.mojang.com/users/profiles/minecraft/$name"); + + return json_decode($req->getBody()->getContents(), true); + } catch (\Throwable $e) { + return null; + } + }); + + if (is_null($data)) { + return null; + } + + return [ + 'uuid' => $this->formatUuid($data['id']), + 'name' => $data['name'], + ]; + } + + private function sortList(array $list): array + { + usort($list, function ($a, $b) { + return strcasecmp($a['name'] ?? $a['ip'], $b['name'] ?? $b['ip']); + }); + + return $list; + } + + private function getServerProperties(Server $server): array + { + return Cache::remember("minecraftserver:properties:{$server->id}", 10, function () use ($server) { + try { + $properties = $this->fileRepository->setServer($server)->getContent('/server.properties'); + $data = explode("\n", $properties); + + $result = []; + foreach ($data as $line) { + if (str_starts_with($line, '#')) { + continue; + } + + $parts = explode('=', $line, 2); + $result[$parts[0]] = $parts[1] ?? ''; + } + + return $result; + } catch (\Throwable $e) { + return []; + } + }); + } + + private function isQueryEnabled(Server $server): bool + { + $properties = $this->getServerProperties($server); + + if (array_key_exists('enable-query', $properties) && $properties['enable-query'] === 'true') { + return true; + } + + return false; + } + + private function isOfflineMode(Server $server): bool + { + $properties = $this->getServerProperties($server); + + if (array_key_exists('online-mode', $properties) && $properties['online-mode'] === 'false') { + return true; + } + + return false; + } + + private function isBukkitBased(Server $server): bool + { + return Cache::remember("minecraftserver:bukkit:{$server->id}", 30, function () use ($server) { + try { + $bukkitYml = $this->fileRepository->setServer($server)->getContent('/bukkit.yml'); + return !!$bukkitYml; + } catch (\Throwable $e) { + return false; + } + }); + } + + /** + * Get player manager status for server. + */ + public function index(GetStatusRequest $request, Server $server): JsonResponse + { + $this->checkExtensionEnabled($server); + + $properties = $this->getServerProperties($server); + + $onlineMode = !$this->isOfflineMode($server); + $opped = []; + $whitelisted = []; + $whitelistEnabled = array_key_exists('white-list', $properties) && $properties['white-list'] === 'true'; + $banned = []; + $bannedIps = []; + + // Load ops.json + try { + $ops = $this->fileRepository->setServer($server)->getContent('/ops.json'); + $data = json_decode($ops, true); + + foreach ($data as $op) { + $uuid = str_replace('-', '', $op['uuid']); + + $opped[] = [ + 'uuid' => $op['uuid'], + 'name' => $op['name'], + 'level' => $op['level'], + 'bypassesPlayerLimit' => $op['bypassesPlayerLimit'], + 'avatar' => "https://minotar.net/helm/$uuid/256.png", + 'render' => "https://render.skinmc.net/3d.php?user=$uuid&vr=-20&hr=30&hrh=0&vrll=-20&vrrl=10&vrla=10&vrra=-10&ratio=20", + ]; + } + } catch (\Throwable $e) { + // ignore + } + + // Load whitelist.json + try { + $whitelist = $this->fileRepository->setServer($server)->getContent('/whitelist.json'); + $data = json_decode($whitelist, true); + + foreach ($data as $whitelist) { + $uuid = str_replace('-', '', $whitelist['uuid']); + + $whitelisted[] = [ + 'uuid' => $whitelist['uuid'], + 'name' => $whitelist['name'], + 'avatar' => "https://minotar.net/helm/$uuid/256.png", + 'render' => "https://render.skinmc.net/3d.php?user=$uuid&vr=-20&hr=30&hrh=0&vrll=-20&vrrl=10&vrla=10&vrra=-10&ratio=20", + ]; + } + } catch (\Throwable $e) { + // ignore + } + + // Load banned-players.json + try { + $bans = $this->fileRepository->setServer($server)->getContent('/banned-players.json'); + $data = json_decode($bans, true); + + foreach ($data as $ban) { + $uuid = str_replace('-', '', $ban['uuid']); + + $banned[] = [ + 'uuid' => $ban['uuid'], + 'name' => $ban['name'], + 'reason' => $ban['reason'], + 'avatar' => "https://minotar.net/helm/$uuid/256.png", + 'render' => "https://render.skinmc.net/3d.php?user=$uuid&vr=-20&hr=30&hrh=0&vrll=-20&vrrl=10&vrla=10&vrra=-10&ratio=20", + ]; + } + } catch (\Throwable $e) { + // ignore + } + + // Load banned-ips.json + try { + $bans = $this->fileRepository->setServer($server)->getContent('/banned-ips.json'); + $data = json_decode($bans, true); + + foreach ($data as $ban) { + $bannedIps[] = [ + 'ip' => $ban['ip'], + 'reason' => $ban['reason'], + ]; + } + } catch (\Throwable $e) { + // ignore + } + + // Try to query online players + try { + $data = $this->queryApi($server); + + $players = []; + foreach ($data['players']['list'] ?? [] as $player) { + $uuid = str_replace('-', '', $player['id']); + + if (preg_match('/^0+$/', $uuid) || str_starts_with($uuid, '0000000000000000')) { + continue; + } + + $players[] = [ + 'uuid' => $player['id'], + 'name' => $player['name'], + 'avatar' => "https://minotar.net/helm/$uuid/256.png", + 'render' => "https://render.skinmc.net/3d.php?user=$uuid&vr=-20&hr=30&hrh=0&vrll=-20&vrrl=10&vrla=10&vrra=-10&ratio=20", + ]; + } + + return new JsonResponse([ + 'server' => [ + 'online' => true, + 'players' => [ + 'online' => $data['players']['online'], + 'max' => $data['players']['max'], + 'list' => $this->sortList($players), + ], + 'version' => '', + 'motd' => '', + ], + 'operators' => $this->sortList($opped), + 'whitelist' => $this->sortList($whitelisted), + 'bannedPlayers' => $this->sortList($banned), + 'bannedIps' => $this->sortList($bannedIps), + 'whitelistEnabled' => $whitelistEnabled, + ]); + } catch (\Throwable $e) { + return new JsonResponse([ + 'server' => [ + 'online' => false, + 'players' => [ + 'online' => 0, + 'max' => 0, + 'list' => [], + ], + 'version' => '', + 'motd' => '', + ], + 'operators' => $this->sortList($opped), + 'whitelist' => $this->sortList($whitelisted), + 'bannedPlayers' => $this->sortList($banned), + 'bannedIps' => $this->sortList($bannedIps), + 'whitelistEnabled' => $whitelistEnabled, + ]); + } + } + + public function op(PlayerNamedRequest $request, Server $server, string $player): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + try { + $ops = $this->fileRepository->setServer($server)->getContent('/ops.json'); + $data = json_decode($ops, true); + } catch (\Throwable $e) { + $data = []; + } + + foreach ($data as $op) { + if ($op['name'] === $name) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Player is already an operator', + ], 400); + } + } + + $playerData = $this->lookupUserName($name, $server); + + if (is_null($playerData)) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Failed to lookup player', + ], 400); + } + + $data[] = [ + 'uuid' => $playerData['uuid'], + 'name' => $playerData['name'], + 'level' => 4, + 'bypassesPlayerLimit' => true, + ]; + + $this->fileRepository->setServer($server)->putContent('/ops.json', json_encode($data, JSON_PRETTY_PRINT)); + usleep(500000); + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:op {$playerData['name']}" : "op {$playerData['name']}"; + $this->commandRepository->setServer($server)->send($cmd); + } catch (\Throwable $e) { + // ignore + } + + Activity::event('server:player.op') + ->property(['uuid' => $playerData['uuid'], 'name' => $playerData['name']]) + ->log(); + + return new JsonResponse(['success' => true]); + } + + public function deop(PlayerRequest $request, Server $server, string $player): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + try { + $ops = $this->fileRepository->setServer($server)->getContent('/ops.json'); + $data = json_decode($ops, true); + } catch (\Throwable $e) { + $data = []; + } + + // Look up player by name from route parameter + $playerData = $this->lookupUserName($name, $server); + + if (is_null($playerData)) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Failed to lookup player', + ], 400); + } + + $data = array_filter($data, function ($op) use ($playerData) { + return $op['uuid'] !== $playerData['uuid']; + }); + + $this->fileRepository->setServer($server)->putContent('/ops.json', json_encode(array_values($data), JSON_PRETTY_PRINT)); + usleep(500000); + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:deop {$playerData['name']}" : "deop {$playerData['name']}"; + $this->commandRepository->setServer($server)->send($cmd); + } catch (\Throwable $e) { + // ignore + } + + Activity::event('server:player.deop') + ->property(['uuid' => $playerData['uuid'], 'name' => $playerData['name']]) + ->log(); + + return new JsonResponse(['success' => true]); + } + + public function setWhitelist(SetWhitelistRequest $request, Server $server): array + { + $this->checkExtensionEnabled($server); + + try { + $properties = $this->fileRepository->setServer($server)->getContent('/server.properties'); + $data = explode("\n", $properties); + } catch (\Throwable $e) { + $data = []; + } + + $whitelist = $request->input('enabled'); + + $data = array_map(function ($line) use ($whitelist) { + if (str_starts_with($line, 'white-list=')) { + return 'white-list=' . ($whitelist ? 'true' : 'false'); + } + return $line; + }, $data); + + if (!in_array('white-list=false', $data) && !in_array('white-list=true', $data)) { + $data[] = 'white-list=' . ($whitelist ? 'true' : 'false'); + } + + Cache::forget("minecraftserver:properties:{$server->id}"); + $this->fileRepository->setServer($server)->putContent('/server.properties', implode("\n", $data)); + usleep(500000); + + try { + $cmd = $this->isBukkitBased($server) ? 'minecraft:whitelist ' : 'whitelist '; + $this->commandRepository->setServer($server)->send($cmd . ($whitelist ? 'on' : 'off')); + } catch (\Throwable $e) { + // ignore + } + + Activity::event('server:whitelist.set') + ->property(['enabled' => $whitelist]) + ->log(); + + return ['success' => true]; + } + + public function addWhitelist(PlayerNamedRequest $request, Server $server, string $player): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + try { + $whitelist = $this->fileRepository->setServer($server)->getContent('/whitelist.json'); + $data = json_decode($whitelist, true); + } catch (\Throwable $e) { + $data = []; + } + + foreach ($data as $w) { + if ($w['name'] === $name) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Player is already whitelisted', + ], 400); + } + } + + $playerData = $this->lookupUserName($name, $server); + + if (is_null($playerData)) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Failed to lookup player', + ], 400); + } + + $data[] = [ + 'uuid' => $playerData['uuid'], + 'name' => $playerData['name'], + ]; + + $this->fileRepository->setServer($server)->putContent('/whitelist.json', json_encode($data, JSON_PRETTY_PRINT)); + usleep(500000); + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:whitelist add {$playerData['name']}" : "whitelist add {$playerData['name']}"; + $this->commandRepository->setServer($server)->send($cmd); + } catch (\Throwable $e) { + // ignore + } + + Activity::event('server:whitelist.add') + ->property(['uuid' => $playerData['uuid'], 'name' => $playerData['name']]) + ->log(); + + return new JsonResponse(['success' => true]); + } + + public function removeWhitelist(PlayerRequest $request, Server $server, string $player): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + try { + $whitelist = $this->fileRepository->setServer($server)->getContent('/whitelist.json'); + $data = json_decode($whitelist, true); + } catch (\Throwable $e) { + $data = []; + } + + $playerData = $this->lookupUserName($name, $server); + + if (is_null($playerData)) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Failed to lookup player', + ], 400); + } + + $data = array_filter($data, function ($w) use ($playerData) { + return $w['uuid'] !== $playerData['uuid']; + }); + + $this->fileRepository->setServer($server)->putContent('/whitelist.json', json_encode(array_values($data), JSON_PRETTY_PRINT)); + usleep(500000); + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:whitelist remove {$playerData['name']}" : "whitelist remove {$playerData['name']}"; + $this->commandRepository->setServer($server)->send($cmd); + } catch (\Throwable $e) { + // ignore + } + + Activity::event('server:whitelist.remove') + ->property(['uuid' => $playerData['uuid'], 'name' => $playerData['name']]) + ->log(); + + return new JsonResponse(['success' => true]); + } + + public function ban(BanRequest $request, Server $server, string $player): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + $reason = $this->sanitizeMessage($request->input('reason', 'Banned by panel')); + + try { + $bans = $this->fileRepository->setServer($server)->getContent('/banned-players.json'); + $data = json_decode($bans, true); + } catch (\Throwable $e) { + $data = []; + } + + foreach ($data as $ban) { + if ($ban['name'] === $name) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Player is already banned', + ], 400); + } + } + + $playerData = $this->lookupUserName($name, $server); + + if (is_null($playerData)) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Failed to lookup player', + ], 400); + } + + $data[] = [ + 'uuid' => $playerData['uuid'], + 'name' => $playerData['name'], + 'source' => 'Panel', + 'created' => date('Y-m-d H:i:s O'), + 'expires' => 'forever', + 'reason' => $reason, + ]; + + $this->fileRepository->setServer($server)->putContent('/banned-players.json', json_encode($data, JSON_PRETTY_PRINT)); + usleep(500000); + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:ban {$playerData['name']} $reason" : "ban {$playerData['name']} $reason"; + $this->commandRepository->setServer($server)->send($cmd); + } catch (\Throwable $e) { + // ignore + } + + Activity::event('server:player.ban') + ->property(['uuid' => $playerData['uuid'], 'name' => $playerData['name'], 'reason' => $reason]) + ->log(); + + return new JsonResponse(['success' => true]); + } + + public function unban(PlayerRequest $request, Server $server, string $player): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + try { + $bans = $this->fileRepository->setServer($server)->getContent('/banned-players.json'); + $data = json_decode($bans, true); + } catch (\Throwable $e) { + $data = []; + } + + $playerData = $this->lookupUserName($name, $server); + + if (is_null($playerData)) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Failed to lookup player', + ], 400); + } + + $data = array_filter($data, function ($ban) use ($playerData) { + return $ban['uuid'] !== $playerData['uuid']; + }); + + $this->fileRepository->setServer($server)->putContent('/banned-players.json', json_encode(array_values($data), JSON_PRETTY_PRINT)); + usleep(500000); + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:pardon {$playerData['name']}" : "pardon {$playerData['name']}"; + $this->commandRepository->setServer($server)->send($cmd); + } catch (\Throwable $e) { + // ignore + } + + Activity::event('server:player.unban') + ->property(['uuid' => $playerData['uuid'], 'name' => $playerData['name']]) + ->log(); + + return new JsonResponse(['success' => true]); + } + + public function banIp(BanIpRequest $request, Server $server, string $ip): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $ip = $this->sanitizeIpAddress($ip); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + $reason = $this->sanitizeMessage($request->input('reason', 'Banned by panel')); + + try { + $bans = $this->fileRepository->setServer($server)->getContent('/banned-ips.json'); + $data = json_decode($bans, true); + } catch (\Throwable $e) { + $data = []; + } + + foreach ($data as $ban) { + if ($ban['ip'] === $ip) { + return new JsonResponse([ + 'success' => false, + 'error' => 'IP is already banned', + ], 400); + } + } + + $data[] = [ + 'ip' => $ip, + 'source' => 'Panel', + 'created' => date('Y-m-d H:i:s O'), + 'expires' => 'forever', + 'reason' => $reason, + ]; + + $this->fileRepository->setServer($server)->putContent('/banned-ips.json', json_encode($data, JSON_PRETTY_PRINT)); + usleep(500000); + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:ban-ip $ip $reason" : "ban-ip $ip $reason"; + $this->commandRepository->setServer($server)->send($cmd); + } catch (\Throwable $e) { + // ignore + } + + Activity::event('server:player.ban-ip') + ->property(['ip' => $ip, 'reason' => $reason]) + ->log(); + + return new JsonResponse(['success' => true]); + } + + public function unbanIp(IpRequest $request, Server $server, string $ip): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $ip = $this->sanitizeIpAddress($ip); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + try { + $bans = $this->fileRepository->setServer($server)->getContent('/banned-ips.json'); + $data = json_decode($bans, true); + } catch (\Throwable $e) { + $data = []; + } + + $data = array_filter($data, function ($ban) use ($ip) { + return $ban['ip'] !== $ip; + }); + + $this->fileRepository->setServer($server)->putContent('/banned-ips.json', json_encode(array_values($data), JSON_PRETTY_PRINT)); + usleep(500000); + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:pardon-ip $ip" : "pardon-ip $ip"; + $this->commandRepository->setServer($server)->send($cmd); + } catch (\Throwable $e) { + // ignore + } + + Activity::event('server:player.unban-ip') + ->property(['ip' => $ip]) + ->log(); + + return new JsonResponse(['success' => true]); + } + + public function kick(KickRequest $request, Server $server, string $player): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + $reason = $this->sanitizeMessage($request->input('reason', 'Kicked by panel')); + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:kick $name $reason" : "kick $name $reason"; + $this->commandRepository->setServer($server)->send($cmd); + + Activity::event('server:player.kick') + ->property(['name' => $name, 'reason' => $reason]) + ->log(); + + return new JsonResponse(['success' => true]); + } catch (\Throwable $e) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Server is offline', + ], 400); + } + } + + public function whisper(WhisperRequest $request, Server $server, string $player): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + $message = $this->sanitizeMessage($request->input('message')); + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:tell $name $message" : "tell $name $message"; + $this->commandRepository->setServer($server)->send($cmd); + + Activity::event('server:player.whisper') + ->property(['name' => $name, 'message' => $message]) + ->log(); + + return new JsonResponse(['success' => true]); + } catch (\Throwable $e) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Server is offline', + ], 400); + } + } + + public function kill(PlayerRequest $request, Server $server, string $player): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + try { + $cmd = $this->isBukkitBased($server) ? "minecraft:kill $name" : "kill $name"; + $this->commandRepository->setServer($server)->send($cmd); + + Activity::event('server:player.kill') + ->property(['name' => $name]) + ->log(); + + return new JsonResponse(['success' => true]); + } catch (\Throwable $e) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Server is offline', + ], 400); + } + } + + /** + * Get the Minecraft server version (cached for 5 minutes). + */ + public function getServerVersion(GetStatusRequest $request, Server $server): JsonResponse + { + $this->checkExtensionEnabled($server); + + $version = Cache::remember("minecraftserver:version:{$server->id}", 300, function () use ($server) { + try { + $query = new MinecraftPing($server->allocation->alias ?? $server->allocation->ip, $server->allocation->port, 2, false); + $query->Connect(); + $data = $query->Query(); + + if (!$data || !isset($data['version']['name'])) { + return null; + } + + $versionString = $data['version']['name']; + + // Parse version number from string (e.g., "1.20.4", "Paper 1.20.4", "Spigot 1.19.2") + preg_match('/(\d+)\.(\d+)(?:\.(\d+))?/', $versionString, $matches); + + if (empty($matches)) { + return null; + } + + $major = (int) $matches[1]; + $minor = (int) $matches[2]; + $patch = (int) ($matches[3] ?? 0); + + return [ + 'raw' => $versionString, + 'major' => $major, + 'minor' => $minor, + 'patch' => $patch, + 'protocol' => $data['version']['protocol'] ?? 0, + 'supportsAttributes' => ($major >= 1 && $minor >= 16), + ]; + } catch (\Throwable $e) { + return null; + } + }); + + if (!$version) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Failed to detect server version', + ], 400); + } + + return new JsonResponse([ + 'success' => true, + 'version' => $version, + ]); + } + + /** + * Get player data from NBT file (inventory, location, stats). + */ + public function getPlayerData(PlayerReadRequest $request, Server $server, string $player): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + // Look up player UUID + $playerData = $this->lookupUserName($name, $server); + + if (is_null($playerData)) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Failed to lookup player', + ], 400); + } + + $uuid = $playerData['uuid']; + + // Find the world directory + $worldDir = $this->getWorldDirectory($server); + if (!$worldDir) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Could not find world directory', + ], 400); + } + + // Try to get the player data file + $playerDataPath = "/{$worldDir}/playerdata/{$uuid}.dat"; + + try { + $datContent = $this->fileRepository->setServer($server)->getContent($playerDataPath); + } catch (\Throwable $e) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Player data file has not been created yet. Please rejoin the server and try again.', + ], 404); + } + + try { + // Write to temp file and parse + $tempFile = tempnam(sys_get_temp_dir(), 'nbt_'); + file_put_contents($tempFile, $datContent); + + $parser = new NbtParser(); + $nbt = $parser->parseFile($tempFile); + + unlink($tempFile); + + // Extract data + $inventory = NbtParser::extractInventory($nbt); + $armor = NbtParser::extractArmor($nbt); + $enderChest = NbtParser::extractEnderChest($nbt); + $location = NbtParser::extractLocation($nbt); + $stats = NbtParser::extractStats($nbt); + + // Debug: collect all slot numbers for troubleshooting + $allSlots = array_map(fn($item) => ['slot' => $item['slot'], 'id' => $item['id']], $inventory); + + // Debug: Get raw NBT keys to understand structure + $nbtData = $nbt['value'] ?? $nbt; + $nbtKeys = is_array($nbtData) ? array_keys($nbtData) : []; + + // Debug: Get equipment structure + $equipmentDebug = isset($nbtData['equipment']) ? $nbtData['equipment'] : null; + + // Sort inventory by slot + usort($inventory, fn($a, $b) => $a['slot'] <=> $b['slot']); + + // Filter out armor slots from main inventory (100-103) and offhand (-106, 45) + $mainInventory = array_values(array_filter($inventory, fn($item) => $item['slot'] >= 0 && $item['slot'] < 100)); + + // Offhand: check equipment field first (1.20.5+), then inventory slot + $offhand = null; + if (isset($nbtData['equipment']['offhand']) && is_array($nbtData['equipment']['offhand']) && !empty($nbtData['equipment']['offhand'])) { + $offhand = NbtParser::parseItemPublic($nbtData['equipment']['offhand']); + } else { + foreach ($inventory as $item) { + if ($item['slot'] === -106 || $item['slot'] === 45) { + $offhand = $item; + break; + } + } + } + + return new JsonResponse([ + 'success' => true, + 'player' => [ + 'uuid' => $uuid, + 'name' => $playerData['name'], + ], + 'inventory' => $mainInventory, + 'armor' => $armor, + 'offhand' => $offhand, + 'enderChest' => $enderChest, + 'location' => $location, + 'stats' => $stats, + 'debug' => [ + 'allSlots' => $allSlots, + 'nbtKeys' => $nbtKeys, + 'equipment' => $equipmentDebug, + ], + ]); + } catch (\Throwable $e) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Failed to parse player data: ' . $e->getMessage(), + ], 500); + } + } + + /** + * Get the world directory name. + */ + private function getWorldDirectory(Server $server): ?string + { + return Cache::remember("minecraftserver:worlddir:{$server->id}", 60, function () use ($server) { + $properties = $this->getServerProperties($server); + $levelName = $properties['level-name'] ?? 'world'; + + // Check if the directory exists + try { + $this->fileRepository->setServer($server)->getDirectory("/{$levelName}"); + return $levelName; + } catch (\Throwable $e) { + // Try common alternatives + $alternatives = ['world', 'server', 'minecraft']; + foreach ($alternatives as $alt) { + try { + $this->fileRepository->setServer($server)->getDirectory("/{$alt}"); + return $alt; + } catch (\Throwable $e) { + continue; + } + } + } + + return null; + }); + } + + /** + * Get a specific attribute for a player. + */ + public function getAttribute(PlayerReadRequest $request, Server $server, string $player, string $attribute): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + // Validate attribute name + $attribute = $this->sanitizeAttributeName($attribute); + if (!$attribute) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Invalid attribute name', + ], 400); + } + + try { + // First check if attributes are supported + $version = Cache::get("minecraftserver:version:{$server->id}"); + if ($version && !$version['supportsAttributes']) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Attributes require Minecraft 1.16 or higher', + ], 400); + } + + // Use data get to retrieve attribute value via data command + $cmd = "data get entity {$name} Attributes"; + $this->commandRepository->setServer($server)->send($cmd); + + // Since we can't read command output directly, we'll return the available attributes + return new JsonResponse([ + 'success' => true, + 'message' => 'Attribute command sent. Check server console for result.', + 'attribute' => $attribute, + ]); + } catch (\Throwable $e) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Server is offline', + ], 400); + } + } + + /** + * Set an attribute value for a player. + */ + public function setAttribute(AttributeRequest $request, Server $server, string $player, string $attribute): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + // Validate attribute name + $attribute = $this->sanitizeAttributeName($attribute); + if (!$attribute) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Invalid attribute name', + ], 400); + } + + $value = $request->input('value'); + if (!is_numeric($value)) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Value must be a number', + ], 400); + } + + // Clamp value to reasonable range + $value = max(-1024, min(1024, (float) $value)); + + try { + // Check if attributes are supported + $version = Cache::get("minecraftserver:version:{$server->id}"); + if ($version && !$version['supportsAttributes']) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Attributes require Minecraft 1.16 or higher', + ], 400); + } + + $cmd = "attribute {$name} minecraft:{$attribute} base set {$value}"; + $this->commandRepository->setServer($server)->send($cmd); + + Activity::event('server:player.attribute.set') + ->property(['name' => $name, 'attribute' => $attribute, 'value' => $value]) + ->log(); + + return new JsonResponse([ + 'success' => true, + 'attribute' => $attribute, + 'value' => $value, + ]); + } catch (\Throwable $e) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Server is offline', + ], 400); + } + } + + /** + * Reset an attribute to its default value. + */ + public function resetAttribute(PlayerRequest $request, Server $server, string $player, string $attribute): JsonResponse + { + $this->checkExtensionEnabled($server); + + try { + $name = $this->sanitizePlayerName($player); + } catch (\InvalidArgumentException $e) { + return new JsonResponse([ + 'success' => false, + 'error' => $e->getMessage(), + ], 400); + } + + // Validate attribute name + $attribute = $this->sanitizeAttributeName($attribute); + if (!$attribute) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Invalid attribute name', + ], 400); + } + + try { + // Check if attributes are supported + $version = Cache::get("minecraftserver:version:{$server->id}"); + if ($version && !$version['supportsAttributes']) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Attributes require Minecraft 1.16 or higher', + ], 400); + } + + // Use the attribute reset command (1.20+) or set to default + $defaultValue = $this->getAttributeDefault($attribute); + $cmd = "attribute {$name} minecraft:{$attribute} base set {$defaultValue}"; + $this->commandRepository->setServer($server)->send($cmd); + + Activity::event('server:player.attribute.reset') + ->property(['name' => $name, 'attribute' => $attribute]) + ->log(); + + return new JsonResponse([ + 'success' => true, + 'attribute' => $attribute, + 'defaultValue' => $defaultValue, + ]); + } catch (\Throwable $e) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Server is offline', + ], 400); + } + } + + /** + * Get all available attributes with their metadata. + */ + public function getAttributes(GetStatusRequest $request, Server $server): JsonResponse + { + $this->checkExtensionEnabled($server); + + // Check if attributes are supported + $version = Cache::get("minecraftserver:version:{$server->id}"); + if ($version && !$version['supportsAttributes']) { + return new JsonResponse([ + 'success' => false, + 'error' => 'Attributes require Minecraft 1.16 or higher', + ], 400); + } + + return new JsonResponse([ + 'success' => true, + 'attributes' => $this->getAttributeList($version), + ]); + } + + /** + * Sanitize and validate attribute name. + */ + private function sanitizeAttributeName(string $attribute): ?string + { + // Remove minecraft: prefix if present + $attribute = str_replace('minecraft:', '', $attribute); + + // Only allow alphanumeric and underscores + if (!preg_match('/^[a-zA-Z0-9_]+$/', $attribute)) { + return null; + } + + // Validate against known player attributes (without generic. or player. prefixes) + // Note: flying_speed, follow_range, tempt_range, spawn_reinforcements are mob-only + $validAttributes = [ + // Base attributes (1.16+) + 'max_health', 'knockback_resistance', + 'movement_speed', 'attack_damage', + 'attack_knockback', 'attack_speed', 'armor', + 'armor_toughness', 'luck', + // Player specific (1.20.5+) + 'block_interaction_range', 'entity_interaction_range', + 'block_break_speed', 'mining_efficiency', 'sneaking_speed', + 'submerged_mining_speed', 'sweeping_damage_ratio', + // 1.21+ attributes + 'scale', 'step_height', 'gravity', + 'safe_fall_distance', 'fall_damage_multiplier', + 'jump_strength', 'oxygen_bonus', + 'burning_time', 'explosion_knockback_resistance', + 'water_movement_efficiency', + ]; + + if (!in_array($attribute, $validAttributes)) { + return null; + } + + return $attribute; + } + + /** + * Get default value for an attribute. + */ + private function getAttributeDefault(string $attribute): float + { + $defaults = [ + 'max_health' => 20.0, + 'knockback_resistance' => 0.0, + 'movement_speed' => 0.1, + 'attack_damage' => 1.0, + 'attack_knockback' => 0.0, + 'attack_speed' => 4.0, + 'armor' => 0.0, + 'armor_toughness' => 0.0, + 'luck' => 0.0, + 'scale' => 1.0, + 'step_height' => 0.6, + 'gravity' => 0.08, + 'safe_fall_distance' => 3.0, + 'fall_damage_multiplier' => 1.0, + 'jump_strength' => 0.42, + 'oxygen_bonus' => 0.0, + 'burning_time' => 1.0, + 'explosion_knockback_resistance' => 0.0, + 'water_movement_efficiency' => 0.0, + 'block_interaction_range' => 4.5, + 'entity_interaction_range' => 3.0, + 'block_break_speed' => 1.0, + 'mining_efficiency' => 0.0, + 'sneaking_speed' => 0.3, + 'submerged_mining_speed' => 0.2, + 'sweeping_damage_ratio' => 0.0, + ]; + + return $defaults[$attribute] ?? 0.0; + } + + /** + * Get list of all attributes with metadata. + */ + private function getAttributeList(?array $version): array + { + $minor = $version['minor'] ?? 20; + + $attributes = [ + [ + 'category' => 'Health & Defense', + 'attributes' => [ + ['id' => 'max_health', 'name' => 'Max Health', 'default' => 20.0, 'min' => 1, 'max' => 1024, 'description' => 'Maximum health points'], + ['id' => 'armor', 'name' => 'Armor', 'default' => 0.0, 'min' => 0, 'max' => 30, 'description' => 'Armor points'], + ['id' => 'armor_toughness', 'name' => 'Armor Toughness', 'default' => 0.0, 'min' => 0, 'max' => 20, 'description' => 'Reduces armor penetration'], + ['id' => 'knockback_resistance', 'name' => 'Knockback Resistance', 'default' => 0.0, 'min' => 0, 'max' => 1, 'description' => 'Chance to resist knockback (0-1)'], + ], + ], + [ + 'category' => 'Combat', + 'attributes' => [ + ['id' => 'attack_damage', 'name' => 'Attack Damage', 'default' => 1.0, 'min' => 0, 'max' => 2048, 'description' => 'Base melee damage'], + ['id' => 'attack_speed', 'name' => 'Attack Speed', 'default' => 4.0, 'min' => 0, 'max' => 1024, 'description' => 'Attack cooldown recovery speed'], + ['id' => 'attack_knockback', 'name' => 'Attack Knockback', 'default' => 0.0, 'min' => 0, 'max' => 5, 'description' => 'Knockback dealt on attack'], + ], + ], + [ + 'category' => 'Movement', + 'attributes' => [ + ['id' => 'movement_speed', 'name' => 'Movement Speed', 'default' => 0.1, 'min' => 0, 'max' => 1024, 'description' => 'Walking/running speed'], + ], + ], + [ + 'category' => 'Miscellaneous', + 'attributes' => [ + ['id' => 'luck', 'name' => 'Luck', 'default' => 0.0, 'min' => -1024, 'max' => 1024, 'description' => 'Affects loot table quality'], + ], + ], + ]; + + // Add 1.20.5+ attributes + if ($minor >= 20) { + $attributes[] = [ + 'category' => 'Player Reach (1.20.5+)', + 'attributes' => [ + ['id' => 'block_interaction_range', 'name' => 'Block Interaction Range', 'default' => 4.5, 'min' => 0, 'max' => 64, 'description' => 'How far you can interact with blocks'], + ['id' => 'entity_interaction_range', 'name' => 'Entity Interaction Range', 'default' => 3.0, 'min' => 0, 'max' => 64, 'description' => 'How far you can interact with entities'], + ['id' => 'block_break_speed', 'name' => 'Block Break Speed', 'default' => 1.0, 'min' => 0, 'max' => 1024, 'description' => 'Mining speed multiplier'], + ['id' => 'mining_efficiency', 'name' => 'Mining Efficiency', 'default' => 0.0, 'min' => 0, 'max' => 1024, 'description' => 'Additional mining speed'], + ['id' => 'sneaking_speed', 'name' => 'Sneaking Speed', 'default' => 0.3, 'min' => 0, 'max' => 1, 'description' => 'Speed while sneaking (0-1)'], + ['id' => 'submerged_mining_speed', 'name' => 'Underwater Mining Speed', 'default' => 0.2, 'min' => 0, 'max' => 20, 'description' => 'Mining speed multiplier underwater'], + ], + ]; + } + + // Add 1.21+ attributes + if ($minor >= 21) { + $attributes[] = [ + 'category' => 'Physics (1.21+)', + 'attributes' => [ + ['id' => 'scale', 'name' => 'Scale', 'default' => 1.0, 'min' => 0.0625, 'max' => 16, 'description' => 'Entity size multiplier'], + ['id' => 'step_height', 'name' => 'Step Height', 'default' => 0.6, 'min' => 0, 'max' => 10, 'description' => 'Max height that can be stepped up'], + ['id' => 'gravity', 'name' => 'Gravity', 'default' => 0.08, 'min' => -1, 'max' => 1, 'description' => 'Gravity strength'], + ['id' => 'safe_fall_distance', 'name' => 'Safe Fall Distance', 'default' => 3.0, 'min' => -1024, 'max' => 1024, 'description' => 'Distance before fall damage'], + ['id' => 'fall_damage_multiplier', 'name' => 'Fall Damage Multiplier', 'default' => 1.0, 'min' => 0, 'max' => 100, 'description' => 'Fall damage multiplier'], + ['id' => 'jump_strength', 'name' => 'Jump Strength', 'default' => 0.42, 'min' => 0, 'max' => 32, 'description' => 'Jump power'], + ['id' => 'oxygen_bonus', 'name' => 'Oxygen Bonus', 'default' => 0.0, 'min' => 0, 'max' => 1024, 'description' => 'Extra breath time underwater'], + ['id' => 'burning_time', 'name' => 'Burning Time', 'default' => 1.0, 'min' => 0, 'max' => 1024, 'description' => 'Fire damage duration multiplier'], + ['id' => 'explosion_knockback_resistance', 'name' => 'Explosion Knockback Resistance', 'default' => 0.0, 'min' => 0, 'max' => 1, 'description' => 'Resistance to explosion knockback (0-1)'], + ['id' => 'water_movement_efficiency', 'name' => 'Water Movement Efficiency', 'default' => 0.0, 'min' => 0, 'max' => 1, 'description' => 'Movement speed in water (0-1)'], + ], + ]; + } + + return $attributes; + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/CustomDomainController.php b/app/Http/Controllers/Api/Client/Servers/CustomDomainController.php new file mode 100644 index 0000000000..39b86f713d --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/CustomDomainController.php @@ -0,0 +1,130 @@ +customDomains()->with('customDomain')->orderByDesc('id')->get()->map(function ($row) { + $dnsRecords = (array) ($row->dns_records ?? []); + $hasSrv = collect($dnsRecords)->contains(fn ($record) => ($record['kind'] ?? null) === 'srv'); + $hostType = collect($dnsRecords)->firstWhere('kind', 'host')['type'] ?? null; + + return [ + 'id' => $row->id, + 'domain_id' => $row->custom_domain_id, + 'domain' => $row->customDomain?->domain, + 'subdomain' => $row->subdomain, + 'full_domain' => $row->full_domain, + 'port' => $row->port, + 'protocol' => $row->protocol, + 'service_tag' => $row->service_tag, + 'record_type' => $hasSrv ? 'srv' : 'cname', + 'host_record_type' => $hostType, + 'status' => $row->status, + 'last_error' => $row->last_error, + 'last_synced_at' => $row->last_synced_at, + ]; + })->values(); + + return response()->json(['data' => $records]); + } + + public function store(StoreCustomDomainRequest $request, Server $server): JsonResponse + { + $domainId = (int) $request->input('domain_id'); + $subdomain = strtolower((string) $request->input('subdomain')); + $port = (int) $request->input('port'); + $protocol = (string) $request->input('protocol', 'both'); + $recordType = $request->filled('record_type') ? strtolower((string) $request->input('record_type')) : null; + $serviceTag = $request->filled('service_tag') ? strtolower((string) $request->input('service_tag')) : null; + + $this->service->createFromPayload($server, [[ + 'domain_id' => $domainId, + 'subdomain' => $subdomain, + 'port' => $port, + 'protocol' => $protocol, + 'record_type' => $recordType, + 'service_tag' => $serviceTag, + ]]); + + $mapping = $server->customDomains() + ->where('custom_domain_id', $domainId) + ->where('subdomain', $subdomain) + ->where('port', $port) + ->where('protocol', $protocol) + ->latest() + ->first(); + + if ($mapping) { + ProvisionCustomDomainRecordJob::dispatch($mapping->id); + } else { + ProvisionServerCustomDomainsJob::dispatch($server->id); + } + + return response()->json([], JsonResponse::HTTP_CREATED); + } + + public function options(GetCustomDomainsRequest $request, Server $server): JsonResponse + { + $recommendation = $this->service->getDnsRecommendationForServer($server); + + $domains = collect($this->service->getAvailableDomains($server))->map(function ($domain) use ($server) { + return [ + 'id' => $domain->id, + 'domain' => $domain->domain, + 'wildcard_enabled' => $domain->wildcard_enabled, + 'default_service_tag' => $this->service->resolveSuggestedServiceTag($server, $domain), + ]; + })->map(function (array $domain) use ($recommendation) { + return array_merge($domain, [ + 'recommended_record_type' => $recommendation['recommended_record_type'], + 'srv_supported' => $recommendation['srv_supported'], + 'allow_record_type_selection' => $recommendation['allow_record_type_selection'], + 'forced_record_type' => $recommendation['forced_record_type'], + 'dns_mode' => $recommendation['mode'], + 'recommendation_notice' => $recommendation['notice'], + 'connection_hint' => $recommendation['connection_hint'], + ]); + })->values(); + + return response()->json(['data' => $domains]); + } + + public function destroy(DeleteCustomDomainRequest $request, Server $server, ServerCustomDomain $customDomain): JsonResponse + { + if ($customDomain->server_id !== $server->id) { + abort(404); + } + + $this->service->cleanup($customDomain); + $customDomain->delete(); + + return response()->json([], JsonResponse::HTTP_NO_CONTENT); + } + + public function sync(SyncCustomDomainsRequest $request, Server $server): JsonResponse + { + ProvisionServerCustomDomainsJob::dispatch($server->id); + + return response()->json(['message' => 'Custom domain provisioning has been queued.']); + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/FileController.php b/app/Http/Controllers/Api/Client/Servers/FileController.php index d46162fd22..9336018115 100644 --- a/app/Http/Controllers/Api/Client/Servers/FileController.php +++ b/app/Http/Controllers/Api/Client/Servers/FileController.php @@ -2,6 +2,7 @@ namespace Everest\Http\Controllers\Api\Client\Servers; +use Everest\Exceptions\DisplayException; use Everest\Models\Server; use Carbon\CarbonImmutable; use Everest\Facades\Activity; @@ -27,6 +28,66 @@ class FileController extends ClientApiController { + private function isArchivePathSegment(string $segment): bool + { + $lower = strtolower($segment); + + foreach ([ + '.zip', + '.7z', + '.ddup', + '.tar', + '.tar.gz', + '.tgz', + '.tar.xz', + '.txz', + '.tar.zst', + '.tzst', + '.tar.lz4', + '.tlz4', + '.tar.bz2', + '.tbz2', + '.gz', + '.xz', + '.zst', + '.lz4', + '.bz2', + ] as $extension) { + if (str_ends_with($lower, $extension)) { + return true; + } + } + + return false; + } + + private function isArchiveReadOnlyPath(string $path): bool + { + $segments = array_values(array_filter(explode('/', str_replace('\\\\', '/', trim($path))), fn (string $segment) => $segment !== '')); + + if (count($segments) === 0) { + return false; + } + + foreach ($segments as $segment) { + if ($this->isArchivePathSegment($segment)) { + return true; + } + } + + return false; + } + + /** + * @throws \Everest\Exceptions\DisplayException + */ + private function guardArchiveWritePath(string $path): void + { + if ($this->isArchiveReadOnlyPath($path)) { + throw new DisplayException('You cannot write to a file inside an archive. Extract it first.'); + } + } + /** * FileController constructor. */ @@ -109,6 +170,8 @@ public function download(GetFileContentsRequest $request, Server $server): array */ public function write(WriteFileContentRequest $request, Server $server): JsonResponse { + $this->guardArchiveWritePath($request->get('file')); + $this->fileRepository->setServer($server)->putContent($request->get('file'), $request->getContent()); Activity::event('server:file.write')->property('file', $request->get('file'))->log(); @@ -128,6 +191,8 @@ public function writeWithDiff(WriteFileWithDiffRequest $request, Server $server) $content = $request->input('content'); $originalContent = $request->input('original_content', ''); + $this->guardArchiveWritePath($file); + // Write the new content to the file $this->fileRepository->setServer($server)->putContent($file, $content); diff --git a/app/Http/Controllers/Api/Client/Servers/ModsController.php b/app/Http/Controllers/Api/Client/Servers/ModsController.php index ea41be704e..12eca4f94f 100644 --- a/app/Http/Controllers/Api/Client/Servers/ModsController.php +++ b/app/Http/Controllers/Api/Client/Servers/ModsController.php @@ -8,6 +8,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; @@ -29,6 +30,20 @@ class ModsController extends ClientApiController { + /** + * Directories to skip when scanning for installed addons. + * These contain internal/remapped files that are not real user plugins. + */ + private const IGNORED_DIRECTORIES = [ + '.paper-remapped', + '.paper-remapped-cp', + ]; + + /** + * Cache TTL for installed addons scan (in seconds). + */ + private const INSTALLED_CACHE_TTL = 300; + /** * ModsController constructor. */ @@ -130,11 +145,14 @@ public function installed(GetInstalledAddonsRequest $request, Server $server): J $perPage = (int) $request->input('perPage', 50); $page = (int) $request->input('page', 1); - $items = $this->scanJarDirectory( - $server, - $type === 'plugins' ? '/plugins' : '/mods', - $type === 'plugins' ? 'plugin' : 'mod' - ); + $cacheKey = "server:{$server->uuid}:installed:{$type}"; + $items = Cache::remember($cacheKey, self::INSTALLED_CACHE_TTL, function () use ($server, $type) { + return $this->scanJarDirectory( + $server, + $type === 'plugins' ? '/plugins' : '/mods', + $type === 'plugins' ? 'plugin' : 'mod' + ); + }); $filtered = array_values(array_filter($items, function (array $item) use ($status, $search) { if ($status === 'enabled' && !$item['enabled']) { @@ -195,6 +213,9 @@ public function toggleInstalledAddon(ToggleInstalledAddonRequest $request, Serve $this->fileRepository->setServer($server)->renameFiles($root, [['from' => $from, 'to' => $target]]); } + // Invalidate cache after toggle. + $this->invalidateInstalledCache($server, $type); + $items = $this->scanJarDirectory($server, $basePath, $type === 'plugins' ? 'plugin' : 'mod'); $updatedPath = $this->joinPath($root, $target); $updated = collect($items)->firstWhere('path', $updatedPath); @@ -341,6 +362,9 @@ public function downloadMod(DownloadModRequest $request, Server $server, string $type = $resource === 'plugins' || in_array($source, ['spiget', 'spigot'], true) ? 'plugin' : 'mod'; $result = $this->pluginInstallService->installFromProvider($server, $source, $type, $modId, $fileId); + // Invalidate cache after successful download. + $this->invalidateInstalledCache($server, $type === 'plugin' ? 'plugins' : 'mods'); + return response()->json($result); } catch (ModsServiceException $e) { return response()->json([ @@ -714,6 +738,9 @@ public function downloadModpack(DownloadModRequest $request, Server $server, int // Clean up temporary files $this->deleteDirectory($tempDir); + // Invalidate cache after modpack install. + $this->invalidateInstalledCache($server, 'mods'); + return response()->json([ 'success' => true, 'message' => 'Modpack downloaded and installed successfully.', @@ -746,49 +773,39 @@ public function downloadModpack(DownloadModRequest $request, Server $server, int private function scanJarDirectory(Server $server, string $path, string $type): array { $results = []; - $queue = [$this->normalizePath($path)]; + $normalized = $this->normalizePath($path); + $entries = $this->listDirectorySafely($server, $normalized); - while (!empty($queue)) { - $current = array_shift($queue); - $entries = $this->listDirectorySafely($server, $current); + if ($entries === null) { + return $results; + } - if ($entries === null) { + foreach ($entries as $entry) { + $name = Arr::get($entry, 'name'); + if (!$name || $name === '.' || $name === '..') { continue; } - foreach ($entries as $entry) { - $name = Arr::get($entry, 'name'); - if (!$name || $name === '.' || $name === '..') { - continue; - } - - $isFile = (bool) Arr::get($entry, 'file', true); - $isSymlink = (bool) Arr::get($entry, 'symlink', false); - $fullPath = $this->joinPath($current, $name); - - if ($isFile && $this->isJarLike($name)) { - $friendlyName = $this->makeFriendlyName($name); - $isEnabled = !$this->isDisabledFile($name); - $results[] = [ - 'filename' => $name, - 'friendly_name' => $friendlyName, - 'path' => $fullPath, - 'size_bytes' => (int) Arr::get($entry, 'size', 0), - 'modified_at' => $this->formatTimestamp(Arr::get($entry, 'modified')), - 'type' => $type, - 'enabled' => $isEnabled, - // Legacy keys for backward compatibility (can be removed once frontend is migrated) - 'name' => $name, - 'display_name' => $this->stripDisabledSuffix($name), - 'size' => (int) Arr::get($entry, 'size', 0), - 'disabled' => !$isEnabled, - ]; - continue; - } - - if (!$isFile && !$isSymlink) { - $queue[] = $fullPath; - } + $isFile = (bool) Arr::get($entry, 'file', true); + + if ($isFile && $this->isJarLike($name)) { + $fullPath = $this->joinPath($normalized, $name); + $friendlyName = $this->makeFriendlyName($name); + $isEnabled = !$this->isDisabledFile($name); + $results[] = [ + 'filename' => $name, + 'friendly_name' => $friendlyName, + 'path' => $fullPath, + 'size_bytes' => (int) Arr::get($entry, 'size', 0), + 'modified_at' => $this->formatTimestamp(Arr::get($entry, 'modified')), + 'type' => $type, + 'enabled' => $isEnabled, + // Legacy keys for backward compatibility (can be removed once frontend is migrated) + 'name' => $name, + 'display_name' => $this->stripDisabledSuffix($name), + 'size' => (int) Arr::get($entry, 'size', 0), + 'disabled' => !$isEnabled, + ]; } } @@ -834,6 +851,14 @@ private function friendlyNameValue(array $item): string return $item['friendly_name'] ?: $item['filename']; } + /** + * Invalidate the installed addons cache for a server. + */ + private function invalidateInstalledCache(Server $server, string $type): void + { + Cache::forget("server:{$server->uuid}:installed:{$type}"); + } + private function listDirectorySafely(Server $server, string $path): ?array { try { diff --git a/app/Http/Controllers/Api/Client/Servers/WingsRsController.php b/app/Http/Controllers/Api/Client/Servers/WingsRsController.php new file mode 100644 index 0000000000..6503962d73 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/WingsRsController.php @@ -0,0 +1,274 @@ + $server->node->isSupercharged(), + 'wings_type' => $server->node->wings_type, + 'wings_version' => $server->node->wings_version, + ]); + } + + /** + * GET /api/client/servers/{server}/files/fingerprints — Get file checksums. + */ + public function fingerprints(Request $request, Server $server): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_FILE_READ, $server)) { + throw new AuthorizationException(); + } + + if (!$server->node->isSupercharged()) { + return new JsonResponse(['error' => 'Feature requires a Supercharged node.'], 400); + } + + $request->validate([ + 'files' => 'required|array|min:1|max:50', + 'files.*' => 'required|string|max:1024', + 'algorithm' => 'string|in:sha256,sha512,md5,blake3', + ]); + + $data = $this->wingsRsRepository + ->setServer($server) + ->getFingerprints( + $request->input('files'), + $request->input('algorithm', 'sha256') + ); + + return new JsonResponse($data); + } + + /** + * POST /api/client/servers/{server}/files/search — Advanced file search. + */ + public function searchFiles(Request $request, Server $server): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_FILE_READ, $server)) { + throw new AuthorizationException(); + } + + if (!$server->node->isSupercharged()) { + return new JsonResponse(['error' => 'Feature requires a Supercharged node.'], 400); + } + + $request->validate([ + 'root' => 'nullable|string|max:1024', + 'per_page' => 'required|integer|min:1|max:100', + 'path_filter' => 'nullable|array', + 'path_filter.include' => 'required_with:path_filter|array|max:20', + 'path_filter.include.*' => 'string|max:512', + 'path_filter.exclude' => 'nullable|array|max:20', + 'path_filter.exclude.*' => 'string|max:512', + 'path_filter.case_insensitive' => 'nullable|boolean', + 'size_filter' => 'nullable|array', + 'size_filter.min' => 'nullable|integer|min:0', + 'size_filter.max' => 'required_with:size_filter|integer|min:0', + 'content_filter' => 'nullable|array', + 'content_filter.query' => 'required_with:content_filter|string|max:1024', + 'content_filter.max_search_size' => 'required_with:content_filter|integer|min:0|max:104857600', + 'content_filter.include_unmatched' => 'nullable|boolean', + 'content_filter.case_insensitive' => 'nullable|boolean', + ]); + + $data = $this->wingsRsRepository + ->setServer($server) + ->searchFiles($request->only([ + 'root', 'per_page', 'path_filter', 'size_filter', 'content_filter', + ])); + + return new JsonResponse($data); + } + + /** + * POST /api/client/servers/{server}/files/compress-advanced — Compress with format selection. + */ + public function compressAdvanced(Request $request, Server $server): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_FILE_ARCHIVE, $server)) { + throw new AuthorizationException(); + } + + if (!$server->node->isSupercharged()) { + return new JsonResponse(['error' => 'Feature requires a Supercharged node.'], 400); + } + + $request->validate([ + 'root' => 'nullable|string|max:1024', + 'files' => 'required|array|min:1|max:200', + 'files.*' => 'required|string|max:1024', + 'format' => 'nullable|string|in:tar,tar_gz,tar_xz,tar_lzip,tar_bz2,tar_lz4,tar_zstd,zip,seven_zip', + 'name' => 'nullable|string|max:255', + 'foreground' => 'nullable|boolean', + ]); + + $data = $this->wingsRsRepository + ->setServer($server) + ->compressFiles( + $request->input('root'), + $request->input('files'), + $request->input('format'), + $request->input('name'), + $request->boolean('foreground', true) + ); + + return new JsonResponse($data, isset($data['identifier']) ? 202 : 200); + } + + /** + * DELETE /api/client/servers/{server}/files/operations/{operation} — Cancel operation. + */ + public function cancelOperation(Request $request, Server $server, string $operation): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_FILE_UPDATE, $server)) { + throw new AuthorizationException(); + } + + if (!$server->node->isSupercharged()) { + return new JsonResponse(['error' => 'Feature requires a Supercharged node.'], 400); + } + + // Validate that the operation ID is a safe UUID-like token before forwarding to the daemon. + if (!preg_match('/^[a-zA-Z0-9\-]{1,64}$/', $operation)) { + return new JsonResponse(['error' => 'Invalid operation identifier.'], 422); + } + + $this->wingsRsRepository->setServer($server)->cancelOperation($operation); + + return new JsonResponse(['success' => true]); + } + + /** + * POST /api/client/servers/{server}/script — Run async script. + * + * Requires the dedicated script.run permission (not startup.update) because this + * allows arbitrary container image selection and script execution — far beyond + * editing startup environment variables. + */ + public function runScript(Request $request, Server $server): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_SCRIPT_RUN, $server)) { + throw new AuthorizationException(); + } + + if (!$server->node->isSupercharged()) { + return new JsonResponse(['error' => 'Feature requires a Supercharged node.'], 400); + } + + $request->validate([ + 'container_image' => 'required|string|max:191', + 'entrypoint' => 'required|string|max:191', + 'script' => 'required|string|max:65535', + 'environment' => 'nullable|array|max:50', + 'environment.*' => 'nullable|string|max:1024', + ]); + + $data = $this->wingsRsRepository + ->setServer($server) + ->runScript( + $request->input('container_image'), + $request->input('entrypoint'), + $request->input('script'), + $request->input('environment', []) + ); + + return new JsonResponse($data); + } + + /** + * POST /api/client/servers/{server}/install/abort — Abort running installation. + */ + public function abortInstall(Request $request, Server $server): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_SETTINGS_REINSTALL, $server)) { + throw new AuthorizationException(); + } + + if (!$server->node->isSupercharged()) { + return new JsonResponse(['error' => 'Feature requires a Supercharged node.'], 400); + } + + $this->wingsRsRepository->setServer($server)->abortInstall(); + + return new JsonResponse(['success' => true], 202); + } + + /** + * GET /api/client/servers/{server}/logs/install — Get install logs. + */ + public function installLogs(Request $request, Server $server): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_CONTROL_CONSOLE, $server)) { + throw new AuthorizationException(); + } + + if (!$server->node->isSupercharged()) { + return new JsonResponse(['error' => 'Feature requires a Supercharged node.'], 400); + } + + $lines = (int) $request->query('lines', 100); + $lines = max(1, min($lines, 5000)); + + try { + $content = $this->wingsRsRepository->setServer($server)->getInstallLogs($lines); + } catch (DaemonConnectionException $exception) { + if ($exception->getStatusCode() === 404) { + return new JsonResponse(['content' => []]); + } + + throw $exception; + } + + return new JsonResponse(['content' => $content]); + } + + /** + * GET /api/client/servers/{server}/ssh — Get SSH connection instructions. + */ + public function sshInfo(Request $request, Server $server): JsonResponse + { + if (!$request->user()->can(Permission::ACTION_FILE_SFTP, $server)) { + throw new AuthorizationException(); + } + + $node = $server->node; + $user = $request->user(); + + return new JsonResponse([ + 'host' => $node->fqdn, + 'port' => $node->public_port_sftp, + 'username' => $user->username . '.' . $server->uuidShort, + 'command' => sprintf( + 'ssh %s.%s@%s -p %d', + $user->username, + $server->uuidShort, + $node->fqdn, + $node->public_port_sftp + ), + 'supercharged' => $node->isSupercharged(), + 'shell_available' => $node->isSupercharged(), + 'shell_help_command' => '.wings help', + ]); + } +} diff --git a/app/Http/Controllers/Api/Remote/ActivityProcessingController.php b/app/Http/Controllers/Api/Remote/ActivityProcessingController.php index e5df408fc4..473b98363b 100644 --- a/app/Http/Controllers/Api/Remote/ActivityProcessingController.php +++ b/app/Http/Controllers/Api/Remote/ActivityProcessingController.php @@ -82,6 +82,8 @@ public function __invoke(ActivityEventRequest $request) foreach ($logs as $key => $data) { Assert::isInstanceOf($server = $servers->get($key), Server::class); + $data = $this->coalesceSftpEvents($data); + $batch = []; foreach ($data as $datum) { $id = ActivityLog::insertGetId($datum); @@ -95,4 +97,103 @@ public function __invoke(ActivityEventRequest $request) ActivityLogSubject::insert($batch); } } + + /** + * Coalesce rapid SFTP create+write+rename sequences into single upload events. + * + * SFTP clients typically upload a file by: creating a temp file, writing to it, + * then renaming it to the final name. This produces 3 activity log entries for + * what the user perceives as a single upload. We collapse these sequences into + * a single "sftp.create" event carrying the final filename. + */ + private function coalesceSftpEvents(array $events): array + { + // Group SFTP events by actor within a tight time window. + // Rename events referencing a temp-created file absorb the create+write. + $sftpCreate = []; + $sftpWrite = []; + $absorbed = []; + + foreach ($events as $idx => $event) { + $e = $event['event'] ?? ''; + $actorId = $event['actor_id'] ?? null; + + $props = is_string($event['properties'] ?? null) + ? json_decode($event['properties'], true) + : ($event['properties'] ?? []); + + $files = $props['files'] ?? []; + + if ($e === 'server:sftp.create' && $actorId !== null) { + foreach ((array) $files as $file) { + $name = is_array($file) ? ($file['to'] ?? $file[0] ?? '') : (string) $file; + if ($name !== '') { + $sftpCreate[$actorId . ':' . $name] = $idx; + } + } + } + + if ($e === 'server:sftp.write' && $actorId !== null) { + foreach ((array) $files as $file) { + $name = is_array($file) ? ($file['to'] ?? $file[0] ?? '') : (string) $file; + if ($name !== '') { + $sftpWrite[$actorId . ':' . $name] = $idx; + } + } + } + } + + // Now scan rename events: if a rename's "from" matches a created temp file, + // rewrite the create event with the final name and drop the write + rename. + foreach ($events as $idx => $event) { + $e = $event['event'] ?? ''; + $actorId = $event['actor_id'] ?? null; + + if ($e !== 'server:sftp.rename' || $actorId === null) { + continue; + } + + $props = is_string($event['properties'] ?? null) + ? json_decode($event['properties'], true) + : ($event['properties'] ?? []); + + $files = $props['files'] ?? []; + + foreach ((array) $files as $file) { + $from = is_array($file) ? ($file['from'] ?? '') : ''; + $to = is_array($file) ? ($file['to'] ?? '') : ''; + + if ($from === '' || $to === '') { + continue; + } + + $createKey = $actorId . ':' . $from; + $writeKey = $actorId . ':' . $from; + + if (isset($sftpCreate[$createKey])) { + $createIdx = $sftpCreate[$createKey]; + + // Rewrite the create event to reference the final filename. + $createProps = is_string($events[$createIdx]['properties'] ?? null) + ? json_decode($events[$createIdx]['properties'], true) + : ($events[$createIdx]['properties'] ?? []); + + $createProps['files'] = [$to]; + $events[$createIdx]['properties'] = json_encode($createProps); + + // Mark write and rename events for removal. + if (isset($sftpWrite[$writeKey])) { + $absorbed[$sftpWrite[$writeKey]] = true; + } + $absorbed[$idx] = true; + } + } + } + + if (empty($absorbed)) { + return $events; + } + + return array_values(array_filter($events, fn ($_, $i) => !isset($absorbed[$i]), ARRAY_FILTER_USE_BOTH)); + } } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 83d1e15aec..5930e8dfaf 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -103,5 +103,6 @@ class Kernel extends HttpKernel 'bindings' => SubstituteBindings::class, 'captcha' => VerifyTurnstile::class, 'node.maintenance' => MaintenanceMiddleware::class, + 'extensions.access' => \Everest\Http\Middleware\Api\Client\Extensions\EnsureExtensionAccess::class, ]; } diff --git a/app/Http/Middleware/Api/Client/Extensions/EnsureExtensionAccess.php b/app/Http/Middleware/Api/Client/Extensions/EnsureExtensionAccess.php new file mode 100644 index 0000000000..e8eecad526 --- /dev/null +++ b/app/Http/Middleware/Api/Client/Extensions/EnsureExtensionAccess.php @@ -0,0 +1,49 @@ +user(); + + $server = $request->route()?->parameter('server'); + if (!$server instanceof Server) { + return response('', 404); + } + + $config = ExtensionConfig::getByExtensionId($extensionId); + if (!$config || !$config->isServerEligible($server)) { + return response('', 404); + } + + if ($user->root_admin || $server->owner_id === $user->id) { + return $next($request); + } + + $subuser = Subuser::query() + ->where('user_id', $user->id) + ->where('server_id', $server->id) + ->first(); + + if ($subuser && in_array($extensionId, $subuser->disabled_extensions ?? [], true)) { + return response()->json([ + 'error' => 'This extension has been disabled for your account by the server owner.', + ], 403); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/Api/Client/Server/ResourceBelongsToServer.php b/app/Http/Middleware/Api/Client/Server/ResourceBelongsToServer.php index 6c91af8d1f..58bac5374a 100644 --- a/app/Http/Middleware/Api/Client/Server/ResourceBelongsToServer.php +++ b/app/Http/Middleware/Api/Client/Server/ResourceBelongsToServer.php @@ -9,6 +9,7 @@ use Everest\Models\Subuser; use Everest\Models\Database; use Everest\Models\Schedule; +use Everest\Models\ServerCustomDomain; use Illuminate\Http\Request; use Everest\Models\Allocation; use Illuminate\Database\Eloquent\Model; @@ -52,6 +53,7 @@ public function handle(Request $request, \Closure $next): mixed case Database::class: case Schedule::class: case Subuser::class: + case ServerCustomDomain::class: if ($model->server_id !== $server->id) { throw $exception; } diff --git a/app/Http/Requests/Api/Application/Billing/CustomDomains/DeleteCustomDomainRequest.php b/app/Http/Requests/Api/Application/Billing/CustomDomains/DeleteCustomDomainRequest.php new file mode 100644 index 0000000000..0df6479e09 --- /dev/null +++ b/app/Http/Requests/Api/Application/Billing/CustomDomains/DeleteCustomDomainRequest.php @@ -0,0 +1,14 @@ + ['required', 'string', 'max:191', 'regex:/^(?!-)[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/'], + 'cloudflare_zone_id' => 'nullable|string|max:191', + 'api_key_id' => 'nullable|integer|exists:custom_domain_api_keys,id', + 'allowed_nest_ids' => 'nullable|array', + 'allowed_nest_ids.*' => 'integer|exists:nests,id', + 'allowed_egg_ids' => 'nullable|array', + 'allowed_egg_ids.*' => 'integer|exists:eggs,id', + 'service_tag' => ['nullable', 'string', 'max:100', 'regex:/^(_?[a-z0-9][a-z0-9-]*|_[a-z0-9][a-z0-9-]*\._(?:tcp|udp)?|_[a-z0-9][a-z0-9-]*\._)$/i'], + 'egg_service_tags' => 'nullable|array', + 'egg_service_tags.*' => ['nullable', 'string', 'max:100', 'regex:/^(_?[a-z0-9][a-z0-9-]*|_[a-z0-9][a-z0-9-]*\._(?:tcp|udp)?|_[a-z0-9][a-z0-9-]*\._)$/i'], + 'wildcard_enabled' => 'sometimes|boolean', + 'enabled' => 'sometimes|boolean', + ]; + } +} diff --git a/app/Http/Requests/Api/Application/Billing/CustomDomains/UpdateCustomDomainRequest.php b/app/Http/Requests/Api/Application/Billing/CustomDomains/UpdateCustomDomainRequest.php new file mode 100644 index 0000000000..2abb18d4d2 --- /dev/null +++ b/app/Http/Requests/Api/Application/Billing/CustomDomains/UpdateCustomDomainRequest.php @@ -0,0 +1,7 @@ + 'required|string|max:191|unique:custom_domain_api_keys,name', + 'token' => 'required|string|min:20|max:500', + 'enabled' => 'sometimes|boolean', + ]; + } +} diff --git a/app/Http/Requests/Api/Application/CustomDomains/UpdateCustomDomainApiKeyRequest.php b/app/Http/Requests/Api/Application/CustomDomains/UpdateCustomDomainApiKeyRequest.php new file mode 100644 index 0000000000..67795a292a --- /dev/null +++ b/app/Http/Requests/Api/Application/CustomDomains/UpdateCustomDomainApiKeyRequest.php @@ -0,0 +1,18 @@ +route('apiKey'); + $id = $apiKey?->id ?? 'NULL'; + + return [ + 'name' => 'sometimes|required|string|max:191|unique:custom_domain_api_keys,name,' . $id, + 'token' => 'nullable|string|min:20|max:500', + 'enabled' => 'sometimes|boolean', + ]; + } +} diff --git a/app/Http/Requests/Api/Application/CustomDomains/UpdateCustomDomainSettingsRequest.php b/app/Http/Requests/Api/Application/CustomDomains/UpdateCustomDomainSettingsRequest.php new file mode 100644 index 0000000000..d181102754 --- /dev/null +++ b/app/Http/Requests/Api/Application/CustomDomains/UpdateCustomDomainSettingsRequest.php @@ -0,0 +1,26 @@ + ['sometimes', 'nullable', 'string', 'min:20', 'max:500'], + 'allow_wildcard' => ['required', 'boolean'], + 'max_wildcards_per_user' => ['required', 'integer', 'min:1', 'max:100'], + 'rate_limit_create_per_minute' => ['required', 'integer', 'min:1', 'max:1000'], + 'rate_limit_sync_per_minute' => ['required', 'integer', 'min:1', 'max:1000'], + 'rate_limit_billing_options_per_minute' => ['required', 'integer', 'min:1', 'max:2000'], + ]; + } +} diff --git a/app/Http/Requests/Api/Application/Extensions/BatchInstallExtensionRequest.php b/app/Http/Requests/Api/Application/Extensions/BatchInstallExtensionRequest.php new file mode 100644 index 0000000000..6214cd0ae0 --- /dev/null +++ b/app/Http/Requests/Api/Application/Extensions/BatchInstallExtensionRequest.php @@ -0,0 +1,18 @@ + 'required|array|min:1|max:50', + 'extensions.*.extension_id' => 'required|string|max:191', + 'extensions.*.repository_id' => 'required|integer|exists:extension_repositories,id', + 'extensions.*.version' => 'nullable|string|max:191', + ]; + } +} diff --git a/app/Http/Requests/Api/Application/Extensions/BatchUninstallExtensionRequest.php b/app/Http/Requests/Api/Application/Extensions/BatchUninstallExtensionRequest.php new file mode 100644 index 0000000000..4f433c0b2f --- /dev/null +++ b/app/Http/Requests/Api/Application/Extensions/BatchUninstallExtensionRequest.php @@ -0,0 +1,16 @@ + 'required|array|min:1|max:50', + 'extension_ids.*' => 'required|string|max:191', + ]; + } +} diff --git a/app/Http/Requests/Api/Application/Extensions/BatchUpdateExtensionRequest.php b/app/Http/Requests/Api/Application/Extensions/BatchUpdateExtensionRequest.php new file mode 100644 index 0000000000..22efc0213b --- /dev/null +++ b/app/Http/Requests/Api/Application/Extensions/BatchUpdateExtensionRequest.php @@ -0,0 +1,18 @@ + 'required|array|min:1|max:50', + 'extensions.*.extension_id' => 'required|string|max:191', + 'extensions.*.repository_id' => 'required|integer|exists:extension_repositories,id', + 'extensions.*.version' => 'nullable|string|max:191', + ]; + } +} diff --git a/app/Http/Requests/Api/Application/Extensions/GetExtensionsRequest.php b/app/Http/Requests/Api/Application/Extensions/GetExtensionsRequest.php new file mode 100644 index 0000000000..02cb9b28e5 --- /dev/null +++ b/app/Http/Requests/Api/Application/Extensions/GetExtensionsRequest.php @@ -0,0 +1,13 @@ + 'required|integer|exists:extension_repositories,id', + 'version' => 'nullable|string|max:191', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Api/Application/Extensions/StoreExtensionRepositoryRequest.php b/app/Http/Requests/Api/Application/Extensions/StoreExtensionRepositoryRequest.php new file mode 100644 index 0000000000..3d307c3278 --- /dev/null +++ b/app/Http/Requests/Api/Application/Extensions/StoreExtensionRepositoryRequest.php @@ -0,0 +1,19 @@ + 'required|string|max:191', + 'manifest_url' => 'required|string|max:2048', + 'homepage_url' => 'nullable|string|max:2048', + 'enabled' => 'sometimes|boolean', + 'acknowledge_risk' => 'required|accepted', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Api/Application/Extensions/UninstallExtensionRequest.php b/app/Http/Requests/Api/Application/Extensions/UninstallExtensionRequest.php new file mode 100644 index 0000000000..32424bda90 --- /dev/null +++ b/app/Http/Requests/Api/Application/Extensions/UninstallExtensionRequest.php @@ -0,0 +1,13 @@ + 'sometimes|string|max:191', + 'manifest_url' => 'sometimes|string|max:2048', + 'homepage_url' => 'nullable|string|max:2048', + 'enabled' => 'sometimes|boolean', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Api/Application/Extensions/UpdateExtensionRequest.php b/app/Http/Requests/Api/Application/Extensions/UpdateExtensionRequest.php new file mode 100644 index 0000000000..339669b383 --- /dev/null +++ b/app/Http/Requests/Api/Application/Extensions/UpdateExtensionRequest.php @@ -0,0 +1,20 @@ + 'sometimes|boolean', + 'allowed_nests' => 'sometimes|array', + 'allowed_nests.*' => 'integer|exists:nests,id', + 'allowed_eggs' => 'sometimes|array', + 'allowed_eggs.*' => 'integer|exists:eggs,id', + 'settings' => 'sometimes|array', + ]; + } +} diff --git a/app/Http/Requests/Api/Application/Extensions/UpdateExtensionSettingsRequest.php b/app/Http/Requests/Api/Application/Extensions/UpdateExtensionSettingsRequest.php new file mode 100644 index 0000000000..9f45344ebc --- /dev/null +++ b/app/Http/Requests/Api/Application/Extensions/UpdateExtensionSettingsRequest.php @@ -0,0 +1,16 @@ + 'required|string|max:191', + 'value' => 'present', + ]; + } +} diff --git a/app/Http/Requests/Api/Application/Nodes/WingsRsNodeReadRequest.php b/app/Http/Requests/Api/Application/Nodes/WingsRsNodeReadRequest.php new file mode 100644 index 0000000000..087a6ad747 --- /dev/null +++ b/app/Http/Requests/Api/Application/Nodes/WingsRsNodeReadRequest.php @@ -0,0 +1,14 @@ + $rules['backup_limit'], 'feature_limits.databases' => $rules['database_limit'], 'feature_limits.subusers' => $rules['subuser_limit'], + 'feature_limits.subdomains' => $rules['subdomain_limit'], 'allocation.default' => 'required|bail|integer|exists:allocations,id', 'allocation.additional.*' => 'integer|exists:allocations,id', @@ -87,6 +88,10 @@ public function validated($key = null, $default = null) 'start_on_completion' => array_get($data, 'start_on_completion', false), ]; + if (Arr::has($data, 'feature_limits.subdomains')) { + $response['subdomain_limit'] = array_get($data, 'feature_limits.subdomains'); + } + return is_null($key) ? $response : Arr::get($response, $key, $default); } diff --git a/app/Http/Requests/Api/Application/Servers/UpdateServerRequest.php b/app/Http/Requests/Api/Application/Servers/UpdateServerRequest.php index 746c8fcfde..f1f702979d 100644 --- a/app/Http/Requests/Api/Application/Servers/UpdateServerRequest.php +++ b/app/Http/Requests/Api/Application/Servers/UpdateServerRequest.php @@ -33,6 +33,7 @@ public function rules(): array 'feature_limits.backups' => $rules['backup_limit'], 'feature_limits.databases' => $rules['database_limit'], 'feature_limits.subusers' => $rules['subuser_limit'], + 'feature_limits.subdomains' => $rules['subdomain_limit'], 'renewal_date' => $rules['renewal_date'], 'billing_product_id' => $rules['billing_product_id'], @@ -83,6 +84,10 @@ public function validated($key = null, $default = null) 'remove_allocations' => array_get($data, 'remove_allocations'), ]; + if (Arr::has($data, 'feature_limits.subdomains')) { + $response['subdomain_limit'] = array_get($data, 'feature_limits.subdomains'); + } + return is_null($key) ? $response : Arr::get($response, $key, $default); } diff --git a/app/Http/Requests/Api/Application/Servers/WingsRsServerReadRequest.php b/app/Http/Requests/Api/Application/Servers/WingsRsServerReadRequest.php new file mode 100644 index 0000000000..da9ae28cd8 --- /dev/null +++ b/app/Http/Requests/Api/Application/Servers/WingsRsServerReadRequest.php @@ -0,0 +1,14 @@ +route()->parameter('server'); + + return $this->user()->can(Permission::ACTION_FILE_UPDATE, $server) + && $this->user()->can(Permission::ACTION_FILE_READ_CONTENT, $server); + } + + public function rules(): array + { + return [ + 'channel_id' => 'required|string|regex:/^\d{10,25}$/', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperInstallRequest.php b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperInstallRequest.php new file mode 100644 index 0000000000..27f73b0cf3 --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperInstallRequest.php @@ -0,0 +1,33 @@ +route()->parameter('server'); + + return $this->user()->can(Permission::ACTION_FILE_CREATE, $server) + && $this->user()->can(Permission::ACTION_FILE_UPDATE, $server); + } + + public function rules(): array + { + return [ + 'jar_url' => 'sometimes|nullable|url', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperOwnerRequest.php b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperOwnerRequest.php new file mode 100644 index 0000000000..60c38c7f96 --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperOwnerRequest.php @@ -0,0 +1,35 @@ +route()->parameter('server'); + $user = $this->user(); + + if (!$server instanceof \Everest\Models\Server) { + return false; + } + + if (!$user->root_admin && $user->id !== $server->owner_id) { + return false; + } + + return parent::authorize(); + } + + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperStatusRequest.php b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperStatusRequest.php new file mode 100644 index 0000000000..fbac00232f --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperStatusRequest.php @@ -0,0 +1,29 @@ +route()->parameter('server'); + return $this->user()->can(Permission::ACTION_FILE_READ, $server); + } + + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperSubuserAccessRequest.php b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperSubuserAccessRequest.php new file mode 100644 index 0000000000..7633b6b03e --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperSubuserAccessRequest.php @@ -0,0 +1,13 @@ + 'required|boolean', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperTokenRequest.php b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperTokenRequest.php new file mode 100644 index 0000000000..a6437e6129 --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/DiscordSrvHelper/DiscordSrvHelperTokenRequest.php @@ -0,0 +1,34 @@ +route()->parameter('server'); + + return $this->user()->can(Permission::ACTION_FILE_CREATE, $server) + && $this->user()->can(Permission::ACTION_FILE_UPDATE, $server) + && $this->user()->can(Permission::ACTION_FILE_READ_CONTENT, $server); + } + + public function rules(): array + { + return [ + 'token' => 'required|string|min:30|max:200', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/GetServerExtensionsRequest.php b/app/Http/Requests/Api/Client/Extensions/GetServerExtensionsRequest.php new file mode 100644 index 0000000000..7e2821a3ef --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/GetServerExtensionsRequest.php @@ -0,0 +1,19 @@ + 'required|numeric', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/PlayerManager/BanIpRequest.php b/app/Http/Requests/Api/Client/Extensions/PlayerManager/BanIpRequest.php new file mode 100644 index 0000000000..aef69d67f9 --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/PlayerManager/BanIpRequest.php @@ -0,0 +1,21 @@ + 'required|string|min:3|max:255', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/PlayerManager/BanRequest.php b/app/Http/Requests/Api/Client/Extensions/PlayerManager/BanRequest.php new file mode 100644 index 0000000000..c329c35900 --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/PlayerManager/BanRequest.php @@ -0,0 +1,21 @@ + 'required|string|min:3|max:255', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/PlayerManager/GetStatusRequest.php b/app/Http/Requests/Api/Client/Extensions/PlayerManager/GetStatusRequest.php new file mode 100644 index 0000000000..81d6ec5742 --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/PlayerManager/GetStatusRequest.php @@ -0,0 +1,19 @@ + 'sometimes|string|max:255', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/PlayerManager/PlayerNamedRequest.php b/app/Http/Requests/Api/Client/Extensions/PlayerManager/PlayerNamedRequest.php new file mode 100644 index 0000000000..11912bedfd --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/PlayerManager/PlayerNamedRequest.php @@ -0,0 +1,21 @@ + 'sometimes|integer|min:1|max:4', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/PlayerManager/PlayerReadRequest.php b/app/Http/Requests/Api/Client/Extensions/PlayerManager/PlayerReadRequest.php new file mode 100644 index 0000000000..1ea410a2ab --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/PlayerManager/PlayerReadRequest.php @@ -0,0 +1,19 @@ + 'required|boolean', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Extensions/PlayerManager/WhisperRequest.php b/app/Http/Requests/Api/Client/Extensions/PlayerManager/WhisperRequest.php new file mode 100644 index 0000000000..048dd0a1b2 --- /dev/null +++ b/app/Http/Requests/Api/Client/Extensions/PlayerManager/WhisperRequest.php @@ -0,0 +1,21 @@ + 'required|string|min:1|max:255', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Servers/CustomDomains/DeleteCustomDomainRequest.php b/app/Http/Requests/Api/Client/Servers/CustomDomains/DeleteCustomDomainRequest.php new file mode 100644 index 0000000000..35510db9d3 --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/CustomDomains/DeleteCustomDomainRequest.php @@ -0,0 +1,14 @@ + 'required|integer|exists:custom_domains,id', + 'subdomain' => ['required', 'string', 'max:191', 'regex:/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i'], + 'port' => 'required|integer|min:1|max:65535', + 'protocol' => 'required|in:tcp,udp,both', + 'record_type' => 'nullable|in:srv,cname', + 'service_tag' => ['nullable', 'string', 'max:100', 'regex:/^(_?[a-z0-9][a-z0-9-]*|_[a-z0-9][a-z0-9-]*\._(?:tcp|udp)?|_[a-z0-9][a-z0-9-]*\._)$/i'], + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Servers/CustomDomains/SyncCustomDomainsRequest.php b/app/Http/Requests/Api/Client/Servers/CustomDomains/SyncCustomDomainsRequest.php new file mode 100644 index 0000000000..ea170a5f2f --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/CustomDomains/SyncCustomDomainsRequest.php @@ -0,0 +1,14 @@ + config('modules.mods.rate_limit.requests_per_hour', 1800), ], ], + 'extensions' => [ + 'enabled' => boolval(config('modules.extensions.enabled', false)), + 'available' => $this->getAvailableExtensions(), + ], ]); } + /** + * Get the list of available extensions with their enabled status. + */ + private function getAvailableExtensions(): array + { + $extensions = config('modules.extensions.available', []); + + if (!is_array($extensions)) { + return []; + } + + $availableExtensions = []; + + foreach ($extensions as $id => $extension) { + if (!is_array($extension)) { + continue; + } + $availableExtensions[$id] = [ + 'name' => $extension['name'] ?? $id, + 'description' => $extension['description'] ?? '', + 'icon' => $extension['icon'] ?? 'puzzle', + 'version' => $extension['version'] ?? '1.0.0', + ]; + } + + return $availableExtensions; + } + private function emailEnabled(): bool { return EmailManager::isDeliveryEnabled(); diff --git a/app/Jobs/CustomDomains/CleanupServerCustomDomainsJob.php b/app/Jobs/CustomDomains/CleanupServerCustomDomainsJob.php new file mode 100644 index 0000000000..774c28b077 --- /dev/null +++ b/app/Jobs/CustomDomains/CleanupServerCustomDomainsJob.php @@ -0,0 +1,36 @@ +with('customDomain') + ->where('server_id', $this->serverId) + ->get(); + + foreach ($mappings as $mapping) { + $service->cleanup($mapping); + $mapping->delete(); + } + } +} diff --git a/app/Jobs/CustomDomains/ProvisionCustomDomainRecordJob.php b/app/Jobs/CustomDomains/ProvisionCustomDomainRecordJob.php new file mode 100644 index 0000000000..011859d043 --- /dev/null +++ b/app/Jobs/CustomDomains/ProvisionCustomDomainRecordJob.php @@ -0,0 +1,33 @@ +with(['customDomain', 'server.node', 'allocation'])->find($this->mappingId); + if (!$mapping) { + return; + } + + $service->provision($mapping); + } +} diff --git a/app/Jobs/CustomDomains/ProvisionServerCustomDomainsJob.php b/app/Jobs/CustomDomains/ProvisionServerCustomDomainsJob.php new file mode 100644 index 0000000000..05e174188b --- /dev/null +++ b/app/Jobs/CustomDomains/ProvisionServerCustomDomainsJob.php @@ -0,0 +1,35 @@ +with('customDomains.customDomain')->find($this->serverId); + if (!$server) { + return; + } + + foreach ($server->customDomains as $mapping) { + $service->provision($mapping); + } + } +} diff --git a/app/Models/Billing/Order.php b/app/Models/Billing/Order.php index 6fb26d1742..5549d8814b 100644 --- a/app/Models/Billing/Order.php +++ b/app/Models/Billing/Order.php @@ -22,6 +22,7 @@ * @property int|null $node_id * @property int|null $server_id * @property array|null $variables + * @property array|null $domain_payload * @property string $type * @property int $threat_index * @property string $payment_intent_id @@ -67,6 +68,7 @@ class Order extends Model 'name', 'user_id', 'description', 'payment_intent_id', 'payment_processor', 'mollie_payment_id', 'paypal_order_id', 'paypal_capture_id', 'paypal_payer_id', 'paypal_payer_email', 'paypal_status', 'paypal_amount', 'paypal_currency', 'paypal_captured_at', 'payment_token', 'total', 'status', 'product_id', 'billing_days', 'final_price', 'multiplier_used', 'node_multiplier_used', 'egg_id', 'node_id', 'server_id', 'variables', 'type', 'threat_index', + 'domain_payload', 'coupon_id', 'subtotal', 'discount', ]; @@ -85,6 +87,7 @@ class Order extends Model 'node_id' => 'int', 'server_id' => 'int', 'variables' => 'array', + 'domain_payload' => 'array', 'threat_index' => 'int', 'coupon_id' => 'int', 'subtotal' => 'float', @@ -101,6 +104,7 @@ class Order extends Model 'status' => 'required|in:expired,pending,failed,processed', 'product_id' => 'exists:products,id', 'egg_id' => 'nullable|exists:eggs,id', + 'domain_payload' => 'nullable|array', 'type' => 'required|in:new,upg,ren', 'threat_index' => 'nullable|int|min:-1|max:100', 'payment_intent_id' => 'required|string|unique:orders,payment_intent_id', diff --git a/app/Models/Billing/Product.php b/app/Models/Billing/Product.php index 59b1c7d656..075076003f 100644 --- a/app/Models/Billing/Product.php +++ b/app/Models/Billing/Product.php @@ -22,6 +22,7 @@ * @property int $backup_limit * @property int $database_limit * @property int $allocation_limit + * @property int|null $subdomain_limit * @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $updated_at */ @@ -45,7 +46,7 @@ class Product extends Model 'uuid', 'category_uuid', 'name', 'icon', 'price', 'base_price', 'description', 'cpu_limit', 'memory_limit', 'disk_limit', - 'backup_limit', 'database_limit', 'allocation_limit', + 'backup_limit', 'database_limit', 'allocation_limit', 'subdomain_limit', ]; /** @@ -60,6 +61,7 @@ class Product extends Model 'backup_limit' => 'integer', 'database_limit' => 'integer', 'allocation_limit' => 'integer', + 'subdomain_limit' => 'integer', ]; public static array $validationRules = [ @@ -78,6 +80,7 @@ class Product extends Model 'backup_limit' => 'required|integer', 'database_limit' => 'required|integer', 'allocation_limit' => 'required|integer', + 'subdomain_limit' => 'nullable|integer|min:0', ]; /** diff --git a/app/Models/CustomDomain.php b/app/Models/CustomDomain.php new file mode 100644 index 0000000000..91e528a1b2 --- /dev/null +++ b/app/Models/CustomDomain.php @@ -0,0 +1,48 @@ + 'boolean', + 'enabled' => 'boolean', + 'allowed_nest_ids' => 'array', + 'allowed_egg_ids' => 'array', + 'egg_service_tags' => 'array', + ]; + + public static array $validationRules = [ + 'domain' => ['required', 'string', 'max:191', 'regex:/^(?!-)[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/'], + 'cloudflare_zone_id' => 'nullable|string|max:191', + 'api_key_id' => 'nullable|integer|exists:custom_domain_api_keys,id', + 'allowed_nest_ids' => 'nullable|array', + 'allowed_nest_ids.*' => 'integer|exists:nests,id', + 'allowed_egg_ids' => 'nullable|array', + 'allowed_egg_ids.*' => 'integer|exists:eggs,id', + 'service_tag' => ['nullable', 'string', 'max:100', 'regex:/^(_?[a-z0-9][a-z0-9-]*|_[a-z0-9][a-z0-9-]*\._(?:tcp|udp)?|_[a-z0-9][a-z0-9-]*\._)$/i'], + 'egg_service_tags' => 'nullable|array', + 'egg_service_tags.*' => ['nullable', 'string', 'max:100', 'regex:/^(_?[a-z0-9][a-z0-9-]*|_[a-z0-9][a-z0-9-]*\._(?:tcp|udp)?|_[a-z0-9][a-z0-9-]*\._)$/i'], + 'wildcard_enabled' => 'boolean', + 'enabled' => 'boolean', + ]; + + public function apiKey(): BelongsTo + { + return $this->belongsTo(CustomDomainApiKey::class, 'api_key_id'); + } + + public function serverDomains(): HasMany + { + return $this->hasMany(ServerCustomDomain::class); + } +} diff --git a/app/Models/CustomDomainApiKey.php b/app/Models/CustomDomainApiKey.php new file mode 100644 index 0000000000..fb9ef18d3d --- /dev/null +++ b/app/Models/CustomDomainApiKey.php @@ -0,0 +1,30 @@ + 'encrypted', + 'enabled' => 'boolean', + ]; + + public static array $validationRules = [ + 'name' => 'required|string|max:191', + 'token' => 'required|string|min:20|max:500', + 'enabled' => 'sometimes|boolean', + ]; + + public function customDomains(): HasMany + { + return $this->hasMany(CustomDomain::class, 'api_key_id'); + } +} diff --git a/app/Models/CustomDomainDnsLog.php b/app/Models/CustomDomainDnsLog.php new file mode 100644 index 0000000000..ca9b9d9bbb --- /dev/null +++ b/app/Models/CustomDomainDnsLog.php @@ -0,0 +1,30 @@ + 'integer', + 'server_custom_domain_id' => 'integer', + 'payload' => 'array', + ]; + + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + public function serverCustomDomain(): BelongsTo + { + return $this->belongsTo(ServerCustomDomain::class); + } +} diff --git a/app/Models/ExtensionConfig.php b/app/Models/ExtensionConfig.php new file mode 100644 index 0000000000..a6a897ba94 --- /dev/null +++ b/app/Models/ExtensionConfig.php @@ -0,0 +1,132 @@ + 'boolean', + 'allowed_nests' => 'array', + 'allowed_eggs' => 'array', + 'settings' => 'array', + ]; + + /** + * Validation rules for the model. + */ + public static array $validationRules = [ + 'extension_id' => 'required|string|max:191', + 'enabled' => 'boolean', + 'allowed_nests' => 'nullable|array', + 'allowed_eggs' => 'nullable|array', + 'settings' => 'nullable|array', + ]; + + /** + * Get the extension configuration by extension ID. + */ + public static function getByExtensionId(string $extensionId): ?self + { + return self::where('extension_id', $extensionId)->first(); + } + + /** + * Check if a server is eligible for an extension based on its egg. + */ + public function isServerEligible(Server $server): bool + { + if (!$this->enabled) { + return false; + } + + $allowedNests = $this->allowed_nests ?? []; + $allowedEggs = $this->allowed_eggs ?? []; + + // If no restrictions, extension is available for all servers + if (empty($allowedNests) && empty($allowedEggs)) { + return true; + } + + // Check if server's nest is in allowed nests + if (!empty($allowedNests) && in_array($server->nest_id, $allowedNests)) { + // If nest is allowed, check if we need to filter by eggs + if (empty($allowedEggs)) { + return true; + } + } + + // Check if server's egg is in allowed eggs + if (!empty($allowedEggs) && in_array($server->egg_id, $allowedEggs)) { + return true; + } + + return false; + } + + /** + * Get all enabled extensions for a server. + */ + public static function getEnabledForServer(Server $server): array + { + $configs = self::where('enabled', true)->get(); + $enabled = []; + + foreach ($configs as $config) { + if ($config->isServerEligible($server)) { + $enabled[] = $config; + } + } + + return $enabled; + } + + /** + * Create or update extension configuration. + */ + public static function updateOrCreateConfig(string $extensionId, array $data): self + { + return self::updateOrCreate( + ['extension_id' => $extensionId], + $data + ); + } +} diff --git a/app/Models/ExtensionFileSnapshot.php b/app/Models/ExtensionFileSnapshot.php new file mode 100644 index 0000000000..cf161b4585 --- /dev/null +++ b/app/Models/ExtensionFileSnapshot.php @@ -0,0 +1,44 @@ + 'int', + 'actor_id' => 'int', + 'files' => 'array', + ]; + + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + public function actor(): BelongsTo + { + return $this->belongsTo(User::class, 'actor_id'); + } +} diff --git a/app/Models/ExtensionPackage.php b/app/Models/ExtensionPackage.php new file mode 100644 index 0000000000..626dc06dd1 --- /dev/null +++ b/app/Models/ExtensionPackage.php @@ -0,0 +1,65 @@ + 'array', + 'installed_at' => 'datetime', + ]; + + public function repository(): BelongsTo + { + return $this->belongsTo(ExtensionRepository::class, 'source_repository_id'); + } + + public function files(): HasMany + { + return $this->hasMany(ExtensionPackageFile::class, 'extension_package_id'); + } +} \ No newline at end of file diff --git a/app/Models/ExtensionPackageFile.php b/app/Models/ExtensionPackageFile.php new file mode 100644 index 0000000000..d3b6edf171 --- /dev/null +++ b/app/Models/ExtensionPackageFile.php @@ -0,0 +1,36 @@ +belongsTo(ExtensionPackage::class, 'extension_package_id'); + } +} \ No newline at end of file diff --git a/app/Models/ExtensionRepository.php b/app/Models/ExtensionRepository.php new file mode 100644 index 0000000000..bf3460c66e --- /dev/null +++ b/app/Models/ExtensionRepository.php @@ -0,0 +1,49 @@ + 'boolean', + 'is_official' => 'boolean', + 'risk_acknowledged_at' => 'datetime', + ]; + + public function getRouteKeyName(): string + { + return $this->getKeyName(); + } + + public function packages(): HasMany + { + return $this->hasMany(ExtensionPackage::class, 'source_repository_id'); + } +} \ No newline at end of file diff --git a/app/Models/Node.php b/app/Models/Node.php index fb6da6742b..b3b3581e68 100644 --- a/app/Models/Node.php +++ b/app/Models/Node.php @@ -41,6 +41,9 @@ * @property bool|null $deployable_free * @property int $servers_count * @property string|null $price_multiplier_description + * @property string $wings_type + * @property string|null $wings_version + * @property \Carbon\Carbon|null $wings_detected_at * @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $updated_at * @property Allocation[]|Collection $allocations @@ -67,6 +70,9 @@ class Node extends Model public const DAEMON_TOKEN_ID_LENGTH = 16; public const DAEMON_TOKEN_LENGTH = 64; + public const WINGS_TYPE_DEFAULT = 'default'; + public const WINGS_TYPE_RS = 'wings-rs'; + /** * The table associated with the model. */ @@ -95,6 +101,9 @@ class Node extends Model 'deployable_free' => 'boolean', 'price_multiplier' => 'float', 'price_multiplier_description' => 'string', + 'wings_type' => 'string', + 'wings_version' => 'string', + 'wings_detected_at' => 'datetime', ]; /** @@ -107,6 +116,7 @@ class Node extends Model 'memory', 'memory_overallocate', 'disk', 'disk_overallocate', 'upload_size', 'daemon_base', 'description', 'maintenance_mode', 'deployable', 'deployable_free', 'price_multiplier', 'price_multiplier_description', + 'wings_type', 'wings_version', 'wings_detected_at', ]; public static array $validationRules = [ @@ -147,8 +157,17 @@ class Node extends Model 'disk_overallocate' => 0, 'daemon_base' => self::DEFAULT_DAEMON_BASE, 'maintenance_mode' => false, + 'wings_type' => self::WINGS_TYPE_DEFAULT, ]; + /** + * Determine if this node is running Wings-RS (Supercharged). + */ + public function isSupercharged(): bool + { + return $this->wings_type === self::WINGS_TYPE_RS; + } + /** * Get the connection address to use when making calls to this node. */ diff --git a/app/Models/Permission.php b/app/Models/Permission.php index f9d44c1f18..4274962b6b 100644 --- a/app/Models/Permission.php +++ b/app/Models/Permission.php @@ -69,6 +69,11 @@ class Permission extends Model public const ACTION_BILLING_RENEW = 'billing.renew'; public const ACTION_BILLING_UPDATE = 'billing.update'; + public const ACTION_EXTENSION_READ = 'extension.read'; + public const ACTION_EXTENSION_MANAGE = 'extension.manage'; + + public const ACTION_SCRIPT_RUN = 'script.run'; + /** * Should timestamps be used on this model. */ @@ -219,6 +224,21 @@ class Permission extends Model 'update' => 'Update general billing settings for the server.', ], ], + + 'extension' => [ + 'description' => 'Permissions that control a user\'s access to server extensions like player managers.', + 'keys' => [ + 'read' => 'Allows a user to view and access enabled extensions for the server.', + 'manage' => 'Allows a user to use extension features like player management (kick, ban, whitelist, etc.). Includes read access.', + ], + ], + + 'script' => [ + 'description' => 'Permissions that control a user\'s ability to run async scripts on a Supercharged (Wings-RS) node.', + 'keys' => [ + 'run' => 'Allows a user to execute an async script inside a container on a Supercharged node. This is a powerful permission and should only be granted to trusted users.', + ], + ], ]; /** @@ -229,4 +249,18 @@ public static function permissions(): Collection { return Collection::make(self::$permissions); } + + /** + * Expands a permissions list with implied permissions. + */ + public static function expandPermissions(array $permissions): array + { + $expanded = $permissions; + + if (in_array(self::ACTION_EXTENSION_MANAGE, $expanded, true)) { + $expanded[] = self::ACTION_EXTENSION_READ; + } + + return array_values(array_unique($expanded)); + } } diff --git a/app/Models/Server.php b/app/Models/Server.php index 1b201d68f5..6532299f95 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -47,6 +47,7 @@ * @property int|null $database_limit * @property int $backup_limit * @property int $subuser_limit + * @property int|null $subdomain_limit * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $installed_at @@ -182,6 +183,7 @@ class Server extends Model 'allocation_limit' => 'sometimes|nullable|integer|min:0', 'backup_limit' => 'present|nullable|integer|min:0', 'subuser_limit' => 'nullable|integer|min:-1', + 'subdomain_limit' => 'nullable|integer|min:0', ]; /** @@ -244,6 +246,8 @@ public static function getRulesForUpdate($model, string $column = 'id'): array 'allocation_limit' => 'integer', 'backup_limit' => 'integer', 'subuser_limit' => 'integer', + 'subdomain_limit' => 'integer', + 'mods_enabled' => 'boolean', self::CREATED_AT => 'datetime', self::UPDATED_AT => 'datetime', 'deleted_at' => 'datetime', @@ -310,6 +314,14 @@ public function allocations(): HasMany return $this->hasMany(Allocation::class, 'server_id'); } + /** + * Gets all custom domain mappings associated with this server. + */ + public function customDomains(): HasMany + { + return $this->hasMany(ServerCustomDomain::class, 'server_id'); + } + /** * Gets information for the nest associated with this server. */ diff --git a/app/Models/ServerCustomDomain.php b/app/Models/ServerCustomDomain.php new file mode 100644 index 0000000000..877ee4eaa6 --- /dev/null +++ b/app/Models/ServerCustomDomain.php @@ -0,0 +1,55 @@ + 'integer', + 'allocation_id' => 'integer', + 'custom_domain_id' => 'integer', + 'port' => 'integer', + 'dns_records' => 'array', + 'last_synced_at' => 'datetime', + ]; + + public static array $validationRules = [ + 'server_id' => 'required|integer|exists:servers,id', + 'allocation_id' => 'nullable|integer|exists:allocations,id', + 'custom_domain_id' => 'required|integer|exists:custom_domains,id', + 'subdomain' => ['required', 'string', 'max:191', 'regex:/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i'], + 'full_domain' => ['required', 'string', 'max:191', 'regex:/^(?!-)[A-Za-z0-9.-]+$/'], + 'port' => 'required|integer|min:1|max:65535', + 'protocol' => 'required|in:tcp,udp,both', + 'record_type' => 'nullable|in:srv,cname', + 'service_tag' => ['nullable', 'string', 'max:100', 'regex:/^(_?[a-z0-9][a-z0-9-]*|_[a-z0-9][a-z0-9-]*\._(?:tcp|udp)?|_[a-z0-9][a-z0-9-]*\._)$/i'], + ]; + + public function getRouteKeyName(): string + { + return 'id'; + } + + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + public function allocation(): BelongsTo + { + return $this->belongsTo(Allocation::class); + } + + public function customDomain(): BelongsTo + { + return $this->belongsTo(CustomDomain::class); + } +} diff --git a/app/Models/Subuser.php b/app/Models/Subuser.php index 5a9a68d6ab..b87da20854 100644 --- a/app/Models/Subuser.php +++ b/app/Models/Subuser.php @@ -43,6 +43,7 @@ class Subuser extends Model 'user_id' => 'int', 'server_id' => 'int', 'permissions' => 'array', + 'disabled_extensions' => 'array', ]; public static array $validationRules = [ diff --git a/app/Observers/ServerObserver.php b/app/Observers/ServerObserver.php index a8e3bf78af..67bcae25c9 100644 --- a/app/Observers/ServerObserver.php +++ b/app/Observers/ServerObserver.php @@ -49,6 +49,10 @@ public function deleting(Server $server): void public function deleted(Server $server): void { event(new Events\Server\Deleted($server)); + + // Custom domain DNS cleanup is handled synchronously in ServerDeletionService::handle() + // before the server is deleted, ensuring DNS records are removed while the + // server_custom_domains rows still exist (before the cascadeOnDelete FK fires). } /** diff --git a/app/Policies/ServerPolicy.php b/app/Policies/ServerPolicy.php index 6799f3e4b7..85bb7237b7 100644 --- a/app/Policies/ServerPolicy.php +++ b/app/Policies/ServerPolicy.php @@ -4,6 +4,7 @@ use Everest\Models\User; use Everest\Models\Server; +use Everest\Models\Permission; class ServerPolicy { @@ -17,7 +18,9 @@ protected function checkPermission(User $user, Server $server, string $permissio return false; } - return in_array($permission, $subuser->permissions); + $permissions = Permission::expandPermissions($subuser->permissions ?? []); + + return in_array($permission, $permissions, true); } /** diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index a059f42ef4..73e23d57ef 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -124,6 +124,26 @@ protected function configureRateLimiting(): void return Limit::perMinutes(5, 20)->by($email !== '' ? $email : $request->ip()); }); + RateLimiter::for('custom-domains-create', function (Request $request) { + $key = optional($request->user())->uuid ?: $request->ip(); + $limit = max(1, (int) config('modules.custom_domains.rate_limits.create_per_minute', 10)); + + return Limit::perMinute($limit)->by($key); + }); + + RateLimiter::for('custom-domains-sync', function (Request $request) { + $key = optional($request->user())->uuid ?: $request->ip(); + $limit = max(1, (int) config('modules.custom_domains.rate_limits.sync_per_minute', 5)); + + return Limit::perMinute($limit)->by($key); + }); + + RateLimiter::for('custom-domains-billing-options', function (Request $request) { + $key = optional($request->user())->uuid ?: $request->ip(); + $limit = max(1, (int) config('modules.custom_domains.rate_limits.billing_options_per_minute', 20)); + + return Limit::perMinute($limit)->by($key); + }); RateLimiter::for('email-verification', function (Request $request) { $key = optional($request->user())->id ?: $request->ip(); @@ -161,6 +181,30 @@ protected function configureRateLimiting(): void ], 429); }); }); + + RateLimiter::for('wings-rs.search', function (Request $request) { + $key = optional($request->user())->uuid ?: $request->ip(); + + return Limit::perMinute(30)->by($key); + }); + + RateLimiter::for('wings-rs.compress', function (Request $request) { + $key = optional($request->user())->uuid ?: $request->ip(); + + return Limit::perMinute(10)->by($key); + }); + + RateLimiter::for('wings-rs.fingerprints', function (Request $request) { + $key = optional($request->user())->uuid ?: $request->ip(); + + return Limit::perMinute(30)->by($key); + }); + + RateLimiter::for('wings-rs.script', function (Request $request) { + $key = optional($request->user())->uuid ?: $request->ip(); + + return Limit::perMinute(5)->by($key); + }); } private function apiDocsMiddleware(): array diff --git a/app/Providers/SettingsServiceProvider.php b/app/Providers/SettingsServiceProvider.php index 8246ea086a..a60974f1f5 100644 --- a/app/Providers/SettingsServiceProvider.php +++ b/app/Providers/SettingsServiceProvider.php @@ -96,6 +96,17 @@ class SettingsServiceProvider extends ServiceProvider // Mods module settings 'modules:mods:enabled', 'modules:mods:curseforge_api_key', + + // Extensions module settings + 'modules:extensions:enabled', + + // Custom domains module settings + 'modules:custom_domains:cloudflare:token', + 'modules:custom_domains:security:allow_wildcard', + 'modules:custom_domains:security:max_wildcards_per_user', + 'modules:custom_domains:rate_limits:create_per_minute', + 'modules:custom_domains:rate_limits:sync_per_minute', + 'modules:custom_domains:rate_limits:billing_options_per_minute', ]; /** diff --git a/app/Repositories/Wings/DaemonFileRepository.php b/app/Repositories/Wings/DaemonFileRepository.php index 8eaba10a75..587894e90e 100644 --- a/app/Repositories/Wings/DaemonFileRepository.php +++ b/app/Repositories/Wings/DaemonFileRepository.php @@ -77,6 +77,32 @@ public function getDirectory(string $path): array { Assert::isInstanceOf($this->server, Server::class); + try { + $response = $this->getHttpClient()->get( + sprintf('/api/servers/%s/files/list', $this->server->uuid), + [ + 'query' => [ + 'directory' => $path, + 'ignored' => [], + 'per_page' => 10000, + 'page' => 1, + ], + ] + ); + + $data = json_decode($response->getBody()->__toString(), true); + + if (is_array($data) && isset($data['entries']) && is_array($data['entries'])) { + return $data['entries']; + } + + if (is_array($data)) { + return $data; + } + } catch (TransferException) { + // Fallback for legacy Wings versions that don't support /files/list. + } + try { $response = $this->getHttpClient()->get( sprintf('/api/servers/%s/files/list-directory', $this->server->uuid), diff --git a/app/Repositories/Wings/DaemonWingsRsRepository.php b/app/Repositories/Wings/DaemonWingsRsRepository.php new file mode 100644 index 0000000000..0f56522fd3 --- /dev/null +++ b/app/Repositories/Wings/DaemonWingsRsRepository.php @@ -0,0 +1,494 @@ +node, Node::class); + + if (!$this->node->isSupercharged()) { + throw new \RuntimeException('This operation requires a Supercharged (Wings-RS) node.'); + } + } + + // ─── System / Node-Level Endpoints ─────────────────────────────────── + + /** + * GET /api/system/overview — detailed system overview. + */ + public function getSystemOverview(): array + { + $this->assertSupercharged(); + + try { + $response = $this->getHttpClient()->get('/api/system/overview'); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return json_decode($response->getBody()->__toString(), true); + } + + /** + * GET /api/system/stats — real-time system statistics. + */ + public function getSystemStats(): array + { + $this->assertSupercharged(); + + try { + $response = $this->getHttpClient()->get('/api/system/stats'); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return json_decode($response->getBody()->__toString(), true); + } + + /** + * GET /api/system/logs — list log files. + */ + public function getSystemLogs(): array + { + $this->assertSupercharged(); + + try { + $response = $this->getHttpClient()->get('/api/system/logs'); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return json_decode($response->getBody()->__toString(), true); + } + + /** + * GET /api/system/logs/{file} — read a specific log file. + */ + public function getSystemLogContents(string $file, ?int $lines = null): string + { + $this->assertSupercharged(); + + try { + $params = []; + if ($lines !== null) { + $params['lines'] = $lines; + } + + $response = $this->getHttpClient()->get( + sprintf('/api/system/logs/%s', rawurlencode($file)), + ['query' => $params] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return $response->getBody()->__toString(); + } + + /** + * POST /api/system/upgrade — trigger Wings-RS self-upgrade. + * + * The restart mechanism is intentionally left to the daemon; this panel + * endpoint no longer forwards caller-supplied restart commands or custom + * download headers to prevent arbitrary command/header injection. + */ + public function upgradeSystem(string $url, string $sha256): void + { + $this->assertSupercharged(); + + try { + $this->getHttpClient()->post('/api/system/upgrade', [ + 'json' => [ + 'url' => $url, + 'sha256' => $sha256, + ], + ]); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + // ─── File Manager Enhancements ─────────────────────────────────────── + + /** + * GET /api/servers/{server}/files/list — paginated file listing (Wings-RS enhanced). + */ + public function getFileList(string $directory, array $ignored = [], int $perPage = 100, int $page = 1): array + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + try { + $response = $this->getHttpClient()->get( + sprintf('/api/servers/%s/files/list', $this->server->uuid), + [ + 'query' => [ + 'directory' => $directory, + 'ignored' => $ignored, + 'per_page' => $perPage, + 'page' => $page, + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return json_decode($response->getBody()->__toString(), true); + } + + /** + * GET /api/servers/{server}/files/fingerprints — file checksums. + */ + public function getFingerprints(array $files, string $algorithm = 'sha256'): array + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + try { + $response = $this->getHttpClient()->get( + sprintf('/api/servers/%s/files/fingerprints', $this->server->uuid), + [ + 'query' => [ + 'algorithm' => $algorithm, + 'files' => $files, + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return json_decode($response->getBody()->__toString(), true); + } + + /** + * POST /api/servers/{server}/files/search — advanced file search. + */ + public function searchFiles(array $params): array + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + try { + $response = $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/search', $this->server->uuid), + ['json' => $params] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return json_decode($response->getBody()->__toString(), true); + } + + /** + * POST /api/servers/{server}/files/compress — advanced compress with format and progress. + */ + public function compressFiles(?string $root, array $files, ?string $format = null, ?string $name = null, bool $foreground = true): array + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + $payload = [ + 'root' => $root ?? '/', + 'files' => $files, + 'foreground' => $foreground, + ]; + + if ($format !== null) { + $payload['format'] = $format; + } + if ($name !== null) { + $payload['name'] = $name; + } + + try { + $response = $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/compress', $this->server->uuid), + [ + 'json' => $payload, + 'timeout' => $foreground ? 60 * 15 : 30, + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return json_decode($response->getBody()->__toString(), true); + } + + /** + * DELETE /api/servers/{server}/files/operations/{operation} — cancel a running operation. + */ + public function cancelOperation(string $operationId): void + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + try { + $this->getHttpClient()->delete( + sprintf('/api/servers/%s/files/operations/%s', $this->server->uuid, $operationId) + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + // ─── Server Scripts ────────────────────────────────────────────────── + + /** + * POST /api/servers/{server}/script — run async scripts. + */ + public function runScript(string $containerImage, string $entrypoint, string $script, array $environment = []): array + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + $payload = [ + 'container_image' => $containerImage, + 'entrypoint' => $entrypoint, + 'script' => $script, + ]; + + if (!empty($environment)) { + $payload['environment'] = $environment; + } + + try { + $response = $this->getHttpClient()->post( + sprintf('/api/servers/%s/script', $this->server->uuid), + ['json' => $payload, 'timeout' => 60 * 30] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return json_decode($response->getBody()->__toString(), true); + } + + /** + * POST /api/servers/{server}/install/abort — abort a running installation. + */ + public function abortInstall(): void + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + try { + $this->getHttpClient()->post( + sprintf('/api/servers/%s/install/abort', $this->server->uuid) + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + /** + * GET /api/servers/{server}/logs/install — get install logs. + */ + public function getInstallLogs(?int $lines = 100): string + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + try { + $params = []; + if ($lines !== null) { + $params['lines'] = $lines; + } + + $response = $this->getHttpClient()->get( + sprintf('/api/servers/%s/logs/install', $this->server->uuid), + ['query' => $params] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return $response->getBody()->__toString(); + } + + /** + * GET /api/servers/{server}/logs — get server logs from Wings-RS. + */ + public function getServerLogs(?int $lines = 100): string + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + try { + $params = []; + if ($lines !== null) { + $params['lines'] = $lines; + } + + $response = $this->getHttpClient()->get( + sprintf('/api/servers/%s/logs', $this->server->uuid), + ['query' => $params] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return $response->getBody()->__toString(); + } + + // ─── WebSocket Enhancements ────────────────────────────────────────── + + /** + * POST /api/servers/{server}/ws/permissions — live permission updates. + */ + public function updateWsPermissions(array $userPermissions): void + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + try { + $this->getHttpClient()->post( + sprintf('/api/servers/%s/ws/permissions', $this->server->uuid), + ['json' => ['user_permissions' => $userPermissions]] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + /** + * POST /api/servers/{server}/ws/broadcast — broadcast message to connected users. + */ + public function broadcastMessage(array $users, array $permissions, string $event, array $args = []): void + { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + try { + $this->getHttpClient()->post( + sprintf('/api/servers/%s/ws/broadcast', $this->server->uuid), + [ + 'json' => [ + 'users' => $users, + 'permissions' => $permissions, + 'message' => [ + 'event' => $event, + 'args' => $args, + ], + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + // ─── Transfer Enhancements ─────────────────────────────────────────── + + /** + * POST /api/servers/{server}/transfer — enhanced transfer with archive format options. + */ + public function initiateTransfer( + string $url, + string $token, + ?string $archiveFormat = null, + ?string $compressionLevel = null, + array $backups = [], + bool $deleteBackups = false, + int $multiplexStreams = 0 + ): void { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + $payload = [ + 'url' => $url, + 'token' => $token, + ]; + + if ($archiveFormat !== null) { + $payload['archive_format'] = $archiveFormat; + } + if ($compressionLevel !== null) { + $payload['compression_level'] = $compressionLevel; + } + if (!empty($backups)) { + $payload['backups'] = $backups; + } + if ($deleteBackups) { + $payload['delete_backups'] = true; + } + if ($multiplexStreams > 0) { + $payload['multiplex_streams'] = $multiplexStreams; + } + + try { + $this->getHttpClient()->post( + sprintf('/api/servers/%s/transfer', $this->server->uuid), + ['json' => $payload] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } + + // ─── File Copy Remote ──────────────────────────────────────────────── + + /** + * POST /api/servers/{server}/files/copy-remote — copy files to another node. + */ + public function copyRemote( + string $url, + string $token, + array $files, + string $destinationServer, + string $destinationPath, + ?string $root = null, + ?string $archiveFormat = null, + ?string $compressionLevel = null, + bool $foreground = true + ): array { + $this->assertSupercharged(); + Assert::isInstanceOf($this->server, Server::class); + + $payload = [ + 'url' => $url, + 'token' => $token, + 'files' => $files, + 'destination_server' => $destinationServer, + 'destination_path' => $destinationPath, + 'foreground' => $foreground, + ]; + + if ($root !== null) { + $payload['root'] = $root; + } + if ($archiveFormat !== null) { + $payload['archive_format'] = $archiveFormat; + } + if ($compressionLevel !== null) { + $payload['compression_level'] = $compressionLevel; + } + + try { + $response = $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/copy-remote', $this->server->uuid), + ['json' => $payload, 'timeout' => $foreground ? 60 * 15 : 30] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + + return json_decode($response->getBody()->__toString(), true); + } +} diff --git a/app/Services/Billing/BillingConfigImportService.php b/app/Services/Billing/BillingConfigImportService.php index 165f19b526..08a907e7c1 100644 --- a/app/Services/Billing/BillingConfigImportService.php +++ b/app/Services/Billing/BillingConfigImportService.php @@ -72,6 +72,7 @@ public function handle(array $import_data, bool $ignore_duplicates): void 'backup_limit' => (int) $product['backup_limit'], 'database_limit' => (int) $product['database_limit'], 'allocation_limit' => (int) $product['allocation_limit'], + 'subdomain_limit' => array_key_exists('subdomain_limit', $product) ? (is_null($product['subdomain_limit']) ? null : (int) $product['subdomain_limit']) : null, 'category_uuid' => $category_id, // Correctly assign the new category ID 'stripe_id' => null, // deprecated ]); diff --git a/app/Services/Billing/BillingValidationService.php b/app/Services/Billing/BillingValidationService.php index c68bfc2d93..98e177e125 100644 --- a/app/Services/Billing/BillingValidationService.php +++ b/app/Services/Billing/BillingValidationService.php @@ -342,6 +342,15 @@ public function validatePlanDowngrade(Server $server, Product $newProduct): arra ]; } + $currentSubdomains = $server->customDomains()->count(); + if (is_null($server->subdomain_limit) && !is_null($newProduct->subdomain_limit) && $currentSubdomains > $newProduct->subdomain_limit) { + $violations['subdomains'] = [ + 'current' => $currentSubdomains, + 'limit' => $newProduct->subdomain_limit, + 'unit' => 'subdomains', + ]; + } + return $violations; } } diff --git a/app/Services/Billing/CreateOrderService.php b/app/Services/Billing/CreateOrderService.php index 53c5f08714..e5d0a1a694 100644 --- a/app/Services/Billing/CreateOrderService.php +++ b/app/Services/Billing/CreateOrderService.php @@ -58,6 +58,7 @@ public function create(?string $intent, User $user, Product $product, ?string $s $order->node_id = $nodeId; $order->server_id = $additionalData['server_id'] ?? null; $order->variables = $additionalData['variables'] ?? null; + $order->domain_payload = $additionalData['domain_payload'] ?? null; $order->type = $type; $order->payment_processor = $additionalData['payment_processor'] ?? 'stripe'; $order->mollie_payment_id = $additionalData['mollie_payment_id'] ?? null; diff --git a/app/Services/Billing/CreateServerService.php b/app/Services/Billing/CreateServerService.php index dbebdb5857..e876428c1c 100644 --- a/app/Services/Billing/CreateServerService.php +++ b/app/Services/Billing/CreateServerService.php @@ -116,6 +116,7 @@ public function process(Request $request, Product $product, object $metadata, Or 'backup_limit' => $product->backup_limit, 'allocation_limit' => $product->allocation_limit, 'subuser_limit' => 3, + 'subdomain_limit' => null, ]); } catch (BillingExceptionClass $e) { // Re-throw billing exceptions as-is diff --git a/app/Services/Billing/OrderProcessorService.php b/app/Services/Billing/OrderProcessorService.php index 4966ba1900..6820a10056 100644 --- a/app/Services/Billing/OrderProcessorService.php +++ b/app/Services/Billing/OrderProcessorService.php @@ -8,6 +8,8 @@ use Everest\Models\Billing\Order; use Everest\Models\Billing\Product; use Everest\Models\Billing\CouponUsage; +use Everest\Jobs\CustomDomains\ProvisionServerCustomDomainsJob; +use Everest\Services\CustomDomains\CustomDomainProvisioningService; /** * Unified order processing service for billing operations. @@ -27,6 +29,7 @@ public function __construct( private CreateOrderService $orderService, private CreateServerService $serverCreationService, private ServerRenewalService $renewalService, + private CustomDomainProvisioningService $customDomainProvisioning, ) { } @@ -45,6 +48,7 @@ public function __construct( * @param string|null $paymentIntentId The Stripe payment intent ID (for paid orders) * @param string|null $serverName The custom server name (optional) * @param int $billingDays The billing cycle days (defaults to 30) + * @param array $domainPayload Custom domain payload collected during checkout * * @return array{server: Server, order: Order} */ @@ -58,7 +62,8 @@ public function createServerOrder( array $variables = [], ?string $paymentIntentId = null, ?string $serverName = null, - int $billingDays = 30 + int $billingDays = 30, + array $domainPayload = [] ): array { // Create the order record $order = $this->orderService->create( @@ -69,7 +74,10 @@ public function createServerOrder( Order::TYPE_NEW, $couponId, $eggId, - ['billing_days' => $billingDays] + [ + 'billing_days' => $billingDays, + 'domain_payload' => $domainPayload, + ] ); // Create the server @@ -82,6 +90,9 @@ public function createServerOrder( $serverName ); + $this->customDomainProvisioning->syncFromOrder($server, $order); + ProvisionServerCustomDomainsJob::dispatch($server->id); + // Record coupon usage if applicable if ($couponId) { $this->recordCouponUsage($couponId, $user->id, $order->id); diff --git a/app/Services/Billing/PlanChangeService.php b/app/Services/Billing/PlanChangeService.php index 4cd2767558..fac90fbe4e 100644 --- a/app/Services/Billing/PlanChangeService.php +++ b/app/Services/Billing/PlanChangeService.php @@ -109,6 +109,10 @@ public function changePlan(Server $server, Product $newProduct, bool $force = fa 'allocation_limit' => $newProduct->allocation_limit, ]; + if (is_null($server->subdomain_limit)) { + $buildData['subdomain_limit'] = null; + } + return $this->buildModificationService->handle($server, $buildData); }); } @@ -133,6 +137,6 @@ private function isDowngrade(Server $server, Product $newProduct): bool $newProduct->cpu_limit < $server->cpu || $newProduct->database_limit < $server->database_limit || $newProduct->backup_limit < $server->backup_limit || - $newProduct->allocation_limit < $server->allocation_limit; + $newProduct->allocation_limit < $server->allocation_limit; } } diff --git a/app/Services/Billing/ServerFulfillmentService.php b/app/Services/Billing/ServerFulfillmentService.php index 83e44064b3..05a47dc546 100644 --- a/app/Services/Billing/ServerFulfillmentService.php +++ b/app/Services/Billing/ServerFulfillmentService.php @@ -10,6 +10,8 @@ use Illuminate\Support\Facades\Log; use Everest\Models\Billing\CouponUsage; use Everest\Exceptions\DisplayException; +use Everest\Jobs\CustomDomains\ProvisionServerCustomDomainsJob; +use Everest\Services\CustomDomains\CustomDomainProvisioningService; /** * Central server fulfillment service for paid orders. @@ -28,6 +30,7 @@ class ServerFulfillmentService public function __construct( private CreateServerService $serverCreation, private OrderProcessorService $processorService, + private CustomDomainProvisioningService $customDomainProvisioning, ) { } @@ -186,6 +189,9 @@ private function processNewServer(Request $request, Order $order, Product $produ // Create the server using the centralized creation service $server = $this->serverCreation->process($request, $product, $metadata, $order); + $this->customDomainProvisioning->syncFromOrder($server, $order); + ProvisionServerCustomDomainsJob::dispatch($server->id); + Log::info("Created new server {$server->id} for order {$order->id}"); return $server; diff --git a/app/Services/CustomDomains/CloudflareDnsService.php b/app/Services/CustomDomains/CloudflareDnsService.php new file mode 100644 index 0000000000..e273883874 --- /dev/null +++ b/app/Services/CustomDomains/CloudflareDnsService.php @@ -0,0 +1,301 @@ +normalizeToken($token); + + if ($token === '') { + throw new Exception('Cloudflare API token is not configured for custom domains.'); + } + + $retries = (int) config('modules.custom_domains.cloudflare.retries', 3); + $sleep = (int) config('modules.custom_domains.cloudflare.retry_sleep_ms', 250); + + return Http::retry($retries, $sleep) + ->acceptJson() + ->asJson() + ->withHeaders([ + 'Authorization' => 'Bearer ' . $token, + ]); + } + + private function normalizeToken(string $token): string + { + $normalized = trim($token); + + if ($normalized === '') { + return ''; + } + + $normalized = trim($normalized, "\"'"); + $normalized = preg_replace('/\s+/', '', $normalized) ?? ''; + + if (str_starts_with(strtolower($normalized), 'bearer')) { + $normalized = preg_replace('/^bearer/i', '', $normalized) ?? ''; + $normalized = trim($normalized); + } + + return $normalized; + } + + private function baseUrl(): string + { + return rtrim((string) config('modules.custom_domains.cloudflare.base_url', 'https://api.cloudflare.com/client/v4'), '/'); + } + + public function getZoneByName(string $domain, ?string $tokenOverride = null): ?array + { + try { + $response = $this->client($tokenOverride)->get($this->baseUrl() . '/zones', [ + 'name' => $domain, + 'status' => 'active', + 'match' => 'all', + ])->throw(); + } catch (RequestException $exception) { + throw new Exception($this->formatCloudflareError('Cloudflare zone lookup failed.', $exception)); + } + + $json = $response->json(); + + if (!($json['success'] ?? false)) { + return null; + } + + return Arr::first($json['result'] ?? []); + } + + public function createOrUpdateAOrCnameRecord( + string $zoneId, + string $name, + string $target, + ?string $tokenOverride = null, + ?string $forcedType = null, + ): array + { + $type = $forcedType !== null + ? strtoupper(trim($forcedType)) + : (filter_var($target, FILTER_VALIDATE_IP) ? 'A' : 'CNAME'); + + if (!in_array($type, ['A', 'CNAME'], true)) { + throw new Exception('Invalid DNS record type for host record.'); + } + + if ($type === 'A' && !filter_var($target, FILTER_VALIDATE_IP)) { + throw new Exception('A record content must be a valid IP address.'); + } + + if ($type === 'CNAME' && filter_var($target, FILTER_VALIDATE_IP)) { + throw new Exception('CNAME record content must be a hostname, not an IP address.'); + } + + $proxied = (bool) config('modules.custom_domains.cloudflare.proxied', false); + + $existingRecords = $this->findRecordsByName($zoneId, $name, $tokenOverride); + $existing = collect($existingRecords)->first(fn (array $record) => ($record['type'] ?? null) === $type); + + foreach ($existingRecords as $record) { + if (($record['type'] ?? null) === $type) { + continue; + } + + if (!in_array($record['type'] ?? '', ['A', 'CNAME'], true)) { + continue; + } + + if (!empty($record['id'])) { + $this->deleteRecord($zoneId, (string) $record['id'], $tokenOverride); + } + } + + $payload = [ + 'type' => $type, + 'name' => $name, + 'content' => $target, + 'proxied' => $type === 'A' ? $proxied : false, + 'ttl' => 1, + ]; + + if ($existing) { + return $this->updateRecord($zoneId, $existing['id'], $payload, $tokenOverride); + } + + return $this->createRecord($zoneId, $payload, $tokenOverride); + } + + /** + * @return array> + */ + public function getRecordsByName(string $zoneId, string $name, ?string $tokenOverride = null): array + { + return $this->findRecordsByName($zoneId, $name, $tokenOverride); + } + + /** + * @return array> + */ + private function findRecordsByName(string $zoneId, string $name, ?string $tokenOverride = null): array + { + try { + $response = $this->client($tokenOverride)->get($this->baseUrl() . '/zones/' . $zoneId . '/dns_records', [ + 'name' => $name, + 'per_page' => 100, + 'page' => 1, + 'match' => 'all', + ])->throw(); + } catch (RequestException $exception) { + throw new Exception($this->formatCloudflareError('Cloudflare DNS lookup request failed.', $exception)); + } + + $json = $response->json(); + + if (!($json['success'] ?? false)) { + return []; + } + + return is_array($json['result'] ?? null) ? $json['result'] : []; + } + + public function createOrUpdateSrvRecord( + string $zoneId, + string $fqdn, + string $servicePrefix, + string $proto, + int $port, + string $target, + ?string $tokenOverride = null, + ): array { + $normalizedPrefix = $this->normalizeServicePrefix($servicePrefix); + $recordName = $normalizedPrefix . $proto . '.' . $fqdn; + + $payload = [ + 'type' => 'SRV', + 'name' => $recordName, + 'data' => [ + 'priority' => 1, + 'weight' => 1, + 'port' => $port, + 'target' => $target, + ], + 'ttl' => 1, + ]; + + $existing = $this->findRecord($zoneId, 'SRV', $recordName, $tokenOverride); + + if ($existing) { + return $this->updateRecord($zoneId, $existing['id'], $payload, $tokenOverride); + } + + return $this->createRecord($zoneId, $payload, $tokenOverride); + } + + private function normalizeServicePrefix(string $servicePrefix): string + { + $value = strtolower(trim($servicePrefix)); + + if (preg_match('/^_([a-z0-9][a-z0-9-]*)\._$/', $value, $matches) === 1) { + return '_' . $matches[1] . '._'; + } + + if (preg_match('/^_?([a-z0-9][a-z0-9-]*)$/', $value, $matches) === 1) { + return '_' . $matches[1] . '._'; + } + + throw new Exception('Invalid SRV service prefix. Use format like _minecraft._'); + } + + public function deleteRecord(string $zoneId, string $recordId, ?string $tokenOverride = null): void + { + try { + $this->client($tokenOverride)->delete($this->baseUrl() . '/zones/' . $zoneId . '/dns_records/' . $recordId)->throw(); + } catch (RequestException $exception) { + throw new Exception($this->formatCloudflareError('Cloudflare DNS delete request failed.', $exception)); + } + } + + private function findRecord(string $zoneId, string $type, string $name, ?string $tokenOverride = null): ?array + { + try { + $response = $this->client($tokenOverride)->get($this->baseUrl() . '/zones/' . $zoneId . '/dns_records', [ + 'type' => $type, + 'name' => $name, + 'per_page' => 1, + 'page' => 1, + 'match' => 'all', + ])->throw(); + } catch (RequestException $exception) { + throw new Exception($this->formatCloudflareError('Cloudflare DNS lookup request failed.', $exception)); + } + + $json = $response->json(); + + if (!($json['success'] ?? false)) { + return null; + } + + return Arr::first($json['result'] ?? []); + } + + private function createRecord(string $zoneId, array $payload, ?string $tokenOverride = null): array + { + try { + $response = $this->client($tokenOverride) + ->post($this->baseUrl() . '/zones/' . $zoneId . '/dns_records', $payload) + ->throw(); + } catch (RequestException $exception) { + throw new Exception($this->formatCloudflareError('Cloudflare DNS create request failed.', $exception)); + } + + $json = $response->json(); + + if (!($json['success'] ?? false)) { + throw new Exception('Cloudflare DNS create request failed.'); + } + + return $json['result']; + } + + private function updateRecord(string $zoneId, string $recordId, array $payload, ?string $tokenOverride = null): array + { + try { + $response = $this->client($tokenOverride) + ->put($this->baseUrl() . '/zones/' . $zoneId . '/dns_records/' . $recordId, $payload) + ->throw(); + } catch (RequestException $exception) { + throw new Exception($this->formatCloudflareError('Cloudflare DNS update request failed.', $exception)); + } + + $json = $response->json(); + + if (!($json['success'] ?? false)) { + throw new Exception('Cloudflare DNS update request failed.'); + } + + return $json['result']; + } + + private function formatCloudflareError(string $prefix, RequestException $exception): string + { + $body = trim((string) optional($exception->response)->body()); + + return $body !== '' ? $prefix . ' Response: ' . $body : $prefix; + } +} \ No newline at end of file diff --git a/app/Services/CustomDomains/CustomDomainProvisioningService.php b/app/Services/CustomDomains/CustomDomainProvisioningService.php new file mode 100644 index 0000000000..952df34fc4 --- /dev/null +++ b/app/Services/CustomDomains/CustomDomainProvisioningService.php @@ -0,0 +1,731 @@ + '_minecraft._', + 'velocity' => '_minecraft._', + 'bungeecord' => '_minecraft._', + 'bedrock' => '_minecraft._', + ]; + + private const RUST_HINTS = [ + 'rust', + ]; + + private const WEB_INTERFACE_HINTS = [ + 'web', + 'nginx', + 'apache', + 'http', + 'dashboard', + 'panel', + ]; + + public function __construct(private CloudflareDnsService $cloudflare) + { + } + + public function getAvailableDomains(?Server $server = null): array + { + $domains = CustomDomain::query()->where('enabled', true)->orderBy('domain')->get(); + + if (!$server) { + return $domains->all(); + } + + return $domains->filter(fn (CustomDomain $domain) => $this->supportsServer($domain, $server))->values()->all(); + } + + public function createFromPayload(Server $server, array $payload): void + { + if (empty($payload)) { + return; + } + + $server->loadMissing('allocation'); + $resolvedPort = (int) ($server->allocation?->port ?? 0); + if ($resolvedPort < 1) { + throw new DisplayException('Custom domain mappings can only be created after the server allocation is ready.'); + } + + DB::transaction(function () use ($server, $payload, $resolvedPort) { + foreach ($payload as $entry) { + $domainId = (int) ($entry['domain_id'] ?? 0); + $subdomain = strtolower(trim((string) ($entry['subdomain'] ?? ''))); + $port = $resolvedPort; + $protocol = 'both'; + $requestedRecordType = isset($entry['record_type']) ? strtolower(trim((string) $entry['record_type'])) : null; + $recordType = $this->resolveRecordTypeForServer($server, $requestedRecordType); + $serviceTag = isset($entry['service_tag']) ? trim((string) $entry['service_tag']) : null; + $serviceTag = $this->normalizeServiceTagPrefix($serviceTag); + + if ($recordType !== 'srv') { + $serviceTag = null; + } elseif ($serviceTag === null && $this->getDnsModeForServer($server) === 'rust') { + $serviceTag = '_rust._'; + } + + $domain = CustomDomain::query()->where('enabled', true)->findOrFail($domainId); + if (!$this->supportsServer($domain, $server)) { + throw new DisplayException('The selected custom domain is not available for this server type.'); + } + + $this->validateSubdomain($subdomain, $domain); + $effectiveSubdomain = $this->applyConfiguredSubdomainSuffix($subdomain); + + $allocation = $server->allocations()->where('port', $port)->first(); + $fullDomain = $effectiveSubdomain . '.' . $domain->domain; + + if (strlen($fullDomain) > 191) { + throw new DisplayException('The generated full domain exceeds the maximum allowed length.'); + } + + $this->assertServerSubdomainLimitNotReached($server, $fullDomain, $port, $protocol); + + $this->assertSubdomainAvailable($domain, $fullDomain); + + $existing = ServerCustomDomain::query() + ->where('full_domain', $fullDomain) + ->where('port', $port) + ->where('protocol', $protocol) + ->first(); + + if ($existing && $existing->server_id !== $server->id) { + throw new DisplayException('The selected domain and port mapping is already in use by another server.'); + } + + ServerCustomDomain::query()->updateOrCreate( + [ + 'full_domain' => $fullDomain, + 'port' => $port, + 'protocol' => $protocol, + ], + [ + 'server_id' => $server->id, + 'allocation_id' => $allocation?->id, + 'custom_domain_id' => $domain->id, + 'subdomain' => $subdomain, + 'record_type' => $recordType, + 'service_tag' => $serviceTag, + 'status' => 'pending', + 'last_error' => null, + ] + ); + } + }); + } + + public function syncFromOrder(Server $server, ?Order $order): void + { + if (!$order || !is_array($order->domain_payload)) { + return; + } + + $this->createFromPayload($server, $order->domain_payload); + } + + public function provision(ServerCustomDomain $mapping): void + { + try { + $mapping->loadMissing(['customDomain.apiKey', 'server.node', 'server.egg', 'server.nest', 'allocation']); + + if ($mapping->subdomain === '*') { + throw new DisplayException('Wildcard subdomains are not supported.'); + } + + $token = trim((string) ($mapping->customDomain->apiKey?->token ?? '')); + if ($token === '') { + throw new DisplayException('No API key is configured for this custom domain.'); + } + + $zoneId = $mapping->customDomain->cloudflare_zone_id; + if (empty($zoneId)) { + $zone = $this->cloudflare->getZoneByName($mapping->customDomain->domain, $token); + if (!$zone) { + throw new Exception('Cloudflare zone could not be resolved for domain: ' . $mapping->customDomain->domain); + } + + $zoneId = $zone['id']; + $mapping->customDomain->forceFill(['cloudflare_zone_id' => $zoneId])->save(); + } + + $recordType = $this->resolveRecordTypeForServer($mapping->server, $mapping->record_type); + $useSrv = $recordType === 'srv'; + + $records = []; + + $existingRecords = (array) ($mapping->dns_records ?? []); + + if (!$useSrv) { + $target = $this->resolvePreferredCnameTarget($mapping); + $hostRecord = $this->cloudflare->createOrUpdateAOrCnameRecord( + $zoneId, + $mapping->full_domain, + $target, + $token, + filter_var($target, FILTER_VALIDATE_IP) ? null : 'CNAME' + ); + $records[] = [ + 'kind' => 'host', + 'id' => $hostRecord['id'] ?? null, + 'type' => $hostRecord['type'] ?? null, + ]; + } + + $service = $this->resolveServiceTag($mapping); + $protocols = ['tcp', 'udp']; + + if ($useSrv && $service === null) { + throw new DisplayException('SRV record type requires a valid service tag.'); + } + + if ($useSrv && $mapping->subdomain !== '*' && $service !== null) { + $srvTarget = $this->resolveSrvTargetHostname($mapping); + + foreach ($protocols as $proto) { + $srvRecord = $this->cloudflare->createOrUpdateSrvRecord( + $zoneId, + $mapping->full_domain, + $service, + $proto, + $mapping->port, + $srvTarget, + $token + ); + + $records[] = [ + 'kind' => 'srv', + 'id' => $srvRecord['id'] ?? null, + 'type' => 'SRV', + 'proto' => $proto, + ]; + } + } + + $recordIdsToKeep = array_filter(array_map(fn (array $record) => $record['id'] ?? null, $records)); + foreach ($existingRecords as $existingRecord) { + $recordId = $existingRecord['id'] ?? null; + if (!$recordId || in_array($recordId, $recordIdsToKeep, true)) { + continue; + } + + try { + $this->cloudflare->deleteRecord($zoneId, (string) $recordId, $token); + } catch (\Throwable $exception) { + $this->writeLog($mapping, 'delete', 'failed', ['record_id' => $recordId], $exception->getMessage()); + } + } + + $mapping->forceFill([ + 'record_type' => $recordType, + 'dns_records' => $records, + 'status' => 'active', + 'last_error' => null, + 'last_synced_at' => now(), + ])->save(); + + $this->writeLog($mapping, 'sync', 'success', ['records' => $records], 'DNS provisioned successfully.'); + } catch (\Throwable $exception) { + $mapping->forceFill([ + 'status' => 'failed', + 'last_error' => $exception->getMessage(), + 'last_synced_at' => now(), + ])->save(); + + $this->writeLog($mapping, 'sync', 'failed', ['error' => $exception->getMessage()], $exception->getMessage()); + } + } + + public function cleanup(ServerCustomDomain $mapping): void + { + $mapping->loadMissing('customDomain.apiKey'); + $zoneId = $mapping->customDomain->cloudflare_zone_id; + $token = trim((string) ($mapping->customDomain->apiKey?->token ?? '')); + + if (!$zoneId || $token === '') { + return; + } + + $records = $mapping->dns_records ?? []; + foreach ($records as $record) { + $recordId = $record['id'] ?? null; + if (!$recordId) { + continue; + } + + try { + $this->cloudflare->deleteRecord($zoneId, $recordId, $token); + } catch (\Throwable $exception) { + $this->writeLog($mapping, 'delete', 'failed', ['record_id' => $recordId], $exception->getMessage()); + } + } + + $this->writeLog($mapping, 'delete', 'success', ['records' => $records], 'DNS records removed.'); + } + + private function validateSubdomain(string $subdomain, CustomDomain $domain): void + { + if ($subdomain === '*') { + throw new DisplayException('Wildcard subdomains are not supported.'); + } + + if (!$this->isValidSubdomainValue($subdomain)) { + throw new DisplayException('Invalid subdomain value: ' . $subdomain); + } + } + + public function getConfiguredSubdomainSuffix(): ?string + { + $raw = (string) config('modules.custom_domains.subdomain_suffix', ''); + + return $this->normalizeConfiguredSubdomainSuffix($raw); + } + + private function applyConfiguredSubdomainSuffix(string $subdomain): string + { + $suffix = $this->getConfiguredSubdomainSuffix(); + if ($suffix === null) { + return $subdomain; + } + + if (str_ends_with($subdomain, '-' . $suffix)) { + return $subdomain; + } + + $candidate = $subdomain . '-' . $suffix; + if (!$this->isValidSubdomainValue($candidate)) { + throw new DisplayException('Configured subdomain suffix results in an invalid hostname.'); + } + + return $candidate; + } + + private function normalizeConfiguredSubdomainSuffix(?string $suffix): ?string + { + $value = strtolower(trim((string) $suffix)); + if ($value === '') { + return null; + } + + $value = trim($value, " .-"); + if ($value === '') { + return null; + } + + if (!$this->isValidSubdomainValue($value)) { + throw new DisplayException('Configured custom domain suffix is invalid.'); + } + + return $value; + } + + private function isValidSubdomainValue(string $subdomain): bool + { + if ($subdomain === '' || strlen($subdomain) > 191) { + return false; + } + + $labels = explode('.', strtolower($subdomain)); + foreach ($labels as $label) { + if (!preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', $label)) { + return false; + } + } + + return true; + } + + private function assertSubdomainAvailable(CustomDomain $domain, string $fullDomain): void + { + $existingMapping = ServerCustomDomain::query()->where('full_domain', $fullDomain)->exists(); + if ($existingMapping) { + throw new DisplayException('This subdomain is unavailable.'); + } + + $domain->loadMissing('apiKey'); + $token = trim((string) ($domain->apiKey?->token ?? '')); + + try { + $zoneId = (string) ($domain->cloudflare_zone_id ?? ''); + if ($zoneId === '') { + $zone = $this->cloudflare->getZoneByName($domain->domain, $token !== '' ? $token : null); + $zoneId = (string) ($zone['id'] ?? ''); + } + + if ($zoneId === '') { + throw new DisplayException('Unable to verify subdomain availability right now.'); + } + + $dnsRecords = $this->cloudflare->getRecordsByName($zoneId, $fullDomain, $token !== '' ? $token : null); + if (!empty($dnsRecords)) { + throw new DisplayException('This subdomain is unavailable.'); + } + } catch (DisplayException $exception) { + throw $exception; + } catch (\Throwable $exception) { + throw new DisplayException('Unable to verify subdomain availability right now.'); + } + } + + private function resolveEffectiveSubdomainLimit(Server $server): ?int + { + if (!is_null($server->subdomain_limit)) { + return max(0, (int) $server->subdomain_limit); + } + + $server->loadMissing('product'); + if (!is_null($server->product?->subdomain_limit)) { + return max(0, (int) $server->product->subdomain_limit); + } + + return null; + } + + private function assertServerSubdomainLimitNotReached(Server $server, string $fullDomain, int $port, string $protocol): void + { + $limit = $this->resolveEffectiveSubdomainLimit($server); + if (is_null($limit)) { + return; + } + + $existingForTarget = ServerCustomDomain::query() + ->where('server_id', $server->id) + ->where('full_domain', $fullDomain) + ->where('port', $port) + ->where('protocol', $protocol) + ->exists(); + + if ($existingForTarget) { + return; + } + + $currentCount = $server->customDomains()->count(); + if ($currentCount >= $limit) { + throw new DisplayException("Subdomain limit reached for this server ({$currentCount}/{$limit})."); + } + } + + public function resolveSuggestedServiceTag(Server $server, ?CustomDomain $domain = null): ?string + { + if (!$this->isSrvSupportedForServer($server)) { + return null; + } + + if ($domain) { + $eggTags = (array) ($domain->egg_service_tags ?? []); + $eggIdKey = (string) (int) ($server->egg_id ?? 0); + + if ($eggIdKey !== '0' && array_key_exists($eggIdKey, $eggTags) && is_string($eggTags[$eggIdKey])) { + return $this->normalizeServiceTagPrefix($eggTags[$eggIdKey]); + } + + if ($domain->service_tag) { + return $this->normalizeServiceTagPrefix((string) $domain->service_tag); + } + } + + $labels = strtolower(trim(($server->egg?->name ?? '') . ' ' . ($server->nest?->name ?? ''))); + if ($labels === '') { + return null; + } + + foreach (self::WEB_INTERFACE_HINTS as $hint) { + if (str_contains($labels, $hint)) { + return null; + } + } + + foreach (self::MINECRAFT_SERVICE_TAG_MAP as $needle => $tag) { + if (str_contains($labels, $needle)) { + return $this->normalizeServiceTagPrefix($tag); + } + } + + return null; + } + + public function getDefaultServiceTagForEgg(?string $eggName, ?string $nestName = null): ?string + { + $labels = strtolower(trim(($eggName ?? '') . ' ' . ($nestName ?? ''))); + if ($labels === '') { + return null; + } + + foreach (self::WEB_INTERFACE_HINTS as $hint) { + if (str_contains($labels, $hint)) { + return null; + } + } + + foreach (self::MINECRAFT_SERVICE_TAG_MAP as $needle => $tag) { + if (str_contains($labels, $needle)) { + return $this->normalizeServiceTagPrefix($tag); + } + } + + return null; + } + + public function getDnsModeForServer(Server $server): string + { + return $this->resolveDnsModeFromLabels($this->serverLabels($server)); + } + + public function getDnsModeForEgg(?string $eggName, ?string $nestName = null): string + { + $labels = strtolower(trim(($eggName ?? '') . ' ' . ($nestName ?? ''))); + + return $this->resolveDnsModeFromLabels($labels); + } + + public function isSrvSupportedForServer(Server $server): bool + { + return in_array($this->getDnsModeForServer($server), ['minecraft', 'rust'], true); + } + + public function resolveRecordTypeForServer(Server $server, ?string $requestedRecordType = null): string + { + $mode = $this->getDnsModeForServer($server); + $requested = strtolower(trim((string) $requestedRecordType)); + + // Always honor an explicit request from payload/API. + if (in_array($requested, ['srv', 'cname'], true)) { + return $requested; + } + + if ($mode === 'minecraft') { + return 'srv'; + } + + if ($mode === 'rust') { + return 'cname'; + } + + return 'cname'; + } + + public function getDnsRecommendationForServer(Server $server): array + { + return $this->getDnsRecommendationForMode($this->getDnsModeForServer($server)); + } + + public function getDnsRecommendationForEgg(?string $eggName, ?string $nestName = null): array + { + return $this->getDnsRecommendationForMode($this->getDnsModeForEgg($eggName, $nestName)); + } + + private function getDnsRecommendationForMode(string $mode): array + { + + if ($mode === 'minecraft') { + return [ + 'mode' => 'minecraft', + 'recommended_record_type' => 'srv', + 'srv_supported' => true, + 'allow_record_type_selection' => true, + 'forced_record_type' => null, + 'notice' => 'SRV is recommended for Minecraft-family servers. CNAME is also supported.', + 'connection_hint' => 'Use SRV for best compatibility (usually no :port), or CNAME if you prefer connecting with :port.', + ]; + } + + if ($mode === 'rust') { + return [ + 'mode' => 'rust', + 'recommended_record_type' => 'cname', + 'srv_supported' => true, + 'allow_record_type_selection' => true, + 'forced_record_type' => null, + 'notice' => 'CNAME is recommended for Rust. SRV is available but not recommended.', + 'connection_hint' => 'Best option: CNAME with :port (example: play.example.com:28015).', + ]; + } + + return [ + 'mode' => 'generic', + 'recommended_record_type' => 'cname', + 'srv_supported' => false, + 'allow_record_type_selection' => false, + 'forced_record_type' => 'cname', + 'notice' => 'CNAME is the only supported option for this game profile.', + 'connection_hint' => 'Use the mapped domain with :port when connecting.', + ]; + } + + private function resolveDnsModeFromLabels(string $labels): string + { + foreach (self::RUST_HINTS as $hint) { + if (str_contains($labels, $hint)) { + return 'rust'; + } + } + + foreach (self::MINECRAFT_SERVICE_TAG_MAP as $needle => $_) { + if (str_contains($labels, $needle)) { + return 'minecraft'; + } + } + + return 'generic'; + } + + private function supportsServer(CustomDomain $domain, Server $server): bool + { + $allowedNests = array_values(array_filter((array) ($domain->allowed_nest_ids ?? []), fn ($id) => is_numeric($id))); + $allowedEggs = array_values(array_filter((array) ($domain->allowed_egg_ids ?? []), fn ($id) => is_numeric($id))); + + $nestAllowed = empty($allowedNests) || in_array((int) $server->nest_id, array_map('intval', $allowedNests), true); + $eggAllowed = empty($allowedEggs) || in_array((int) $server->egg_id, array_map('intval', $allowedEggs), true); + + return $nestAllowed && $eggAllowed; + } + + private function resolveServiceTag(ServerCustomDomain $mapping): ?string + { + if ($this->resolveRecordTypeForServer($mapping->server, $mapping->record_type) !== 'srv') { + return null; + } + + $customTag = $this->normalizeServiceTagPrefix((string) ($mapping->service_tag ?? '')); + if ($customTag !== null) { + return $customTag; + } + + if ($this->getDnsModeForServer($mapping->server) === 'rust') { + return '_rust._'; + } + + $suggested = $this->resolveSuggestedServiceTag($mapping->server, $mapping->customDomain); + if ($suggested !== null) { + return $suggested; + } + + if ($this->getDnsModeForServer($mapping->server) === 'minecraft') { + return '_minecraft._'; + } + + // If SRV is selected but no specific hint/tag is available, default to + // minecraft service for broad client compatibility. + return '_minecraft._'; + } + + private function resolvePreferredCnameTarget(ServerCustomDomain $mapping): string + { + $allocationAlias = trim((string) ($mapping->allocation?->ip_alias ?? $mapping->server->allocation?->ip_alias ?? '')); + if ($allocationAlias !== '') { + return $allocationAlias; + } + + try { + return $this->resolveSrvTargetHostname($mapping); + } catch (DisplayException $exception) { + // Fall back to the resolved node/allocation target so provisioning still succeeds. + return $this->resolveTarget($mapping); + } + } + + private function normalizeServiceTagPrefix(?string $serviceTag): ?string + { + $value = strtolower(trim((string) $serviceTag)); + if ($value === '') { + return null; + } + + if (preg_match('/^_([a-z0-9][a-z0-9-]*)\._(?:tcp|udp)$/', $value, $matches) === 1) { + return '_' . $matches[1] . '._'; + } + + if (preg_match('/^_([a-z0-9][a-z0-9-]*)\._$/', $value, $matches) === 1) { + return '_' . $matches[1] . '._'; + } + + if (preg_match('/^_?([a-z0-9][a-z0-9-]*)$/', $value, $matches) === 1) { + return '_' . $matches[1] . '._'; + } + + throw new DisplayException('Invalid service tag. Use format like _minecraft._'); + } + + private function resolveTarget(ServerCustomDomain $mapping): string + { + if ($mapping->allocation && filter_var($mapping->allocation->ip, FILTER_VALIDATE_IP)) { + return $mapping->allocation->ip; + } + + if ($mapping->server->allocation && filter_var($mapping->server->allocation->ip, FILTER_VALIDATE_IP)) { + return $mapping->server->allocation->ip; + } + + return (string) $mapping->server->node->fqdn; + } + + private function resolveSrvTargetHostname(ServerCustomDomain $mapping): string + { + $raw = trim((string) $mapping->server->node->fqdn); + + if ($raw === '') { + throw new DisplayException('Node hostname is not configured.'); + } + + $hostname = $raw; + + if (str_contains($hostname, '://')) { + $parsed = parse_url($hostname, PHP_URL_HOST); + $hostname = is_string($parsed) ? $parsed : ''; + } else { + $hostname = preg_split('/[\/\?#]/', $hostname, 2)[0] ?? ''; + + if (str_starts_with($hostname, '[') && str_contains($hostname, ']')) { + $hostname = trim(explode(']', $hostname, 2)[0], '[]'); + } elseif (preg_match('/:[0-9]+$/', $hostname) === 1 && substr_count($hostname, ':') === 1) { + $hostname = substr($hostname, 0, (int) strrpos($hostname, ':')); + } + } + + $hostname = strtolower(rtrim(trim($hostname), '.')); + + if ($hostname === '') { + throw new DisplayException('Node hostname is invalid.'); + } + + if (filter_var($hostname, FILTER_VALIDATE_IP)) { + throw new DisplayException('Node hostname must be a DNS hostname, not an IP address.'); + } + + if (!preg_match('/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$/', $hostname)) { + throw new DisplayException('Node hostname is invalid for SRV target.'); + } + + return $hostname; + } + + private function writeLog(ServerCustomDomain $mapping, string $action, string $status, array $payload = [], ?string $message = null): void + { + CustomDomainDnsLog::query()->create([ + 'server_id' => $mapping->server_id, + 'server_custom_domain_id' => $mapping->id, + 'action' => $action, + 'status' => $status, + 'payload' => $payload, + 'message' => $message, + ]); + } + + private function serverLabels(Server $server): string + { + return strtolower(trim(($server->egg?->name ?? '') . ' ' . ($server->nest?->name ?? ''))); + } +} \ No newline at end of file diff --git a/app/Services/CustomDomains/SslProvisioningService.php b/app/Services/CustomDomains/SslProvisioningService.php new file mode 100644 index 0000000000..7f579f7c4b --- /dev/null +++ b/app/Services/CustomDomains/SslProvisioningService.php @@ -0,0 +1,30 @@ +run($command); + + if (!$result->successful()) { + throw new Exception('SSL provisioning command failed: ' . $result->errorOutput()); + } + } +} diff --git a/app/Services/Extensions/ExtensionCatalogService.php b/app/Services/Extensions/ExtensionCatalogService.php new file mode 100644 index 0000000000..7d4d0c56d0 --- /dev/null +++ b/app/Services/Extensions/ExtensionCatalogService.php @@ -0,0 +1,525 @@ +> + */ + public function getLocalExtensions(): array + { + $configs = ExtensionConfig::query()->get()->keyBy('extension_id'); + $extensions = []; + + foreach ((array) config('modules.extensions.available', []) as $extensionId => $definition) { + if (!is_array($definition)) { + continue; + } + + $config = $configs->get($extensionId); + $extensions[$extensionId] = [ + 'id' => $extensionId, + 'name' => $definition['name'] ?? $extensionId, + 'description' => $definition['description'] ?? '', + 'version' => $definition['version'] ?? '1.0.0', + 'latestVersion' => $definition['version'] ?? '1.0.0', + 'author' => $definition['author'] ?? 'M12Labs', + 'icon' => $definition['icon'] ?? 'puzzle', + 'route' => $definition['route'] ?? $extensionId, + 'enabled' => (bool) ($config?->enabled ?? false), + 'allowedNests' => array_values($config?->allowed_nests ?? $definition['allowed_nests'] ?? []), + 'allowedEggs' => array_values($config?->allowed_eggs ?? $definition['allowed_eggs'] ?? []), + 'settings' => is_array($config?->settings) ? $config->settings : [], + 'settingsSchema' => $this->normalizeSettingsSchema($definition['settings_schema'] ?? []), + 'installed' => true, + 'installable' => false, + 'canUninstall' => false, + 'status' => 'core', + 'updateAvailable' => false, + 'compatiblePanelVersions' => [], + 'source' => [ + 'type' => 'core', + 'label' => 'Core', + 'official' => true, + 'repositoryId' => null, + 'repositoryName' => 'Core', + 'homepageUrl' => null, + 'securityWarning' => 'Core extensions ship with M12Labs itself and are not removed through the repository installer.', + ], + ]; + } + + $packages = ExtensionPackage::query()->with('repository')->get(); + foreach ($packages as $package) { + $config = $configs->get($package->extension_id); + $manifest = is_array($package->manifest) ? $package->manifest : []; + $extension = (array) Arr::get($manifest, 'extension', []); + $repository = $package->repository; + + $extensions[$package->extension_id] = [ + 'id' => $package->extension_id, + 'name' => $package->name, + 'description' => $package->description ?? '', + 'version' => $package->installed_version, + 'latestVersion' => $package->installed_version, + 'author' => $package->author ?? 'M12Labs', + 'icon' => $package->icon ?: 'puzzle', + 'route' => $package->route ?: $package->extension_id, + 'enabled' => (bool) ($config?->enabled ?? false), + 'allowedNests' => array_values($config?->allowed_nests ?? Arr::get($extension, 'defaults.allowedNests', [])), + 'allowedEggs' => array_values($config?->allowed_eggs ?? Arr::get($extension, 'defaults.allowedEggs', [])), + 'settings' => is_array($config?->settings) + ? $config->settings + : (array) Arr::get($extension, 'defaults.settings', []), + 'settingsSchema' => $this->normalizeSettingsSchema(Arr::get($extension, 'settingsSchema', [])), + 'installed' => true, + 'installable' => false, + 'canUninstall' => true, + 'status' => 'installed', + 'updateAvailable' => false, + 'compatiblePanelVersions' => array_values(array_filter((array) Arr::get($manifest, 'compatiblePanelVersions', []), 'is_string')), + 'source' => [ + 'type' => 'repository', + 'label' => $package->source_repository_name ?: ($repository?->name ?? 'Custom repository'), + 'official' => (bool) $repository?->is_official, + 'repositoryId' => $repository?->id, + 'repositoryName' => $package->source_repository_name ?: $repository?->name, + 'homepageUrl' => $repository?->homepage_url, + 'securityWarning' => $this->getRepositorySecurityWarning($repository), + ], + ]; + } + + ksort($extensions); + + return array_values($extensions); + } + + /** + * @return array{extensions: array>, repositories: array>} + */ + public function getCatalog(bool $forceRefresh = false): array + { + $this->bootstrapService->ensureOfficialRepository(); + + $localExtensions = []; + foreach ($this->getLocalExtensions() as $extension) { + $localExtensions[$extension['id']] = $extension; + } + + $configs = ExtensionConfig::query()->get()->keyBy('extension_id'); + $repositories = []; + + foreach (ExtensionRepository::query()->orderByDesc('is_official')->orderBy('name')->get() as $repository) { + $repositorySummary = $this->formatRepositorySummary($repository); + + if (!$repository->enabled) { + $repositorySummary['status'] = 'disabled'; + $repositories[] = $repositorySummary; + + continue; + } + + try { + $manifest = $this->fetchRepositoryManifest($repository, $forceRefresh); + $packages = $manifest['packages'] ?? []; + + $repositorySummary['status'] = 'ok'; + $repositorySummary['packagesCount'] = count($packages); + + foreach ($packages as $package) { + $extensionId = $package['id']; + $latestRelease = $package['latestRelease']; + $config = $configs->get($extensionId); + + if (isset($localExtensions[$extensionId])) { + $localExtensions[$extensionId]['latestVersion'] = $latestRelease['version']; + $localExtensions[$extensionId]['compatiblePanelVersions'] = $latestRelease['compatiblePanelVersions']; + $localExtensions[$extensionId]['updateAvailable'] = + in_array($localExtensions[$extensionId]['status'], ['installed', 'core'], true) + && $localExtensions[$extensionId]['version'] !== $latestRelease['version']; + + if ($this->shouldMirrorCoreExtensionFromRepository($localExtensions[$extensionId], $repository)) { + $localExtensions[$extensionId] = $this->mirrorCoreExtensionFromRepository( + $localExtensions[$extensionId], + $repository + ); + } + + continue; + } + + $localExtensions[$extensionId] = [ + 'id' => $extensionId, + 'name' => $package['name'], + 'description' => $package['description'], + 'version' => $latestRelease['version'], + 'latestVersion' => $latestRelease['version'], + 'author' => $package['author'], + 'icon' => $package['icon'], + 'route' => $package['route'], + 'enabled' => false, + 'allowedNests' => array_values($config?->allowed_nests ?? []), + 'allowedEggs' => array_values($config?->allowed_eggs ?? []), + 'settings' => is_array($config?->settings) ? $config->settings : [], + 'settingsSchema' => $this->normalizeSettingsSchema($package['settingsSchema'] ?? []), + 'installed' => false, + 'installable' => true, + 'canUninstall' => false, + 'status' => 'available', + 'updateAvailable' => false, + 'compatiblePanelVersions' => $latestRelease['compatiblePanelVersions'], + 'source' => [ + 'type' => 'repository', + 'label' => $repository->name, + 'official' => $repository->is_official, + 'repositoryId' => $repository->id, + 'repositoryName' => $repository->name, + 'homepageUrl' => $repository->homepage_url, + 'securityWarning' => $this->getRepositorySecurityWarning($repository), + ], + ]; + } + } catch (Throwable $exception) { + report($exception); + + $repositorySummary['status'] = 'error'; + $repositorySummary['error'] = $exception->getMessage(); + } + + $repositories[] = $repositorySummary; + } + + ksort($localExtensions); + + return [ + 'extensions' => array_values($localExtensions), + 'repositories' => $repositories, + ]; + } + + /** + * @return array|null + */ + public function getExtension(string $extensionId, bool $forceRefresh = false): ?array + { + foreach ($this->getCatalog($forceRefresh)['extensions'] as $extension) { + if ($extension['id'] === $extensionId) { + return $extension; + } + } + + return null; + } + + /** + * @return array> + */ + public function getRepositories(bool $forceRefresh = false): array + { + return $this->getCatalog($forceRefresh)['repositories']; + } + + /** + * @return array{id: string, name: string, description: string, author: string, icon: string, route: string, settingsSchema: array>, latestRelease: array} + */ + public function findRepositoryPackage(string $extensionId, int $repositoryId, ?string $version = null): array + { + $this->bootstrapService->ensureOfficialRepository(); + + $repository = ExtensionRepository::query()->findOrFail($repositoryId); + $manifest = $this->fetchRepositoryManifest($repository, true); + + foreach ($manifest['packages'] ?? [] as $package) { + if ($package['id'] !== $extensionId) { + continue; + } + + if ($version === null || $package['latestRelease']['version'] === $version) { + $package['repository'] = $repository; + + return $package; + } + + foreach ($package['versions'] as $release) { + if ($release['version'] === $version) { + $package['latestRelease'] = $release; + $package['repository'] = $repository; + + return $package; + } + } + } + + throw new DisplayException('The selected extension could not be found in that repository.'); + } + + public function validateRepository(ExtensionRepository $repository): void + { + if (!$repository->enabled) { + return; + } + + $this->fetchRepositoryManifest($repository, true); + } + + /** + * @return array + */ + public function fetchRepositoryManifest(ExtensionRepository $repository, bool $forceRefresh = false): array + { + $cacheKey = sprintf( + 'extensions:repository:%s:%s', + $repository->id, + sha1($repository->manifest_url . '|' . $repository->updated_at?->timestamp) + ); + + if ($forceRefresh) { + Cache::forget($cacheKey); + } + + return Cache::remember($cacheKey, now()->addMinutes(5), function () use ($repository) { + $payload = json_decode($this->readLocationContents($repository->manifest_url), true, 512, JSON_THROW_ON_ERROR); + if (!is_array($payload)) { + throw new DisplayException(sprintf('Repository "%s" returned an invalid manifest.', $repository->name)); + } + + return $this->normalizeRepositoryManifest($payload, $repository); + }); + } + + /** + * @param array $payload + * @return array + */ + private function normalizeRepositoryManifest(array $payload, ExtensionRepository $repository): array + { + $packages = []; + foreach ((array) ($payload['packages'] ?? []) as $package) { + if (!is_array($package)) { + continue; + } + + $extensionId = trim((string) ($package['id'] ?? '')); + if ($extensionId === '') { + continue; + } + + $versions = []; + foreach ((array) ($package['versions'] ?? []) as $release) { + if (!is_array($release)) { + continue; + } + + $version = trim((string) ($release['version'] ?? '')); + $archive = trim((string) ($release['archive'] ?? '')); + $checksum = $this->normalizeChecksum((string) ($release['sha256'] ?? '')); + + if ($version === '' || $archive === '' || $checksum === '') { + continue; + } + + $versions[] = [ + 'version' => $version, + 'archiveUrl' => $this->resolveLocation($repository->manifest_url, $archive), + 'archiveChecksum' => $checksum, + 'publishedAt' => $release['publishedAt'] ?? null, + 'compatiblePanelVersions' => array_values(array_filter((array) ($release['compatiblePanelVersions'] ?? []), 'is_string')), + 'notes' => $release['notes'] ?? null, + ]; + } + + if ($versions === []) { + continue; + } + + usort($versions, function (array $left, array $right): int { + $leftPublishedAt = $left['publishedAt'] ?? ''; + $rightPublishedAt = $right['publishedAt'] ?? ''; + + if ($leftPublishedAt !== '' || $rightPublishedAt !== '') { + return strcmp((string) $rightPublishedAt, (string) $leftPublishedAt); + } + + return strcmp((string) $right['version'], (string) $left['version']); + }); + + $packages[] = [ + 'id' => $extensionId, + 'name' => (string) ($package['name'] ?? $extensionId), + 'description' => (string) ($package['description'] ?? ''), + 'author' => (string) ($package['author'] ?? 'M12Labs'), + 'icon' => (string) ($package['icon'] ?? 'puzzle'), + 'route' => (string) ($package['route'] ?? $extensionId), + 'settingsSchema' => $this->normalizeSettingsSchema($package['settingsSchema'] ?? []), + 'versions' => $versions, + 'latestRelease' => $versions[0], + ]; + } + + return [ + 'schemaVersion' => (int) ($payload['schemaVersion'] ?? 1), + 'repository' => [ + 'name' => (string) Arr::get($payload, 'repository.name', $repository->name), + 'homepage' => Arr::get($payload, 'repository.homepage', $repository->homepage_url), + ], + 'packages' => $packages, + ]; + } + + /** + * @param mixed $schema + * @return array> + */ + private function normalizeSettingsSchema(mixed $schema): array + { + if (!is_array($schema)) { + return []; + } + + return array_values(array_filter($schema, function ($field): bool { + return is_array($field) + && !empty($field['key']) + && !empty($field['label']) + && !empty($field['type']); + })); + } + + /** + * @return array + */ + private function formatRepositorySummary(ExtensionRepository $repository): array + { + return [ + 'id' => $repository->id, + 'slug' => $repository->slug, + 'name' => $repository->name, + 'manifestUrl' => $repository->manifest_url, + 'homepageUrl' => $repository->homepage_url, + 'enabled' => $repository->enabled, + 'official' => $repository->is_official, + 'packagesCount' => 0, + 'securityWarning' => $this->getRepositorySecurityWarning($repository), + ]; + } + + /** + * @param array $extension + */ + private function shouldMirrorCoreExtensionFromRepository(array $extension, ExtensionRepository $repository): bool + { + return ($extension['status'] ?? null) === 'core' && $repository->is_official; + } + + /** + * @param array $extension + * @return array + */ + private function mirrorCoreExtensionFromRepository(array $extension, ExtensionRepository $repository): array + { + $extension['status'] = 'installed'; + $extension['installed'] = true; + $extension['installable'] = false; + $extension['canUninstall'] = false; + $extension['source'] = [ + 'type' => 'repository', + 'label' => $repository->name, + 'official' => (bool) $repository->is_official, + 'repositoryId' => $repository->id, + 'repositoryName' => $repository->name, + 'homepageUrl' => $repository->homepage_url, + 'securityWarning' => $this->getRepositorySecurityWarning($repository), + ]; + + return $extension; + } + + private function getRepositorySecurityWarning(?ExtensionRepository $repository): string + { + if ($repository?->is_official) { + return 'Checksums verify that the downloaded archive matches the manifest published by the official M12Labs repository.'; + } + + return 'Third-party repositories can ship arbitrary PHP and frontend code into M12Labs. Checksums only verify the archive matches that repository manifest.'; + } + + private function readLocationContents(string $location): string + { + if ($this->isHttpLocation($location)) { + $response = Http::timeout(30)->get($location); + if (!$response->successful()) { + throw new DisplayException(sprintf('Unable to fetch repository manifest from "%s".', $location)); + } + + return (string) $response->body(); + } + + $path = $this->toLocalPath($location); + if (!is_file($path)) { + throw new DisplayException(sprintf('Repository manifest "%s" does not exist on disk.', $path)); + } + + $contents = File::get($path); + if ($contents === false) { + throw new DisplayException(sprintf('Unable to read repository manifest "%s".', $path)); + } + + return $contents; + } + + private function resolveLocation(string $baseLocation, string $path): string + { + if ($path === '') { + return $path; + } + + if ($this->isHttpLocation($path) || Str::startsWith($path, 'file://') || Str::startsWith($path, '/')) { + return $path; + } + + if ($this->isHttpLocation($baseLocation)) { + return (string) UriResolver::resolve(Utils::uriFor($baseLocation), Utils::uriFor($path)); + } + + return dirname($this->toLocalPath($baseLocation)) . '/' . ltrim($path, '/'); + } + + private function normalizeChecksum(string $checksum): string + { + $checksum = strtolower(trim($checksum)); + + return Str::startsWith($checksum, 'sha256:') ? substr($checksum, 7) : $checksum; + } + + private function isHttpLocation(string $location): bool + { + return Str::startsWith($location, ['http://', 'https://']); + } + + private function toLocalPath(string $location): string + { + if (Str::startsWith($location, 'file://')) { + return rawurldecode(substr($location, 7)); + } + + return $location; + } +} \ No newline at end of file diff --git a/app/Services/Extensions/ExtensionFileSnapshotService.php b/app/Services/Extensions/ExtensionFileSnapshotService.php new file mode 100644 index 0000000000..bf1a9cd518 --- /dev/null +++ b/app/Services/Extensions/ExtensionFileSnapshotService.php @@ -0,0 +1,43 @@ + $fileContentsMap Map of file path => plain text file contents. + */ + public function create(Server $server, string $extensionId, ?User $actor, string $action, array $fileContentsMap): ExtensionFileSnapshot + { + $encrypted = []; + foreach ($fileContentsMap as $path => $contents) { + $encrypted[$path] = Crypt::encryptString($contents); + } + + return ExtensionFileSnapshot::query()->create([ + 'server_id' => $server->id, + 'actor_id' => $actor?->id, + 'extension_id' => $extensionId, + 'action' => $action, + 'files' => $encrypted, + ]); + } + + /** + * @return array Map of file path => decrypted contents. + */ + public function decryptFiles(ExtensionFileSnapshot $snapshot): array + { + $decrypted = []; + foreach (($snapshot->files ?? []) as $path => $encrypted) { + $decrypted[$path] = Crypt::decryptString($encrypted); + } + + return $decrypted; + } +} diff --git a/app/Services/Extensions/ExtensionFilesystemOwnershipService.php b/app/Services/Extensions/ExtensionFilesystemOwnershipService.php new file mode 100644 index 0000000000..686ec8e747 --- /dev/null +++ b/app/Services/Extensions/ExtensionFilesystemOwnershipService.php @@ -0,0 +1,315 @@ + + */ + public function repairStandardPaths(?string $extensionId = null): array + { + if (!$this->isRunningAsRoot()) { + return []; + } + + $ownership = $this->resolveOwnershipTarget(); + if ($ownership === null) { + return []; + } + + $paths = [ + storage_path('app/extensions'), + base_path('public/build'), + ]; + + if ($extensionId !== null && $extensionId !== '') { + $paths[] = base_path(sprintf('app/Extensions/Packages/%s', $extensionId)); + $paths[] = base_path(sprintf('resources/scripts/extensions/packages/%s', $extensionId)); + } else { + $paths[] = base_path('app/Extensions/Packages'); + $paths[] = base_path('resources/scripts/extensions'); + } + + $repaired = []; + foreach (array_unique($paths) as $path) { + if (!file_exists($path)) { + continue; + } + + $this->applyOwnership($path, $ownership['uid'], $ownership['gid']); + $repaired[] = $path; + } + + return [ + 'user' => $ownership['user'], + 'group' => $ownership['group'], + 'sourcePath' => $ownership['sourcePath'], + 'paths' => $repaired, + ]; + } + + public function ensureWritablePath(string $path, string $label): void + { + $probe = file_exists($path) ? $path : $this->findClosestExistingPath(dirname($path)); + if ($probe !== null && is_writable($probe)) { + return; + } + + throw new DisplayException(sprintf( + 'M12Labs cannot write to "%s". Repair the panel file ownership and permissions, then try again. Files created by a root-run extension install or uninstall should belong to the panel user (for example www-data).', + $label + )); + } + + public function ensureRemovablePath(string $path, string $label): void + { + $probe = $this->findClosestExistingPath(dirname($path)); + if ($probe !== null && is_writable($probe)) { + return; + } + + throw new DisplayException(sprintf( + 'M12Labs cannot remove "%s". Repair the panel file ownership and permissions, then try again. Files created by a root-run extension install or uninstall should belong to the panel user (for example www-data).', + $label + )); + } + + public function isRunningAsRoot(): bool + { + return function_exists('posix_geteuid') && posix_geteuid() === 0; + } + + /** + * Validate that the build workspace paths are writable before the frontend build runs. + * + * When running as root, any path whose owner doesn't match the panel user + * (including root-owned paths in application directories) is repaired + * automatically. When not running as root, writability is checked directly + * so that bad permissions — including root-owned files left by a previous + * root-run build — are caught before pnpm/npm starts. + * + * @throws DisplayException if any path is not writable and cannot be repaired automatically. + */ + public function validateBuildWorkspaceOwnership(): void + { + $ownership = $this->resolveOwnershipTarget(); + + $candidates = [ + base_path(), + base_path('vendor'), + base_path('node_modules'), + base_path('public/build'), + base_path('public/build/assets'), + storage_path('app/extensions/runtime-home'), + ]; + + $mismatched = []; + + foreach ($candidates as $path) { + if (!file_exists($path)) { + continue; + } + + if ($this->isRunningAsRoot()) { + // When running as root, repair any path whose owner doesn't match + // the panel user — including root-owned application paths. + if ($ownership === null) { + continue; + } + + $actualUid = @fileowner($path); + if ($actualUid === false || $actualUid === $ownership['uid']) { + continue; + } + + $this->applyOwnership($path, $ownership['uid'], $ownership['gid']); + } else { + // When not running as root, check real writability. This catches + // root-owned directories and files left by a previous root-run build + // that would cause pnpm/npm to fail with EACCES. + if (is_writable($path)) { + continue; + } + + $mismatched[] = $path; + } + } + + if ($mismatched === []) { + return; + } + + $user = $ownership['user'] ?? 'the panel user'; + $group = $ownership['group'] ?? 'the panel group'; + + throw new DisplayException(sprintf( + 'M12Labs cannot start the build because %d path(s) are not writable: %s. ' + . 'Run "sudo chown -R %s:%s " for each path listed to repair ownership, then try again.', + count($mismatched), + implode(', ', $mismatched), + $user, + $group + )); + } + + /** + * @return array{uid: int, gid: int, user: string, group: string, sourcePath: string}|null + */ + private function resolveOwnershipTarget(): ?array + { + $envUser = env('M12LABS_PANEL_OWNER'); + $envGroup = env('M12LABS_PANEL_GROUP'); + if (is_string($envUser) && is_string($envGroup) && function_exists('posix_getpwnam') && function_exists('posix_getgrnam')) { + $userInfo = posix_getpwnam($envUser); + $groupInfo = posix_getgrnam($envGroup); + + if (is_array($userInfo) && is_array($groupInfo)) { + return [ + 'uid' => (int) $userInfo['uid'], + 'gid' => (int) $groupInfo['gid'], + 'user' => $envUser, + 'group' => $envGroup, + 'sourcePath' => 'environment', + ]; + } + } + + foreach ([ + storage_path(), + storage_path('logs'), + base_path('resources/scripts'), + base_path('bootstrap/cache'), + base_path('public'), + ] as $candidate) { + if (!file_exists($candidate)) { + continue; + } + + $uid = @fileowner($candidate); + $gid = @filegroup($candidate); + if ($uid === false || $gid === false) { + continue; + } + + if ($uid === 0 && $gid === 0) { + continue; + } + + return [ + 'uid' => (int) $uid, + 'gid' => (int) $gid, + 'user' => $this->resolveUserName((int) $uid), + 'group' => $this->resolveGroupName((int) $gid), + 'sourcePath' => $candidate, + ]; + } + + foreach ([['www-data', 'www-data'], ['nginx', 'nginx'], ['apache', 'apache']] as [$user, $group]) { + if (!function_exists('posix_getpwnam') || !function_exists('posix_getgrnam')) { + continue; + } + + $userInfo = posix_getpwnam($user); + $groupInfo = posix_getgrnam($group); + if (!is_array($userInfo) || !is_array($groupInfo)) { + continue; + } + + return [ + 'uid' => (int) $userInfo['uid'], + 'gid' => (int) $groupInfo['gid'], + 'user' => $user, + 'group' => $group, + 'sourcePath' => 'fallback:' . $user, + ]; + } + + return null; + } + + private function applyOwnership(string $path, int $uid, int $gid): void + { + $this->chownPath($path, $uid, $gid); + + if (!is_dir($path)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + + /** @var SplFileInfo $item */ + foreach ($iterator as $item) { + $this->chownPath($item->getPathname(), $uid, $gid); + } + } + + private function chownPath(string $path, int $uid, int $gid): void + { + if (!file_exists($path)) { + return; + } + + @chown($path, $uid); + @chgrp($path, $gid); + + if (is_dir($path)) { + @chmod($path, 0755); + + return; + } + + if (is_file($path)) { + @chmod($path, 0644); + } + } + + private function findClosestExistingPath(string $path): ?string + { + $candidate = $path; + + while ($candidate !== '' && $candidate !== DIRECTORY_SEPARATOR && !file_exists($candidate)) { + $parent = dirname($candidate); + if ($parent === $candidate) { + return null; + } + + $candidate = $parent; + } + + return file_exists($candidate) ? $candidate : null; + } + + private function resolveUserName(int $uid): string + { + if (function_exists('posix_getpwuid')) { + $info = posix_getpwuid($uid); + if (is_array($info) && !empty($info['name'])) { + return (string) $info['name']; + } + } + + return (string) $uid; + } + + private function resolveGroupName(int $gid): string + { + if (function_exists('posix_getgrgid')) { + $info = posix_getgrgid($gid); + if (is_array($info) && !empty($info['name'])) { + return (string) $info['name']; + } + } + + return (string) $gid; + } +} \ No newline at end of file diff --git a/app/Services/Extensions/ExtensionInstallProgressService.php b/app/Services/Extensions/ExtensionInstallProgressService.php new file mode 100644 index 0000000000..9f6989be4d --- /dev/null +++ b/app/Services/Extensions/ExtensionInstallProgressService.php @@ -0,0 +1,151 @@ + self::INSTALL_STAGES, + 'uninstall' => self::UNINSTALL_STAGES, + 'update' => self::UPDATE_STAGES, + 'batch-install' => self::INSTALL_STAGES, + 'batch-uninstall' => self::UNINSTALL_STAGES, + 'batch-update' => self::UPDATE_STAGES, + default => throw new \InvalidArgumentException(sprintf('Unknown extension action "%s".', $action)), + }; + + if (!in_array($stage, $validStages, true)) { + throw new \InvalidArgumentException( + sprintf('Invalid stage "%s" for action "%s".', $stage, $action) + ); + } + + $path = $this->progressFilePath(); + $tmp = $path . '.tmp'; + + File::ensureDirectoryExists(dirname($path)); + + // Carry forward started_at so callers can detect stale/hung operations. + $existing = $this->current(); + $startedAt = $existing['started_at'] ?? now()->toIso8601String(); + + $payload = [ + 'action' => $action, + 'extension_id' => $extensionId, + 'stage' => $stage, + 'started_at' => $startedAt, + 'updated_at' => now()->toIso8601String(), + ]; + + if ($batchTotal !== null) { + $payload['batch_total'] = $batchTotal; + $payload['batch_current'] = $batchCurrent ?? 1; + if ($batchExtensions !== null) { + $payload['batch_extensions'] = $batchExtensions; + } + } + + // Atomic write: write to a temp file then rename into place so a + // concurrent reader never sees partial JSON. + File::put($tmp, json_encode($payload, JSON_UNESCAPED_SLASHES)); + + rename($tmp, $path); + } + + /** + * Return the current progress payload or null when no operation is running. + * + * @return array|null + */ + public function current(): ?array + { + $path = $this->progressFilePath(); + + if (!File::exists($path)) { + return null; + } + + $contents = File::get($path); + $data = json_decode($contents, true); + + return is_array($data) ? $data : null; + } + + /** + * Clear the progress record (called after an operation finishes or fails). + */ + public function clear(): void + { + File::delete($this->progressFilePath()); + } + + /** + * Absolute path to the progress JSON file. + */ + private function progressFilePath(): string + { + return storage_path('app/' . self::PROGRESS_FILE); + } +} diff --git a/app/Services/Extensions/ExtensionOperationLockService.php b/app/Services/Extensions/ExtensionOperationLockService.php new file mode 100644 index 0000000000..f24ed23075 --- /dev/null +++ b/app/Services/Extensions/ExtensionOperationLockService.php @@ -0,0 +1,56 @@ +get()) { + throw new DisplayException($this->buildBlockedMessage()); + } + + Cache::put(self::CONTEXT_KEY, [ + 'action' => $action, + 'subject' => $subject, + 'started_at' => now()->toIso8601String(), + ], self::LOCK_TTL_SECONDS); + + try { + return $callback(); + } finally { + Cache::forget(self::CONTEXT_KEY); + $lock->release(); + } + } + + private function buildBlockedMessage(): string + { + $context = Cache::get(self::CONTEXT_KEY); + $action = is_array($context) ? ($context['action'] ?? null) : null; + $subject = is_array($context) ? trim((string) ($context['subject'] ?? '')) : ''; + + if ($action === null) { + return 'Another extension action is already running. Wait for the previous install, update, or uninstall to finish before starting a new one.'; + } + + // NOTE: Current actions (install, update, uninstall) all form regular past participles + // with "-ed". If a new action with an irregular past tense is added, use a lookup map. + $suffix = $subject !== '' ? sprintf(' (%s)', $subject) : ''; + + return sprintf( + 'Another extension is currently being %sed%s. Wait for the previous extension action to finish before starting a new install, update, or uninstall.', + $action, + $suffix + ); + } +} \ No newline at end of file diff --git a/app/Services/Extensions/ExtensionPackageArtifactService.php b/app/Services/Extensions/ExtensionPackageArtifactService.php new file mode 100644 index 0000000000..8ed212a6ff --- /dev/null +++ b/app/Services/Extensions/ExtensionPackageArtifactService.php @@ -0,0 +1,300 @@ + + */ + public function inspectArchive(string $archivePath, ?string $workingDirectory = null): array + { + $resolvedPath = $this->resolveArchivePath($archivePath, $workingDirectory); + + $zip = new ZipArchive(); + if ($zip->open($resolvedPath) !== true) { + throw new DisplayException(sprintf('The extension package file "%s" could not be opened.', $resolvedPath)); + } + + try { + $rawManifest = $zip->getFromName(self::MANIFEST_FILENAME); + if (!is_string($rawManifest)) { + throw new DisplayException(sprintf('The extension package "%s" does not contain %s.', basename($resolvedPath), self::MANIFEST_FILENAME)); + } + + $manifest = json_decode($rawManifest, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($manifest)) { + throw new DisplayException(sprintf('The extension package "%s" contains an invalid manifest.', basename($resolvedPath))); + } + } catch (\JsonException $exception) { + throw new DisplayException(sprintf('The extension package "%s" contains malformed manifest JSON.', basename($resolvedPath)), $exception); + } finally { + $zip->close(); + } + + $extensionId = trim((string) Arr::get($manifest, 'extension.id', '')); + $version = trim((string) Arr::get($manifest, 'package.version', '')); + + if ($extensionId === '' || $version === '') { + throw new DisplayException(sprintf('The extension package "%s" is missing extension.id or package.version.', basename($resolvedPath))); + } + + return [ + 'archivePath' => $resolvedPath, + 'archiveName' => basename($resolvedPath), + 'extensionId' => $extensionId, + 'packageId' => trim((string) Arr::get($manifest, 'package.id', $extensionId)), + 'version' => $version, + 'name' => trim((string) Arr::get($manifest, 'extension.name', $extensionId)), + 'description' => trim((string) Arr::get($manifest, 'extension.description', '')), + 'route' => trim((string) Arr::get($manifest, 'extension.route', $extensionId)), + 'fileCount' => count((array) Arr::get($manifest, 'files', [])), + 'compatiblePanelVersions' => array_values(array_filter((array) Arr::get($manifest, 'compatiblePanelVersions', []), 'is_string')), + 'manifest' => $manifest, + ]; + } + + /** + * @return array> + */ + public function discoverArchives(string $directory): array + { + if (!is_dir($directory)) { + return []; + } + + $archives = []; + $entries = scandir($directory) ?: []; + sort($entries); + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = rtrim($directory, '/') . '/' . $entry; + if (!is_file($path) || !$this->isSupportedArchiveName($path)) { + continue; + } + + try { + $archives[] = $this->inspectArchive($path, $directory); + } catch (\Throwable $exception) { + $archives[] = [ + 'archivePath' => realpath($path) ?: $path, + 'archiveName' => basename($path), + 'error' => $exception->getMessage(), + ]; + } + } + + return $archives; + } + + public function looksLikeArchiveReference(string $value, ?string $workingDirectory = null): bool + { + $value = trim($value); + if ($value === '') { + return false; + } + + if ($this->isSupportedArchiveName($value)) { + return true; + } + + if (Str::startsWith($value, 'file://')) { + return true; + } + + if (Str::contains($value, ['/','\\'])) { + return true; + } + + if ($workingDirectory) { + $candidate = rtrim($workingDirectory, '/') . '/' . $value; + + return is_file($candidate) && $this->isSupportedArchiveName($candidate); + } + + return false; + } + + public function resolveArchivePath(string $archivePath, ?string $workingDirectory = null): string + { + $archivePath = trim($archivePath); + if ($archivePath === '') { + throw new DisplayException('Provide a path to a local .M12LabsExtension package file.'); + } + + if (Str::startsWith($archivePath, 'file://')) { + $archivePath = rawurldecode(substr($archivePath, 7)); + } + + $candidates = [$archivePath]; + + if (!Str::startsWith($archivePath, '/')) { + if ($workingDirectory) { + $candidates[] = rtrim($workingDirectory, '/') . '/' . $archivePath; + } + + $candidates[] = base_path($archivePath); + } + + foreach ($candidates as $candidate) { + $resolved = realpath($candidate); + if ($resolved && is_file($resolved) && $this->isSupportedArchiveName($resolved)) { + return $resolved; + } + } + + throw new DisplayException(sprintf('The extension package file "%s" was not found.', $archivePath)); + } + + // --------------------------------------------------------------------------- + // Shared archive helpers — used by install and update services + // --------------------------------------------------------------------------- + + public function downloadArchive(string $location, string $destination): void + { + if (Str::startsWith($location, ['http://', 'https://'])) { + $response = Http::timeout(120)->withOptions(['sink' => $destination])->get($location); + if (!$response->successful()) { + throw new DisplayException(sprintf('Unable to download extension archive from "%s".', $location)); + } + + return; + } + + $sourcePath = Str::startsWith($location, 'file://') ? rawurldecode(substr($location, 7)) : $location; + if (!is_file($sourcePath)) { + throw new DisplayException(sprintf('Extension archive "%s" was not found.', $sourcePath)); + } + + File::copy($sourcePath, $destination); + } + + public function verifyChecksum(string $path, string $expectedChecksum, string $label): void + { + if (hash_file('sha256', $path) !== $expectedChecksum) { + throw new DisplayException(sprintf('The %s checksum did not match the manifest.', $label)); + } + } + + public function extractArchive(string $archivePath, string $extractPath): void + { + $zip = new ZipArchive(); + if ($zip->open($archivePath) !== true) { + throw new DisplayException('The downloaded extension archive could not be opened.'); + } + + if (!$zip->extractTo($extractPath)) { + $zip->close(); + + throw new DisplayException('The downloaded extension archive could not be extracted.'); + } + + $zip->close(); + } + + /** + * @return array + */ + public function readPackageManifest(string $extractPath): array + { + $manifestPath = $extractPath . '/' . self::MANIFEST_FILENAME; + if (!is_file($manifestPath)) { + throw new DisplayException('The extension archive did not include an m12labs-extension.json manifest.'); + } + + $manifest = json_decode(File::get($manifestPath), true, 512, JSON_THROW_ON_ERROR); + if (!is_array($manifest)) { + throw new DisplayException('The extension package manifest is invalid.'); + } + + return $manifest; + } + + /** + * Validate the manifest's extension id / version against expected values and return it unchanged. + * + * @param array $manifest + * @return array + */ + public function normalizeManifest(array $manifest, ?string $expectedExtensionId = null, ?string $expectedVersion = null): array + { + $extensionId = trim((string) Arr::get($manifest, 'extension.id', '')); + $version = trim((string) Arr::get($manifest, 'package.version', '')); + + if ($extensionId === '' || $version === '') { + throw new DisplayException('The extension package manifest is missing required metadata.'); + } + + if ($expectedExtensionId !== null && $extensionId !== $expectedExtensionId) { + throw new DisplayException('The downloaded package does not match the requested extension id.'); + } + + if ($expectedVersion !== null && $version !== $expectedVersion) { + throw new DisplayException('The downloaded package version does not match the repository manifest.'); + } + + return $manifest; + } + + /** + * @param array $versions + */ + public function assertCompatiblePanelVersions(array $versions): void + { + $versions = array_values(array_filter($versions, 'is_string')); + if ($versions === []) { + return; + } + + $currentVersion = (string) config('app.version'); + if (!in_array($currentVersion, $versions, true)) { + throw new DisplayException(sprintf( + 'This extension package supports M12Labs panel versions %s. The current panel version is %s.', + implode(', ', $versions), + $currentVersion + )); + } + } + + public function normalizeTargetPath(string $path, string $extensionId): string + { + $normalized = str_replace('\\', '/', trim($path)); + $normalized = trim($normalized, '/'); + + if ($normalized === '' || Str::contains($normalized, ['../', '..\\']) || Str::startsWith($normalized, '/')) { + throw new DisplayException('The extension package includes an unsafe target path.'); + } + + $allowedPrefixes = [ + sprintf('app/Extensions/Packages/%s/', $extensionId), + sprintf('resources/scripts/extensions/packages/%s/', $extensionId), + ]; + + foreach ($allowedPrefixes as $prefix) { + if (Str::startsWith($normalized, $prefix)) { + return $normalized; + } + } + + throw new DisplayException(sprintf('The package target path "%s" is not allowed by M12Labs.', $normalized)); + } + + private function isSupportedArchiveName(string $path): bool + { + return Str::endsWith(Str::lower($path), ['.m12labsextension', '.zip']); + } +} \ No newline at end of file diff --git a/app/Services/Extensions/ExtensionPackageBatchService.php b/app/Services/Extensions/ExtensionPackageBatchService.php new file mode 100644 index 0000000000..063e9a48bb --- /dev/null +++ b/app/Services/Extensions/ExtensionPackageBatchService.php @@ -0,0 +1,267 @@ + $items + * @return array + */ + public function batchInstall(array $items): array + { + if ($items === []) { + return []; + } + + return $this->operationLockService->withinLock('install', 'batch', function () use ($items) { + $preparedList = []; + $total = count($items); + $allExtensionIds = array_column($items, 'extensionId'); + + try { + foreach ($items as $index => $item) { + $current = $index + 1; + $this->progressService->report('batch-install', $item['extensionId'], 'downloading', $total, $current, $allExtensionIds); + $prepared = $this->installService->prepareInstall( + $item['extensionId'], + (int) $item['repositoryId'], + $item['version'] ?? null + ); + $preparedList[] = $prepared; + } + + // Rebuild the panel once for all prepared installs. + $lastExtensionId = end($preparedList)['extensionId'] ?? 'unknown'; + $this->rebuildService->rebuild( + sprintf('Batch install %d extension(s)', $total), + function (int $cmdIndex) use ($lastExtensionId, $total, $allExtensionIds): void { + $this->progressService->report( + 'batch-install', + $lastExtensionId, + $cmdIndex === 0 ? 'optimizing' : 'building', + $total, + $total, + $allExtensionIds + ); + } + ); + + // Finalize all installs (DB registration) after the rebuild succeeded. + $this->progressService->report('batch-install', $lastExtensionId, 'registering', $total, $total, $allExtensionIds); + $packages = []; + foreach ($preparedList as $prepared) { + $packages[] = $this->installService->finalizeInstall($prepared); + } + + $this->progressService->report('batch-install', $lastExtensionId, 'completed', $total, $total, $allExtensionIds); + + return $packages; + } catch (\Throwable $exception) { + foreach ($preparedList as $prepared) { + $this->installService->rollbackInstall($prepared); + } + + $this->attemptRollbackRebuild('batch-install'); + + if ($exception instanceof DisplayException) { + throw $exception; + } + + throw new DisplayException('Failed to complete the batch install.', $exception); + } finally { + $this->progressService->clear(); + foreach ($preparedList as $prepared) { + $this->ownershipService->repairStandardPaths($prepared['extensionId'] ?? null); + $this->installService->cleanupPreparedInstall($prepared); + } + } + }); + } + + /** + * Uninstall multiple extensions, performing all file removal first and rebuilding + * the panel only once after every extension's files have been removed. + * + * @param array $extensionIds + */ + public function batchUninstall(array $extensionIds): void + { + if ($extensionIds === []) { + return; + } + + $this->operationLockService->withinLock('uninstall', 'batch', function () use ($extensionIds) { + $preparedList = []; + $total = count($extensionIds); + $allExtensionIds = array_values($extensionIds); + + try { + foreach ($extensionIds as $index => $extensionId) { + $current = $index + 1; + $this->progressService->report('batch-uninstall', $extensionId, 'validating', $total, $current, $allExtensionIds); + $prepared = $this->uninstallService->prepareUninstall($extensionId); + $preparedList[] = $prepared; + } + + // Rebuild the panel once for all prepared uninstalls. + $lastExtensionId = end($preparedList)['extensionId'] ?? 'unknown'; + $this->rebuildService->rebuild( + sprintf('Batch uninstall %d extension(s)', $total), + function (int $cmdIndex) use ($lastExtensionId, $total, $allExtensionIds): void { + $this->progressService->report( + 'batch-uninstall', + $lastExtensionId, + $cmdIndex === 0 ? 'optimizing' : 'building', + $total, + $total, + $allExtensionIds + ); + } + ); + + // Finalize all uninstalls (DB deletion) after the rebuild succeeded. + $this->progressService->report('batch-uninstall', $lastExtensionId, 'registering', $total, $total, $allExtensionIds); + foreach ($preparedList as $prepared) { + $this->uninstallService->finalizeUninstall($prepared); + } + + $this->progressService->report('batch-uninstall', $lastExtensionId, 'completed', $total, $total, $allExtensionIds); + } catch (\Throwable $exception) { + foreach ($preparedList as $prepared) { + $this->uninstallService->rollbackUninstall($prepared); + } + + $this->attemptRollbackRebuild('batch-uninstall'); + + if ($exception instanceof DisplayException) { + throw $exception; + } + + throw new DisplayException('Failed to complete the batch uninstall.', $exception); + } finally { + $this->progressService->clear(); + foreach ($preparedList as $prepared) { + $this->ownershipService->repairStandardPaths($prepared['extensionId'] ?? null); + $this->uninstallService->cleanupPreparedUninstall($prepared); + } + } + }); + } + + /** + * Update multiple extensions, performing all file operations first and rebuilding + * the panel only once after every extension's files have been swapped into place. + * + * @param array $items + * @return array + */ + public function batchUpdate(array $items): array + { + if ($items === []) { + return []; + } + + return $this->operationLockService->withinLock('update', 'batch', function () use ($items) { + $preparedList = []; + $total = count($items); + $allExtensionIds = array_column($items, 'extensionId'); + + try { + foreach ($items as $index => $item) { + $current = $index + 1; + $this->progressService->report('batch-update', $item['extensionId'], 'downloading', $total, $current, $allExtensionIds); + $prepared = $this->updateService->prepareUpdate( + $item['extensionId'], + (int) $item['repositoryId'], + $item['version'] ?? null + ); + $preparedList[] = $prepared; + } + + // Rebuild the panel once for all prepared updates. + $lastExtensionId = end($preparedList)['extensionId'] ?? 'unknown'; + $this->rebuildService->rebuild( + sprintf('Batch update %d extension(s)', $total), + function (int $cmdIndex) use ($lastExtensionId, $total, $allExtensionIds): void { + $this->progressService->report( + 'batch-update', + $lastExtensionId, + $cmdIndex === 0 ? 'optimizing' : 'building', + $total, + $total, + $allExtensionIds + ); + } + ); + + // Finalize all updates (DB records) after the rebuild succeeded. + $this->progressService->report('batch-update', $lastExtensionId, 'registering', $total, $total, $allExtensionIds); + $packages = []; + foreach ($preparedList as $prepared) { + $packages[] = $this->updateService->finalizeUpdate($prepared); + } + + $this->progressService->report('batch-update', $lastExtensionId, 'completed', $total, $total, $allExtensionIds); + + return $packages; + } catch (\Throwable $exception) { + foreach ($preparedList as $prepared) { + $this->updateService->rollbackUpdate($prepared); + } + + $this->attemptRollbackRebuild('batch-update'); + + if ($exception instanceof DisplayException) { + throw $exception; + } + + throw new DisplayException('Failed to complete the batch update.', $exception); + } finally { + $this->progressService->clear(); + foreach ($preparedList as $prepared) { + $this->ownershipService->repairStandardPaths($prepared['extensionId'] ?? null); + $this->updateService->cleanupPreparedUpdate($prepared); + } + } + }); + } + + private function attemptRollbackRebuild(string $reason): void + { + try { + $this->rebuildService->rebuild(sprintf('%s rollback', $reason)); + } catch (\Throwable $exception) { + report($exception); + } + } +} diff --git a/app/Services/Extensions/ExtensionPackageFileService.php b/app/Services/Extensions/ExtensionPackageFileService.php new file mode 100644 index 0000000000..7929164b8f --- /dev/null +++ b/app/Services/Extensions/ExtensionPackageFileService.php @@ -0,0 +1,97 @@ + $files + * @param string $verb Human-readable operation verb for the error message (e.g. 'uninstalled', 'updated'). + */ + public function assertFilesUnmodified(array $files, string $verb): void + { + $modified = []; + + foreach ($files as $file) { + $targetPath = base_path($file->path); + if (!is_file($targetPath)) { + $modified[] = $file->path; + continue; + } + + if (hash_file('sha256', $targetPath) !== $file->installed_checksum) { + $modified[] = $file->path; + } + } + + if ($modified === []) { + return; + } + + $preview = implode(', ', array_slice($modified, 0, 5)); + $suffix = count($modified) > 5 ? ', and more' : ''; + + throw new DisplayException(sprintf( + 'The extension cannot be %s because these files were modified after installation: %s%s.', + $verb, + $preview, + $suffix + )); + } + + /** + * Copy all currently-present tracked files into $rollbackRoot, preserving relative paths. + * + * @param array $files + */ + public function createRollbackSnapshot(array $files, string $rollbackRoot): void + { + foreach ($files as $file) { + $targetPath = base_path($file->path); + if (!is_file($targetPath)) { + continue; + } + + $rollbackPath = $rollbackRoot . '/' . $file->path; + File::ensureDirectoryExists(dirname($rollbackPath)); + File::copy($targetPath, $rollbackPath); + } + } + + /** + * Restore tracked files from a snapshot directory created by createRollbackSnapshot(). + * + * @param array $files + */ + public function restoreRollbackSnapshot(array $files, string $rollbackRoot): void + { + foreach ($files as $file) { + $rollbackPath = $rollbackRoot . '/' . $file->path; + if (!is_file($rollbackPath)) { + continue; + } + + $targetPath = base_path($file->path); + $this->ownershipService->ensureWritablePath($targetPath, $file->path); + File::ensureDirectoryExists(dirname($targetPath)); + File::copy($rollbackPath, $targetPath); + } + } +} diff --git a/app/Services/Extensions/ExtensionPackageInstallService.php b/app/Services/Extensions/ExtensionPackageInstallService.php new file mode 100644 index 0000000000..d7f550b5eb --- /dev/null +++ b/app/Services/Extensions/ExtensionPackageInstallService.php @@ -0,0 +1,512 @@ +operationLockService->withinLock('install', $extensionId, function () use ($extensionId, $repositoryId, $version) { + $prepared = null; + try { + $prepared = $this->prepareInstall($extensionId, $repositoryId, $version); + + $this->rebuildService->rebuild( + sprintf('Install extension %s', $prepared['extensionId']), + function (int $index) use ($prepared): void { + $this->progressService->report( + 'install', + $prepared['extensionId'], + $index === 0 ? 'optimizing' : 'building' + ); + } + ); + + $this->progressService->report('install', $prepared['extensionId'], 'registering'); + $packageModel = $this->finalizeInstall($prepared); + $this->progressService->report('install', $prepared['extensionId'], 'completed'); + + return $packageModel->fresh(['repository', 'files']); + } catch (\Throwable $exception) { + if ($prepared !== null) { + $this->rollbackInstall($prepared); + $this->attemptRollbackRebuild($prepared['extensionId'], 'install rollback'); + } + + if ($exception instanceof DisplayException) { + throw $exception; + } + + throw new DisplayException('Failed to install the selected extension package.', $exception); + } finally { + $this->progressService->clear(); + if ($prepared !== null) { + $this->cleanupPreparedInstall($prepared); + } + } + }); + } + + public function installFromArchive(string $archivePath, ?string $sourceLabel = null, bool $skipScan = false): ExtensionPackage + { + $resolvedArchivePath = $this->artifactService->resolveArchivePath($archivePath); + $this->assertSupportedArchiveArtifact($resolvedArchivePath); + + return $this->operationLockService->withinLock('install', basename($resolvedArchivePath), function () use ($resolvedArchivePath, $sourceLabel, $skipScan) { + $prepared = null; + try { + $prepared = $this->performInstallFileOps( + archiveLocation: $resolvedArchivePath, + expectedExtensionId: null, + expectedVersion: null, + expectedArchiveChecksum: null, + compatiblePanelVersions: [], + sourceRepositoryId: null, + sourceRepositoryName: $sourceLabel ?: 'Manual package file', + sourceRegistryUrl: null, + sourceArchiveUrl: 'file://' . $resolvedArchivePath, + fallbackPackageMetadata: [], + skipScan: $skipScan, + ); + + $this->rebuildService->rebuild( + sprintf('Install extension %s', $prepared['extensionId']), + function (int $index) use ($prepared): void { + $this->progressService->report( + 'install', + $prepared['extensionId'], + $index === 0 ? 'optimizing' : 'building' + ); + } + ); + + $this->progressService->report('install', $prepared['extensionId'], 'registering'); + $packageModel = $this->finalizeInstall($prepared); + $this->progressService->report('install', $prepared['extensionId'], 'completed'); + + return $packageModel->fresh(['repository', 'files']); + } catch (\Throwable $exception) { + if ($prepared !== null) { + $this->rollbackInstall($prepared); + $this->attemptRollbackRebuild($prepared['extensionId'], 'install rollback'); + } + + if ($exception instanceof DisplayException) { + throw $exception; + } + + throw new DisplayException('Failed to install the selected extension package.', $exception); + } finally { + $this->progressService->clear(); + if ($prepared !== null) { + $this->cleanupPreparedInstall($prepared); + } + } + }); + } + + /** + * Prepare an extension install: download, extract, validate, and copy files into place. + * Does NOT rebuild the panel or write to the database. + * + * Used by the batch service to prepare multiple extensions before a single rebuild. + * After calling this for each extension, call ExtensionPanelRebuildService::rebuild() + * once, then finalizeInstall() for each prepared result. + * + * @return array Opaque prepared state; pass to finalizeInstall() and rollbackInstall(). + */ + public function prepareInstall(string $extensionId, int $repositoryId, ?string $version = null): array + { + $package = $this->catalogService->findRepositoryPackage($extensionId, $repositoryId, $version); + $release = $package['latestRelease']; + + return $this->performInstallFileOps( + archiveLocation: $release['archiveUrl'], + expectedExtensionId: $extensionId, + expectedVersion: $release['version'], + expectedArchiveChecksum: $release['archiveChecksum'], + compatiblePanelVersions: $release['compatiblePanelVersions'] ?? [], + sourceRepositoryId: $package['repository']->id, + sourceRepositoryName: $package['repository']->name, + sourceRegistryUrl: $package['repository']->manifest_url, + sourceArchiveUrl: $release['archiveUrl'], + fallbackPackageMetadata: $package, + ); + } + + /** + * Finalize an install prepared via prepareInstall(): write the package record to the database. + * Must be called after the panel has been rebuilt. + * + * @param array $prepared + */ + public function finalizeInstall(array $prepared): ExtensionPackage + { + return DB::transaction(function () use ($prepared) { + return $this->persistInstalledPackage( + extensionId: $prepared['extensionId'], + normalizedManifest: $prepared['normalizedManifest'], + fallbackPackageMetadata: $prepared['fallbackPackageMetadata'], + filePlans: $prepared['filePlans'], + sourceRepositoryId: $prepared['sourceRepositoryId'], + sourceRepositoryName: $prepared['sourceRepositoryName'], + sourceRegistryUrl: $prepared['sourceRegistryUrl'], + sourceArchiveUrl: $prepared['sourceArchiveUrl'], + archiveChecksum: $prepared['archiveChecksum'], + ); + }); + } + + /** + * Roll back a prepared install by reverting copied files to their pre-install state. + * + * @param array $prepared + */ + public function rollbackInstall(array $prepared): void + { + $this->rollbackAppliedFiles($prepared['appliedFiles'] ?? []); + $this->ownershipService->repairStandardPaths($prepared['extensionId'] ?? null); + } + + /** + * Clean up temp files associated with a prepared install. + * + * @param array $prepared + */ + public function cleanupPreparedInstall(array $prepared): void + { + if (!empty($prepared['tempRoot'])) { + File::deleteDirectory($prepared['tempRoot']); + } + } + + /** + * Perform the file-operations phase of an install (download → extract → validate → copy). + * Returns the prepared state needed to finalize or roll back. + * + * @param array $fallbackPackageMetadata + * @param array $compatiblePanelVersions + * @return array + */ + private function performInstallFileOps( + string $archiveLocation, + ?string $expectedExtensionId, + ?string $expectedVersion, + ?string $expectedArchiveChecksum, + array $compatiblePanelVersions, + ?int $sourceRepositoryId, + ?string $sourceRepositoryName, + ?string $sourceRegistryUrl, + string $sourceArchiveUrl, + array $fallbackPackageMetadata, + bool $skipScan = false + ): array { + $tempRoot = storage_path('app/extensions/tmp/' . Str::uuid()->toString()); + $archivePath = $tempRoot . '/' . ExtensionPackageArtifactService::PACKAGE_ARTIFACT_FILENAME; + $extractPath = $tempRoot . '/extract'; + $appliedFiles = []; + $resolvedExtensionId = $expectedExtensionId; + + File::ensureDirectoryExists($tempRoot); + File::ensureDirectoryExists($extractPath); + + try { + $this->progressService->report('install', $resolvedExtensionId ?? 'unknown', 'downloading'); + $this->artifactService->downloadArchive($archiveLocation, $archivePath); + + // Security scan runs on the downloaded archive before extraction. + if (!$skipScan) { + $this->progressService->report('install', $resolvedExtensionId ?? 'unknown', 'scanning'); + $this->runArchiveScan($archivePath, $resolvedExtensionId ?? 'unknown', 'install'); + } + + $this->progressService->report('install', $resolvedExtensionId ?? 'unknown', 'extracting'); + $archiveChecksum = hash_file('sha256', $archivePath); + if ($expectedArchiveChecksum !== null) { + $this->artifactService->verifyChecksum($archivePath, $expectedArchiveChecksum, 'archive'); + } + $this->artifactService->extractArchive($archivePath, $extractPath); + + $this->progressService->report('install', $resolvedExtensionId ?? 'unknown', 'validating'); + $manifest = $this->artifactService->readPackageManifest($extractPath); + $normalizedManifest = $this->artifactService->normalizeManifest($manifest, $expectedExtensionId, $expectedVersion); + $extensionId = (string) Arr::get($normalizedManifest, 'extension.id'); + $resolvedExtensionId = $extensionId; + $backupRoot = storage_path('app/extensions/backups/' . $extensionId . '/' . Str::uuid()->toString()); + + $this->assertExtensionNotInstalled($extensionId); + $this->artifactService->assertCompatiblePanelVersions($compatiblePanelVersions); + $this->artifactService->assertCompatiblePanelVersions(Arr::get($normalizedManifest, 'compatiblePanelVersions', [])); + $this->ownershipService->repairStandardPaths($extensionId); + + $filePlans = $this->prepareFilePlans($extractPath, $normalizedManifest, $backupRoot, $extensionId); + $this->assertWritableInstallTargets($filePlans); + + $this->progressService->report('install', $extensionId, 'copying'); + foreach ($filePlans as $plan) { + File::ensureDirectoryExists(dirname($plan['targetPath'])); + File::copy($plan['sourcePath'], $plan['targetPath']); + $appliedFiles[] = $plan; + } + + return [ + 'extensionId' => $extensionId, + 'normalizedManifest' => $normalizedManifest, + 'fallbackPackageMetadata' => $fallbackPackageMetadata, + 'filePlans' => $filePlans, + 'appliedFiles' => $appliedFiles, + 'sourceRepositoryId' => $sourceRepositoryId, + 'sourceRepositoryName' => $sourceRepositoryName, + 'sourceRegistryUrl' => $sourceRegistryUrl, + 'sourceArchiveUrl' => $sourceArchiveUrl, + 'archiveChecksum' => is_string($archiveChecksum) ? $archiveChecksum : null, + 'tempRoot' => $tempRoot, + ]; + } catch (\Throwable $exception) { + $this->rollbackAppliedFiles($appliedFiles); + $this->ownershipService->repairStandardPaths($resolvedExtensionId); + File::deleteDirectory($tempRoot); + + if ($exception instanceof DisplayException) { + throw $exception; + } + + throw new DisplayException('Failed to prepare the extension package for installation.', $exception); + } + } + + /** + * @param array $normalizedManifest + * @param array $fallbackPackageMetadata + * @param array> $filePlans + */ + private function persistInstalledPackage( + string $extensionId, + array $normalizedManifest, + array $fallbackPackageMetadata, + array $filePlans, + ?int $sourceRepositoryId, + ?string $sourceRepositoryName, + ?string $sourceRegistryUrl, + ?string $sourceArchiveUrl, + ?string $archiveChecksum + ): ExtensionPackage { + $packageModel = ExtensionPackage::query()->create([ + 'extension_id' => $extensionId, + 'package_id' => Arr::get($normalizedManifest, 'package.id', Arr::get($fallbackPackageMetadata, 'id', $extensionId)), + 'name' => Arr::get($normalizedManifest, 'extension.name', Arr::get($fallbackPackageMetadata, 'name', $extensionId)), + 'description' => Arr::get($normalizedManifest, 'extension.description', Arr::get($fallbackPackageMetadata, 'description', '')), + 'author' => Arr::get($normalizedManifest, 'extension.author', Arr::get($fallbackPackageMetadata, 'author', 'M12Labs')), + 'icon' => Arr::get($normalizedManifest, 'extension.icon', Arr::get($fallbackPackageMetadata, 'icon', 'puzzle')), + 'route' => Arr::get($normalizedManifest, 'extension.route', Arr::get($fallbackPackageMetadata, 'route', $extensionId)), + 'installed_version' => Arr::get($normalizedManifest, 'package.version'), + 'source_repository_id' => $sourceRepositoryId, + 'source_repository_name' => $sourceRepositoryName, + 'source_registry_url' => $sourceRegistryUrl, + 'source_archive_url' => $sourceArchiveUrl, + 'package_checksum' => $archiveChecksum, + 'manifest' => $normalizedManifest, + 'installed_at' => now(), + ]); + + foreach ($filePlans as $plan) { + ExtensionPackageFile::query()->create([ + 'extension_package_id' => $packageModel->id, + 'path' => $plan['path'], + 'operation' => $plan['operation'], + 'installed_checksum' => $plan['checksum'], + 'backup_path' => $plan['backupPath'], + 'backup_checksum' => $plan['backupChecksum'], + ]); + } + + ExtensionConfig::query()->firstOrCreate( + ['extension_id' => $extensionId], + [ + 'enabled' => (bool) Arr::get($normalizedManifest, 'extension.defaults.enabled', false), + 'allowed_nests' => Arr::get($normalizedManifest, 'extension.defaults.allowedNests', []), + 'allowed_eggs' => Arr::get($normalizedManifest, 'extension.defaults.allowedEggs', []), + 'settings' => Arr::get($normalizedManifest, 'extension.defaults.settings', []), + ] + ); + + return $packageModel; + } + + private function assertExtensionNotInstalled(string $extensionId): void + { + if (ExtensionPackage::query()->where('extension_id', $extensionId)->exists()) { + throw new DisplayException('This extension is already installed. Uninstall it before installing it again.'); + } + } + + private function assertSupportedArchiveArtifact(string $archivePath): void + { + if (!Str::endsWith(Str::lower($archivePath), ['.m12labsextension', '.zip'])) { + throw new DisplayException('Manual installs expect a .M12LabsExtension package file. Legacy .zip artifacts are still supported for compatibility.'); + } + } + + /** + * @param array $manifest + * @return array> + */ + private function prepareFilePlans(string $extractPath, array $manifest, string $backupRoot, string $extensionId): array + { + $plans = []; + $files = Arr::get($manifest, 'files', []); + if (!is_array($files) || $files === []) { + throw new DisplayException('The extension package manifest does not declare any installable files.'); + } + + foreach ($files as $file) { + if (!is_array($file)) { + continue; + } + + $path = $this->artifactService->normalizeTargetPath((string) ($file['path'] ?? ''), $extensionId); + $checksum = trim((string) ($file['sha256'] ?? '')); + + if ($path === '' || $checksum === '') { + throw new DisplayException('The extension package manifest contains an invalid file entry.'); + } + + $sourcePath = $extractPath . '/' . $path; + if (!is_file($sourcePath)) { + throw new DisplayException(sprintf('The extension package is missing "%s".', $path)); + } + + $this->artifactService->verifyChecksum($sourcePath, $checksum, sprintf('file "%s"', $path)); + + if (ExtensionPackageFile::query()->where('path', $path)->exists()) { + throw new DisplayException(sprintf('The path "%s" is already managed by another installed extension.', $path)); + } + + $targetPath = base_path($path); + $backupPath = null; + $backupChecksum = null; + $operation = is_file($targetPath) ? 'updated' : 'created'; + + if ($operation === 'updated') { + $backupPath = $backupRoot . '/' . $path; + File::ensureDirectoryExists(dirname($backupPath)); + File::copy($targetPath, $backupPath); + $backupChecksum = hash_file('sha256', $backupPath); + } + + $plans[] = [ + 'path' => $path, + 'sourcePath' => $sourcePath, + 'targetPath' => $targetPath, + 'operation' => $operation, + 'checksum' => $checksum, + 'backupPath' => $backupPath, + 'backupChecksum' => $backupChecksum, + ]; + } + + return $plans; + } + + /** + * @param array> $filePlans + */ + private function assertWritableInstallTargets(array $filePlans): void + { + foreach ($filePlans as $plan) { + $this->ownershipService->ensureWritablePath($plan['targetPath'], $plan['path']); + } + } + + /** + * @param array> $appliedFiles + */ + private function rollbackAppliedFiles(array $appliedFiles): void + { + foreach (array_reverse($appliedFiles) as $plan) { + if (!empty($plan['backupPath']) && is_file($plan['backupPath'])) { + File::ensureDirectoryExists(dirname($plan['targetPath'])); + File::copy($plan['backupPath'], $plan['targetPath']); + + continue; + } + + if (is_file($plan['targetPath'])) { + File::delete($plan['targetPath']); + } + } + } + + private function attemptRollbackRebuild(string $extensionId, string $reason): void + { + try { + $this->rebuildService->rebuild(sprintf('%s for %s', $reason, $extensionId)); + } catch (\Throwable $exception) { + report($exception); + } + } + + /** + * Run the security scanner on a downloaded archive. + * Throws DisplayException if the scan is BLOCKED. + * Logs a warning if WARNED but continues. + * Scanner errors are logged but do not block installation. + */ + private function runArchiveScan(string $archivePath, string $extensionId, string $action): void + { + try { + $scanResult = $this->scanner->scan($archivePath); + + if ($scanResult->isBlocked()) { + $summary = $scanResult->toArray()['summary']; + throw new DisplayException(sprintf( + 'Security scan BLOCKED %s of "%s": %d high-severity finding(s) detected. Review the scan report at: %s', + $action, + $extensionId, + $summary['high'], + $scanResult->reportPath + )); + } + + if ($scanResult->hasSevereFindings()) { + Log::warning('Extension security scan found warnings.', [ + 'action' => $action, + 'extension' => $extensionId, + 'warnings' => $scanResult->toArray()['summary']['warnings'], + 'report' => $scanResult->reportPath, + ]); + } + } catch (DisplayException $e) { + throw $e; + } catch (\Throwable $e) { + // Scanner errors (missing binaries, corrupt archives, etc.) are logged + // but do not block installation so a missing tool never prevents installs. + Log::error('Extension security scanner encountered an error.', [ + 'action' => $action, + 'extension' => $extensionId, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Services/Extensions/ExtensionPackageUninstallService.php b/app/Services/Extensions/ExtensionPackageUninstallService.php new file mode 100644 index 0000000000..689e2a0625 --- /dev/null +++ b/app/Services/Extensions/ExtensionPackageUninstallService.php @@ -0,0 +1,207 @@ +operationLockService->withinLock('uninstall', $extensionId, function () use ($extensionId) { + $prepared = null; + try { + $prepared = $this->prepareUninstall($extensionId); + + $this->rebuildService->rebuild( + sprintf('Uninstall extension %s', $extensionId), + function (int $index) use ($extensionId): void { + $this->progressService->report( + 'uninstall', + $extensionId, + $index === 0 ? 'optimizing' : 'building' + ); + } + ); + + $this->progressService->report('uninstall', $extensionId, 'registering'); + $this->finalizeUninstall($prepared); + $this->progressService->report('uninstall', $extensionId, 'completed'); + } catch (\Throwable $exception) { + if ($prepared !== null) { + $this->rollbackUninstall($prepared); + $this->attemptRollbackRebuild($extensionId, 'uninstall rollback'); + } + + if ($exception instanceof DisplayException) { + throw $exception; + } + + throw new DisplayException('Failed to uninstall the selected extension package.', $exception); + } finally { + $this->progressService->clear(); + $this->ownershipService->repairStandardPaths($extensionId); + if ($prepared !== null) { + $this->cleanupPreparedUninstall($prepared); + } + } + }); + } + + /** + * Prepare an extension uninstall: validate files, snapshot for rollback, and remove files from disk. + * Does NOT rebuild the panel or modify the database. + * + * Used by the batch service to prepare multiple extensions before a single rebuild. + * After calling this for each extension, call ExtensionPanelRebuildService::rebuild() + * once, then finalizeUninstall() for each prepared result. + * + * @return array Opaque prepared state; pass to finalizeUninstall() and rollbackUninstall(). + */ + public function prepareUninstall(string $extensionId): array + { + $package = ExtensionPackage::query()->with('files')->where('extension_id', $extensionId)->first(); + if (!$package) { + throw new DisplayException('That extension is not installed through the repository system.'); + } + + $files = $package->files->sortByDesc(fn (ExtensionPackageFile $file) => substr_count($file->path, '/'))->values(); + $rollbackRoot = storage_path('app/extensions/tmp-uninstall/' . Str::uuid()->toString()); + File::ensureDirectoryExists($rollbackRoot); + $this->ownershipService->repairStandardPaths($extensionId); + + $this->progressService->report('uninstall', $extensionId, 'validating'); + $this->fileService->assertFilesUnmodified($files->all(), 'uninstalled'); + $this->fileService->createRollbackSnapshot($files->all(), $rollbackRoot); + $this->assertWritableUninstallTargets($files->all()); + + try { + $this->progressService->report('uninstall', $extensionId, 'removing'); + foreach ($files as $file) { + $targetPath = base_path($file->path); + + if ($file->operation === 'updated') { + if (!$file->backup_path || !is_file($file->backup_path)) { + throw new DisplayException(sprintf('The backup for "%s" is missing, so the extension cannot be uninstalled safely.', $file->path)); + } + + File::ensureDirectoryExists(dirname($targetPath)); + File::copy($file->backup_path, $targetPath); + + continue; + } + + if (is_file($targetPath)) { + File::delete($targetPath); + } + } + + return [ + 'extensionId' => $extensionId, + 'package' => $package, + 'files' => $files, + 'rollbackRoot' => $rollbackRoot, + ]; + } catch (\Throwable $exception) { + $this->fileService->restoreRollbackSnapshot($files->all(), $rollbackRoot); + File::deleteDirectory($rollbackRoot); + $this->ownershipService->repairStandardPaths($extensionId); + + if ($exception instanceof DisplayException) { + throw $exception; + } + + throw new DisplayException('Failed to prepare the extension for uninstallation.', $exception); + } + } + + /** + * Finalize an uninstall prepared via prepareUninstall(): delete the package record from the database. + * Must be called after the panel has been rebuilt. + * + * @param array $prepared + */ + public function finalizeUninstall(array $prepared): void + { + $package = $prepared['package']; + $files = $prepared['files']; + $extensionId = $prepared['extensionId']; + + DB::transaction(function () use ($package, $files, $extensionId) { + foreach ($files as $file) { + if ($file->backup_path && is_file($file->backup_path)) { + File::delete($file->backup_path); + } + } + + $package->delete(); + + ExtensionConfig::query()->where('extension_id', $extensionId)->update(['enabled' => false]); + }); + } + + /** + * Roll back a prepared uninstall by restoring files from the rollback snapshot. + * + * @param array $prepared + */ + public function rollbackUninstall(array $prepared): void + { + $this->fileService->restoreRollbackSnapshot($prepared['files']->all(), $prepared['rollbackRoot']); + $this->ownershipService->repairStandardPaths($prepared['extensionId']); + } + + /** + * Clean up temp files associated with a prepared uninstall. + * + * @param array $prepared + */ + public function cleanupPreparedUninstall(array $prepared): void + { + if (!empty($prepared['rollbackRoot'])) { + File::deleteDirectory($prepared['rollbackRoot']); + } + } + + /** + * @param array $files + */ + private function assertWritableUninstallTargets(array $files): void + { + foreach ($files as $file) { + $targetPath = base_path($file->path); + + if ($file->operation === 'updated') { + $this->ownershipService->ensureWritablePath($targetPath, $file->path); + + continue; + } + + $this->ownershipService->ensureRemovablePath($targetPath, $file->path); + } + } + + private function attemptRollbackRebuild(string $extensionId, string $reason): void + { + try { + $this->rebuildService->rebuild(sprintf('%s for %s', $reason, $extensionId)); + } catch (\Throwable $exception) { + report($exception); + } + } +} diff --git a/app/Services/Extensions/ExtensionPackageUpdateService.php b/app/Services/Extensions/ExtensionPackageUpdateService.php new file mode 100644 index 0000000000..3d8985d9a5 --- /dev/null +++ b/app/Services/Extensions/ExtensionPackageUpdateService.php @@ -0,0 +1,584 @@ +operationLockService->withinLock('update', $extensionId, function () use ($extensionId, $repositoryId, $version) { + $prepared = null; + try { + $prepared = $this->prepareUpdate($extensionId, $repositoryId, $version); + + $this->rebuildService->rebuild( + sprintf('Update extension %s', $prepared['extensionId']), + function (int $index) use ($prepared): void { + $this->progressService->report( + 'update', + $prepared['extensionId'], + $index === 0 ? 'optimizing' : 'building' + ); + } + ); + + $this->progressService->report('update', $prepared['extensionId'], 'registering'); + $packageModel = $this->finalizeUpdate($prepared); + $this->progressService->report('update', $prepared['extensionId'], 'completed'); + + return $packageModel->fresh(['repository', 'files']); + } catch (\Throwable $exception) { + if ($prepared !== null) { + $this->rollbackUpdate($prepared); + $this->attemptRollbackRebuild($prepared['extensionId'], 'update rollback'); + } + + if ($exception instanceof DisplayException) { + throw $exception; + } + + throw new DisplayException('Failed to update the selected extension package.', $exception); + } finally { + $this->progressService->clear(); + if ($prepared !== null) { + $this->ownershipService->repairStandardPaths($prepared['extensionId']); + $this->cleanupPreparedUpdate($prepared); + } + } + }); + } + + /** + * Update an extension from a local .M12LabsExtension archive. + */ + public function updateFromArchive(string $archivePath, ?string $sourceLabel = null, bool $skipScan = false): ExtensionPackage + { + $resolvedPath = $this->artifactService->resolveArchivePath($archivePath); + $this->assertSupportedArchiveArtifact($resolvedPath); + + return $this->operationLockService->withinLock('update', basename($resolvedPath), function () use ($resolvedPath, $sourceLabel, $skipScan) { + $prepared = null; + try { + $prepared = $this->performUpdateFileOps( + archiveLocation: $resolvedPath, + extensionId: null, + expectedVersion: null, + expectedArchiveChecksum: null, + compatiblePanelVersions: [], + sourceRepositoryId: null, + sourceRepositoryName: $sourceLabel ?: 'Manual package file', + sourceRegistryUrl: null, + sourceArchiveUrl: 'file://' . $resolvedPath, + fallbackPackageMetadata: [], + skipScan: $skipScan, + ); + + $this->rebuildService->rebuild( + sprintf('Update extension %s', $prepared['extensionId']), + function (int $index) use ($prepared): void { + $this->progressService->report( + 'update', + $prepared['extensionId'], + $index === 0 ? 'optimizing' : 'building' + ); + } + ); + + $this->progressService->report('update', $prepared['extensionId'], 'registering'); + $packageModel = $this->finalizeUpdate($prepared); + $this->progressService->report('update', $prepared['extensionId'], 'completed'); + + return $packageModel->fresh(['repository', 'files']); + } catch (\Throwable $exception) { + if ($prepared !== null) { + $this->rollbackUpdate($prepared); + $this->attemptRollbackRebuild($prepared['extensionId'], 'update rollback'); + } + + if ($exception instanceof DisplayException) { + throw $exception; + } + + throw new DisplayException('Failed to update the selected extension package.', $exception); + } finally { + $this->progressService->clear(); + if ($prepared !== null) { + $this->ownershipService->repairStandardPaths($prepared['extensionId']); + $this->cleanupPreparedUpdate($prepared); + } + } + }); + } + + /** + * Prepare an extension update: download, extract, validate, and swap files into place. + * Does NOT rebuild the panel or modify the database. + * + * Used by the batch service to prepare multiple extensions before a single rebuild. + * After calling this for each extension, call ExtensionPanelRebuildService::rebuild() + * once, then finalizeUpdate() for each prepared result. + * + * @return array Opaque prepared state; pass to finalizeUpdate() and rollbackUpdate(). + */ + public function prepareUpdate(string $extensionId, int $repositoryId, ?string $version = null): array + { + $package = $this->catalogService->findRepositoryPackage($extensionId, $repositoryId, $version); + $release = $package['latestRelease']; + + return $this->performUpdateFileOps( + archiveLocation: $release['archiveUrl'], + extensionId: $extensionId, + expectedVersion: $release['version'], + expectedArchiveChecksum: $release['archiveChecksum'], + compatiblePanelVersions: $release['compatiblePanelVersions'] ?? [], + sourceRepositoryId: $package['repository']->id, + sourceRepositoryName: $package['repository']->name, + sourceRegistryUrl: $package['repository']->manifest_url, + sourceArchiveUrl: $release['archiveUrl'], + fallbackPackageMetadata: $package, + ); + } + + /** + * Finalize an update prepared via prepareUpdate(): write updated package records to the database. + * Must be called after the panel has been rebuilt. + * + * @param array $prepared + */ + public function finalizeUpdate(array $prepared): ExtensionPackage + { + $existingPackage = $prepared['existingPackage']; + $normalizedManifest = $prepared['normalizedManifest']; + $fallbackPackageMetadata = $prepared['fallbackPackageMetadata']; + $newFilePlans = $prepared['newFilePlans']; + $oldOnlyFiles = $prepared['oldOnlyFiles']; + $resolvedExtensionId = $prepared['extensionId']; + $archiveChecksum = $prepared['archiveChecksum']; + $sourceRepositoryId = $prepared['sourceRepositoryId']; + $sourceRepositoryName = $prepared['sourceRepositoryName']; + $sourceRegistryUrl = $prepared['sourceRegistryUrl']; + $sourceArchiveUrl = $prepared['sourceArchiveUrl']; + + DB::transaction(function () use ( + $archiveChecksum, + $existingPackage, + $fallbackPackageMetadata, + $newFilePlans, + $normalizedManifest, + $oldOnlyFiles, + $resolvedExtensionId, + $sourceArchiveUrl, + $sourceRegistryUrl, + $sourceRepositoryId, + $sourceRepositoryName + ) { + ExtensionPackageFile::query() + ->where('extension_package_id', $existingPackage->id) + ->delete(); + + $existingPackage->update([ + 'package_id' => Arr::get($normalizedManifest, 'package.id', Arr::get($fallbackPackageMetadata, 'id', $resolvedExtensionId)), + 'name' => Arr::get($normalizedManifest, 'extension.name', Arr::get($fallbackPackageMetadata, 'name', $resolvedExtensionId)), + 'description' => Arr::get($normalizedManifest, 'extension.description', Arr::get($fallbackPackageMetadata, 'description', '')), + 'author' => Arr::get($normalizedManifest, 'extension.author', Arr::get($fallbackPackageMetadata, 'author', 'M12Labs')), + 'icon' => Arr::get($normalizedManifest, 'extension.icon', Arr::get($fallbackPackageMetadata, 'icon', 'puzzle')), + 'route' => Arr::get($normalizedManifest, 'extension.route', Arr::get($fallbackPackageMetadata, 'route', $resolvedExtensionId)), + 'installed_version' => Arr::get($normalizedManifest, 'package.version'), + 'source_repository_id' => $sourceRepositoryId ?? $existingPackage->source_repository_id, + 'source_repository_name' => $sourceRepositoryName ?? $existingPackage->source_repository_name, + 'source_registry_url' => $sourceRegistryUrl ?? $existingPackage->source_registry_url, + 'source_archive_url' => $sourceArchiveUrl, + 'package_checksum' => is_string($archiveChecksum) ? $archiveChecksum : null, + 'manifest' => $normalizedManifest, + 'installed_at' => now(), + ]); + + foreach ($newFilePlans as $plan) { + ExtensionPackageFile::query()->create([ + 'extension_package_id' => $existingPackage->id, + 'path' => $plan['path'], + 'operation' => $plan['operation'], + 'installed_checksum' => $plan['checksum'], + 'backup_path' => $plan['backupPath'], + 'backup_checksum' => $plan['backupChecksum'], + ]); + } + + foreach ($oldOnlyFiles as $oldFile) { + if ($oldFile->operation === 'updated' && $oldFile->backup_path && is_file($oldFile->backup_path)) { + File::delete($oldFile->backup_path); + } + } + }); + + return $existingPackage->fresh(['repository', 'files']); + } + + /** + * Roll back a prepared update by restoring files from the rollback snapshot. + * + * @param array $prepared + */ + public function rollbackUpdate(array $prepared): void + { + if ($prepared['existingPackage']) { + $this->fileService->restoreRollbackSnapshot($prepared['existingPackage']->files->all(), $prepared['rollbackRoot']); + } + + $this->ownershipService->repairStandardPaths($prepared['extensionId']); + } + + /** + * Clean up temp files associated with a prepared update. + * + * @param array $prepared + */ + public function cleanupPreparedUpdate(array $prepared): void + { + if (!empty($prepared['tempRoot'])) { + File::deleteDirectory($prepared['tempRoot']); + } + + if (!empty($prepared['rollbackRoot'])) { + File::deleteDirectory($prepared['rollbackRoot']); + } + } + + /** + * Perform the file-operations phase of an update (download → extract → validate → swap files). + * Returns the prepared state needed to finalize or roll back. + * + * @param array $fallbackPackageMetadata + * @param array $compatiblePanelVersions + * @return array + */ + private function performUpdateFileOps( + string $archiveLocation, + ?string $extensionId, + ?string $expectedVersion, + ?string $expectedArchiveChecksum, + array $compatiblePanelVersions, + ?int $sourceRepositoryId, + ?string $sourceRepositoryName, + ?string $sourceRegistryUrl, + string $sourceArchiveUrl, + array $fallbackPackageMetadata, + bool $skipScan = false + ): array { + $tempRoot = storage_path('app/extensions/tmp/' . Str::uuid()->toString()); + $archivePath = $tempRoot . '/' . ExtensionPackageArtifactService::PACKAGE_ARTIFACT_FILENAME; + $extractPath = $tempRoot . '/extract'; + $rollbackRoot = storage_path('app/extensions/tmp-update/' . Str::uuid()->toString()); + $resolvedExtensionId = $extensionId; + $existingPackage = null; + + File::ensureDirectoryExists($tempRoot); + File::ensureDirectoryExists($extractPath); + File::ensureDirectoryExists($rollbackRoot); + + try { + $this->progressService->report('update', $resolvedExtensionId ?? 'unknown', 'downloading'); + $this->artifactService->downloadArchive($archiveLocation, $archivePath); + + // Security scan runs on the downloaded archive before extraction. + if (!$skipScan) { + $this->progressService->report('update', $resolvedExtensionId ?? 'unknown', 'scanning'); + $this->runArchiveScan($archivePath, $resolvedExtensionId ?? 'unknown', 'update'); + } + + $this->progressService->report('update', $resolvedExtensionId ?? 'unknown', 'extracting'); + $archiveChecksum = hash_file('sha256', $archivePath); + if ($expectedArchiveChecksum !== null) { + $this->artifactService->verifyChecksum($archivePath, $expectedArchiveChecksum, 'archive'); + } + $this->artifactService->extractArchive($archivePath, $extractPath); + + $this->progressService->report('update', $resolvedExtensionId ?? 'unknown', 'validating'); + $manifest = $this->artifactService->readPackageManifest($extractPath); + $normalizedManifest = $this->artifactService->normalizeManifest($manifest, $extensionId, $expectedVersion); + $resolvedExtensionId = (string) Arr::get($normalizedManifest, 'extension.id'); + + $existingPackage = ExtensionPackage::query() + ->with('files') + ->where('extension_id', $resolvedExtensionId) + ->first(); + + if (!$existingPackage) { + throw new DisplayException('This extension is not currently installed. Use the install command to install it first.'); + } + + $this->artifactService->assertCompatiblePanelVersions($compatiblePanelVersions); + $this->artifactService->assertCompatiblePanelVersions(Arr::get($normalizedManifest, 'compatiblePanelVersions', [])); + $this->ownershipService->repairStandardPaths($resolvedExtensionId); + + $this->fileService->assertFilesUnmodified($existingPackage->files->all(), 'updated'); + $this->fileService->createRollbackSnapshot($existingPackage->files->all(), $rollbackRoot); + + $newBackupRoot = storage_path('app/extensions/backups/' . $resolvedExtensionId . '/' . Str::uuid()->toString()); + + $newFilePlans = $this->prepareUpdateFilePlans( + $extractPath, + $normalizedManifest, + $newBackupRoot, + $resolvedExtensionId, + $existingPackage + ); + + $newFilePaths = array_column($newFilePlans, 'path'); + /** @var array $oldOnlyFiles */ + $oldOnlyFiles = $existingPackage->files + ->filter(fn (ExtensionPackageFile $f) => !in_array($f->path, $newFilePaths, true)) + ->values() + ->all(); + + $this->assertWritableUpdateTargets($newFilePlans, $oldOnlyFiles); + + $this->progressService->report('update', $resolvedExtensionId, 'removing'); + foreach ($oldOnlyFiles as $oldFile) { + $targetPath = base_path($oldFile->path); + + if ($oldFile->operation === 'updated') { + if ($oldFile->backup_path && is_file($oldFile->backup_path)) { + File::ensureDirectoryExists(dirname($targetPath)); + File::copy($oldFile->backup_path, $targetPath); + } + } elseif (is_file($targetPath)) { + File::delete($targetPath); + } + } + + $this->progressService->report('update', $resolvedExtensionId, 'copying'); + foreach ($newFilePlans as $plan) { + File::ensureDirectoryExists(dirname($plan['targetPath'])); + File::copy($plan['sourcePath'], $plan['targetPath']); + } + + return [ + 'extensionId' => $resolvedExtensionId, + 'existingPackage' => $existingPackage, + 'normalizedManifest' => $normalizedManifest, + 'fallbackPackageMetadata' => $fallbackPackageMetadata, + 'newFilePlans' => $newFilePlans, + 'oldOnlyFiles' => $oldOnlyFiles, + 'archiveChecksum' => is_string($archiveChecksum) ? $archiveChecksum : null, + 'sourceRepositoryId' => $sourceRepositoryId, + 'sourceRepositoryName' => $sourceRepositoryName, + 'sourceRegistryUrl' => $sourceRegistryUrl, + 'sourceArchiveUrl' => $sourceArchiveUrl, + 'rollbackRoot' => $rollbackRoot, + 'tempRoot' => $tempRoot, + ]; + } catch (\Throwable $exception) { + if ($existingPackage) { + $this->fileService->restoreRollbackSnapshot($existingPackage->files->all(), $rollbackRoot); + } + + $this->ownershipService->repairStandardPaths($resolvedExtensionId); + File::deleteDirectory($tempRoot); + File::deleteDirectory($rollbackRoot); + + if ($exception instanceof DisplayException) { + throw $exception; + } + + throw new DisplayException('Failed to prepare the extension package for update.', $exception); + } + } + + /** + * Build the file plans for the new version, inheriting pre-extension backups + * from the current install for any paths that were already tracked. + * + * @param array $manifest + * @return array> + */ + private function prepareUpdateFilePlans( + string $extractPath, + array $manifest, + string $newBackupRoot, + string $extensionId, + ExtensionPackage $existingPackage + ): array { + $plans = []; + $files = Arr::get($manifest, 'files', []); + if (!is_array($files) || $files === []) { + throw new DisplayException('The extension package manifest does not declare any installable files.'); + } + + /** @var array $oldFilesByPath */ + $oldFilesByPath = $existingPackage->files->keyBy('path')->all(); + + foreach ($files as $file) { + if (!is_array($file)) { + continue; + } + + $path = $this->artifactService->normalizeTargetPath((string) ($file['path'] ?? ''), $extensionId); + $checksum = trim((string) ($file['sha256'] ?? '')); + + if ($path === '' || $checksum === '') { + throw new DisplayException('The extension package manifest contains an invalid file entry.'); + } + + $sourcePath = $extractPath . '/' . $path; + if (!is_file($sourcePath)) { + throw new DisplayException(sprintf('The extension package is missing "%s".', $path)); + } + + $this->artifactService->verifyChecksum($sourcePath, $checksum, sprintf('file "%s"', $path)); + + // Ensure the path is not owned by a different extension. + if (ExtensionPackageFile::query() + ->where('path', $path) + ->where('extension_package_id', '!=', $existingPackage->id) + ->exists() + ) { + throw new DisplayException(sprintf('The path "%s" is already managed by another installed extension.', $path)); + } + + $targetPath = base_path($path); + $backupPath = null; + $backupChecksum = null; + $operation = 'created'; + + $oldFile = $oldFilesByPath[$path] ?? null; + + if ($oldFile !== null) { + if ($oldFile->operation === 'updated') { + // Inherit the original pre-extension backup so a future + // uninstall can still restore the original file. + $operation = 'updated'; + $backupPath = $oldFile->backup_path; + $backupChecksum = $oldFile->backup_checksum; + } else { + // File was created by our extension; it remains ours. + $operation = 'created'; + } + } elseif (is_file($targetPath)) { + // New path for this version, but a file already exists there + // (not tracked by us); back it up so it can be restored. + $operation = 'updated'; + $backupPath = $newBackupRoot . '/' . $path; + File::ensureDirectoryExists(dirname($backupPath)); + File::copy($targetPath, $backupPath); + $backupChecksum = hash_file('sha256', $backupPath); + } + + $plans[] = [ + 'path' => $path, + 'sourcePath' => $sourcePath, + 'targetPath' => $targetPath, + 'operation' => $operation, + 'checksum' => $checksum, + 'backupPath' => $backupPath, + 'backupChecksum' => $backupChecksum, + ]; + } + + return $plans; + } + + /** + * Assert that all target paths are writable before making any changes. + * + * @param array> $newFilePlans + * @param array $oldOnlyFiles + */ + private function assertWritableUpdateTargets(array $newFilePlans, array $oldOnlyFiles): void + { + foreach ($newFilePlans as $plan) { + $this->ownershipService->ensureWritablePath($plan['targetPath'], $plan['path']); + } + + foreach ($oldOnlyFiles as $oldFile) { + $targetPath = base_path($oldFile->path); + + if ($oldFile->operation === 'updated') { + $this->ownershipService->ensureWritablePath($targetPath, $oldFile->path); + } else { + $this->ownershipService->ensureRemovablePath($targetPath, $oldFile->path); + } + } + } + + private function assertSupportedArchiveArtifact(string $archivePath): void + { + if (!Str::endsWith(Str::lower($archivePath), ['.m12labsextension', '.zip'])) { + throw new DisplayException('Manual updates expect a .M12LabsExtension package file. Legacy .zip artifacts are still supported for compatibility.'); + } + } + + private function attemptRollbackRebuild(string $extensionId, string $reason): void + { + try { + $this->rebuildService->rebuild(sprintf('%s for %s', $reason, $extensionId)); + } catch (\Throwable $exception) { + report($exception); + } + } + + /** + * Run the security scanner on a downloaded archive. + * Throws DisplayException if the scan is BLOCKED. + * Logs a warning if WARNED but continues. + * Scanner errors are logged but do not block the update. + */ + private function runArchiveScan(string $archivePath, string $extensionId, string $action): void + { + try { + $scanResult = $this->scanner->scan($archivePath); + + if ($scanResult->isBlocked()) { + $summary = $scanResult->toArray()['summary']; + throw new DisplayException(sprintf( + 'Security scan BLOCKED %s of "%s": %d high-severity finding(s) detected. Review the scan report at: %s', + $action, + $extensionId, + $summary['high'], + $scanResult->reportPath + )); + } + + if ($scanResult->hasSevereFindings()) { + Log::warning('Extension security scan found warnings.', [ + 'action' => $action, + 'extension' => $extensionId, + 'warnings' => $scanResult->toArray()['summary']['warnings'], + 'report' => $scanResult->reportPath, + ]); + } + } catch (DisplayException $e) { + throw $e; + } catch (\Throwable $e) { + Log::error('Extension security scanner encountered an error.', [ + 'action' => $action, + 'extension' => $extensionId, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Services/Extensions/ExtensionPanelRebuildService.php b/app/Services/Extensions/ExtensionPanelRebuildService.php new file mode 100644 index 0000000000..74dbd2ef55 --- /dev/null +++ b/app/Services/Extensions/ExtensionPanelRebuildService.php @@ -0,0 +1,126 @@ + + */ + public function rebuild(string $reason, ?callable $onCommandStart = null): array + { + $commands = [ + ['php', 'artisan', 'optimize:clear'], + $this->getFrontendBuildCommand(), + ]; + + $output = []; + $environment = $this->getProcessEnvironment($reason); + + foreach ($commands as $index => $command) { + if ($onCommandStart !== null) { + $onCommandStart($index); + } + + // Before the frontend build, validate (and auto-repair when root) + // filesystem ownership so permission problems produce a clear error + // instead of a cryptic mid-build failure. + if ($index === 1) { + $this->ownershipService->validateBuildWorkspaceOwnership(); + } + + $process = new Process($command, base_path(), $environment); + $process->setTimeout(1800); + $process->run(); + + $combinedOutput = trim($process->getOutput() . "\n" . $process->getErrorOutput()); + $output[] = [ + 'command' => implode(' ', $command), + 'output' => $combinedOutput, + ]; + + if (!$process->isSuccessful()) { + throw new DisplayException( + sprintf('M12Labs rebuild failed while running "%s".', implode(' ', $command)), + new \RuntimeException($combinedOutput) + ); + } + } + + return $output; + } + + /** + * @return array + */ + private function getFrontendBuildCommand(): array + { + $finder = new ExecutableFinder(); + + $pnpm = $finder->find('pnpm'); + if (File::exists(base_path('pnpm-lock.yaml')) && $pnpm) { + return [$pnpm, 'build']; + } + + $npm = $finder->find('npm'); + if ($npm) { + return [$npm, 'run', 'build']; + } + + throw new DisplayException('Unable to rebuild M12Labs because neither pnpm nor npm is available on this host.'); + } + + /** + * @return array + */ + private function getProcessEnvironment(string $reason): array + { + $home = storage_path('app/extensions/runtime-home'); + $cache = $home . '/.cache'; + $corepack = $cache . '/corepack'; + $npmCache = $cache . '/npm'; + $pnpmStore = $home . '/.local/share/pnpm/store'; + $pnpmHome = $home . '/.local/share/pnpm'; + $xdgData = $home . '/.local/share'; + $xdgState = $home . '/.local/state'; + + File::ensureDirectoryExists($corepack); + File::ensureDirectoryExists($npmCache); + File::ensureDirectoryExists($pnpmHome); + File::ensureDirectoryExists($pnpmStore); + File::ensureDirectoryExists($xdgState); + + return [ + 'M12LABS_EXTENSION_REBUILD_REASON' => $reason, + 'HOME' => $home, + 'PATH' => (string) (getenv('PATH') ?: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'), + 'XDG_CACHE_HOME' => $cache, + 'XDG_DATA_HOME' => $xdgData, + 'XDG_STATE_HOME' => $xdgState, + 'COREPACK_HOME' => $corepack, + 'COREPACK_ENABLE_DOWNLOAD_PROMPT' => '0', + 'npm_config_cache' => $npmCache, + 'NPM_CONFIG_CACHE' => $npmCache, + 'PNPM_HOME' => $pnpmHome, + 'PNPM_STORE_DIR' => $pnpmStore, + 'pnpm_config_store_dir' => $pnpmStore, + ]; + } +} \ No newline at end of file diff --git a/app/Services/Extensions/ExtensionRepositoryBootstrapService.php b/app/Services/Extensions/ExtensionRepositoryBootstrapService.php new file mode 100644 index 0000000000..50465f0df0 --- /dev/null +++ b/app/Services/Extensions/ExtensionRepositoryBootstrapService.php @@ -0,0 +1,47 @@ +firstOrNew([ + 'slug' => self::OFFICIAL_REPOSITORY_SLUG, + ]); + + $repository->name = self::OFFICIAL_REPOSITORY_NAME; + $repository->manifest_url = $this->getOfficialManifestUrl(); + $repository->homepage_url = self::OFFICIAL_REPOSITORY_HOMEPAGE; + $repository->is_official = true; + + if (!$repository->exists) { + $repository->enabled = true; + } + + if ($repository->risk_acknowledged_at === null) { + $repository->risk_acknowledged_at = now(); + } + + $repository->save(); + + return $repository->refresh(); + } + + private function getOfficialManifestUrl(): string + { + $override = env('M12LABS_EXTENSIONS_MANIFEST_URL'); + if (!empty($override)) { + return $override; + } + + return self::OFFICIAL_REPOSITORY_MANIFEST_URL; + } +} \ No newline at end of file diff --git a/app/Services/Extensions/ExtensionSecurityScanner.php b/app/Services/Extensions/ExtensionSecurityScanner.php new file mode 100644 index 0000000000..519ac01072 --- /dev/null +++ b/app/Services/Extensions/ExtensionSecurityScanner.php @@ -0,0 +1,626 @@ +toString(); + $tempDir = rtrim(config('extensions.scan.temp_dir', storage_path('app/extension-scans')), '/') . '/' . $uuid; + + File::ensureDirectoryExists($tempDir); + + try { + // Step 1 — Extract archive + $this->extractArchive($archivePath, $tempDir); + + // Step 2 — Validate manifest + $slug = $this->validateManifest($tempDir, $archivePath); + + // Step 3 — PHP scan + $phpFindings = $this->runPhpScan($tempDir); + + // Step 4 — JS/TS scan + $jsFindings = $this->runJsScan($tempDir); + + // Step 5 — Semgrep scan (optional) + $semgrepFindings = $this->runSemgrepScan($tempDir); + + // Step 6 — Decide outcome + $highCount = $this->countHigh($phpFindings, $jsFindings, $semgrepFindings); + $warnCount = $this->countWarnings($phpFindings, $jsFindings, $semgrepFindings); + $blockOnHigh = (bool) config('extensions.scan.block_on_high', true); + + $outcome = match (true) { + $highCount > 0 && $blockOnHigh => ScanResult::BLOCKED, + $highCount > 0 || $warnCount > 0 => ScanResult::WARNED, + default => ScanResult::PASSED, + }; + + // Step 7 — Write report + $reportPath = $this->writeReport($slug, $outcome, $phpFindings, $jsFindings, $semgrepFindings); + + return new ScanResult( + outcome: $outcome, + phpFindings: $phpFindings, + jsFindings: $jsFindings, + semgrepFindings: $semgrepFindings, + reportPath: $reportPath, + scannedAt: new \DateTimeImmutable(), + ); + } finally { + // Step 8 — Cleanup + File::deleteDirectory($tempDir); + } + } + + // ------------------------------------------------------------------------- + // Extraction + // ------------------------------------------------------------------------- + + private function extractArchive(string $archivePath, string $targetDir): void + { + $zip = new ZipArchive(); + $result = $zip->open($archivePath); + + if ($result !== true) { + throw new \RuntimeException(sprintf( + 'Could not open extension archive "%s" (ZipArchive error %d).', + basename($archivePath), + $result + )); + } + + $realTarget = realpath($targetDir); + if ($realTarget === false) { + $zip->close(); + throw new \RuntimeException('Could not resolve extraction target directory.'); + } + + $totalEntries = $zip->count(); + + // Zip-bomb guard: reject archives with an excessive number of entries. + if ($totalEntries > self::MAX_ZIP_ENTRIES) { + $zip->close(); + throw new \RuntimeException(sprintf( + 'Extension archive contains too many entries (%d). Maximum allowed is %d.', + $totalEntries, + self::MAX_ZIP_ENTRIES + )); + } + + $totalUncompressedSize = 0; + + for ($i = 0; $i < $totalEntries; $i++) { + $entry = $zip->getNameIndex($i); + if ($entry === false) { + continue; + } + + // Zip-slip guard: normalise the entry path purely by string manipulation so + // that the check works even before any file exists on disk (realpath() returns + // false for non-existent paths and would silently skip the traversal check). + $rawPath = $realTarget . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $entry); + $segments = explode(DIRECTORY_SEPARATOR, $rawPath); + $normalised = []; + foreach ($segments as $segment) { + if ($segment === '..') { + if (empty($normalised)) { + // More '..' segments than valid components — path escapes the root. + $zip->close(); + throw new \RuntimeException(sprintf( + 'Extension archive contains a path traversal entry: "%s". Aborting extraction.', + $entry + )); + } + array_pop($normalised); + } elseif ($segment !== '.') { + $normalised[] = $segment; + } + } + $resolvedPath = implode(DIRECTORY_SEPARATOR, $normalised); + + if (!str_starts_with($resolvedPath, $realTarget . DIRECTORY_SEPARATOR)) { + $zip->close(); + throw new \RuntimeException(sprintf( + 'Extension archive contains a path traversal entry: "%s". Aborting extraction.', + $entry + )); + } + + // Zip-bomb guard: accumulate uncompressed sizes and reject if the limit is exceeded. + $stat = $zip->statIndex($i); + if ($stat !== false) { + $totalUncompressedSize += $stat['size']; + if ($totalUncompressedSize > self::MAX_EXTRACTED_BYTES) { + $zip->close(); + throw new \RuntimeException(sprintf( + 'Extension archive exceeds the maximum allowed extracted size (%d bytes).', + self::MAX_EXTRACTED_BYTES + )); + } + } + } + + $zip->extractTo($realTarget); + $zip->close(); + } + + // ------------------------------------------------------------------------- + // Manifest validation + // ------------------------------------------------------------------------- + + private function validateManifest(string $tempDir, string $archivePath): string + { + $manifestPath = $tempDir . '/' . self::MANIFEST_FILENAME; + + if (!is_file($manifestPath)) { + throw new \RuntimeException(sprintf( + 'Extension archive "%s" is missing the required %s manifest.', + basename($archivePath), + self::MANIFEST_FILENAME + )); + } + + $raw = file_get_contents($manifestPath); + if ($raw === false) { + throw new \RuntimeException('Could not read extension manifest.'); + } + + try { + $manifest = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new \RuntimeException('Extension manifest contains malformed JSON.', 0, $e); + } + + $id = trim((string) ($manifest['extension']['id'] ?? '')); + $version = trim((string) ($manifest['package']['version'] ?? '')); + $author = trim((string) ($manifest['extension']['author'] ?? '')); + $name = trim((string) ($manifest['extension']['name'] ?? '')); + + if ($id === '' || $version === '' || ($author === '' && $name === '')) { + throw new \RuntimeException( + 'Extension manifest must contain at minimum: extension.id, package.version, and extension.name or extension.author.' + ); + } + + return $id; + } + + // ------------------------------------------------------------------------- + // PHP scan (phpcs) + // ------------------------------------------------------------------------- + + /** + * @return array> + */ + private function runPhpScan(string $dir): array + { + $binary = (string) config('extensions.scan.phpcs_binary', 'phpcs'); + + if (!$this->binaryExists($binary)) { + Log::warning('ExtensionSecurityScanner: phpcs binary not found, skipping PHP scan.', ['binary' => $binary]); + return []; + } + + $phpFiles = $this->findFiles($dir, ['php']); + if ($phpFiles === []) { + return []; + } + + $process = new Process([ + $binary, + '--standard=Security', + '--report=json', + '--severity=1', + ...$phpFiles, + ]); + $process->setTimeout(120); + $process->run(); + + $output = $process->getOutput(); + if (empty($output)) { + return []; + } + + try { + $decoded = json_decode($output, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return []; + } + + $findings = []; + foreach ((array) ($decoded['files'] ?? []) as $filePath => $fileData) { + foreach ((array) ($fileData['messages'] ?? []) as $message) { + $findings[] = [ + 'file' => $filePath, + 'line' => $message['line'] ?? 0, + 'column' => $message['column'] ?? 0, + 'severity' => strtoupper((string) ($message['type'] ?? 'WARNING')), + 'message' => $message['message'] ?? '', + 'source' => $message['source'] ?? '', + ]; + } + } + + return $findings; + } + + // ------------------------------------------------------------------------- + // JS/TS scan (ESLint) + // ------------------------------------------------------------------------- + + /** + * @return array> + */ + private function runJsScan(string $dir): array + { + $jsFiles = $this->findFiles($dir, ['ts', 'tsx', 'js']); + if ($jsFiles === []) { + return []; + } + + $binaryParts = explode(' ', (string) config('extensions.scan.eslint_binary', 'npx eslint')); + + if (!$this->binaryExists($binaryParts[0])) { + Log::warning('ExtensionSecurityScanner: eslint binary not found, skipping JS scan.', ['binary' => $binaryParts[0]]); + return []; + } + + $eslintMajor = $this->getEslintMajorVersion($binaryParts[0]); + + File::ensureDirectoryExists(storage_path('app/tmp')); + + if ($eslintMajor >= 9) { + // ESLint v9+ uses flat config (eslint.config.cjs). The legacy eslintrc + // format and --no-eslintrc flag were removed in v9. + // We write the config to storage/app/tmp/ but ESLint's base path is + // the process CWD — files outside of it are silently ignored. We + // therefore run ESLint with CWD = $dir and pass relative file paths. + $eslintConfigPath = storage_path('app/tmp/.eslint-scan-' . Str::uuid()->toString() . '.cjs'); + $pluginPath = $this->resolveNodeModule('eslint-plugin-security', $binaryParts[0]); + + if ($pluginPath === null) { + Log::warning('ExtensionSecurityScanner: eslint-plugin-security not found, skipping JS scan.'); + return []; + } + + // All security rules are set to 'error' so they count as high-severity + // findings and can trigger BLOCKED, consistent with phpcs behaviour. + $configContent = <<jsString($pluginPath)}); +const rules = Object.fromEntries( + Object.keys(security.configs.recommended.rules).map(k => [k, 'error']) +); +module.exports = [{plugins:{security}, rules, languageOptions:{ecmaVersion:2020}}]; +JSEOF; + File::put($eslintConfigPath, $configContent); + + // Build relative paths so they fall within ESLint's base path (CWD = $dir). + $relativeFiles = array_map( + fn (string $f) => ltrim(substr($f, strlen(rtrim($dir, '/'))), '/'), + $jsFiles + ); + + $cmd = array_merge( + $binaryParts, + ['-c', $eslintConfigPath, '--no-warn-ignored', '--format', 'json'], + $relativeFiles + ); + + $process = new Process($cmd); + $process->setWorkingDirectory($dir); + } else { + // ESLint ≤ v8: legacy eslintrc JSON config. + $eslintConfigPath = storage_path('app/tmp/.eslint-scan-' . Str::uuid()->toString() . '.json'); + $eslintConfig = [ + 'plugins' => ['security'], + 'rules' => ['security/detect-object-injection' => 'error'], + 'extends' => ['plugin:security/recommended'], + ]; + + $encoded = json_encode($eslintConfig); + if ($encoded === false) { + throw new \RuntimeException('Failed to encode ESLint config as JSON: ' . json_last_error_msg()); + } + + File::put($eslintConfigPath, $encoded); + + $cmd = array_merge( + $binaryParts, + ['--no-eslintrc', '-c', $eslintConfigPath, '--format', 'json'], + $jsFiles + ); + + $process = new Process($cmd); + } + + $process->setTimeout(120); + + try { + $process->run(); + + $output = $process->getOutput(); + if (empty($output)) { + return []; + } + + try { + $decoded = json_decode($output, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return []; + } + + $findings = []; + foreach ((array) $decoded as $fileResult) { + foreach ((array) ($fileResult['messages'] ?? []) as $msg) { + $sev = (int) ($msg['severity'] ?? 1); + $findings[] = [ + 'file' => $fileResult['filePath'] ?? '', + 'line' => $msg['line'] ?? 0, + 'column' => $msg['column'] ?? 0, + 'severity' => $sev, + 'message' => $msg['message'] ?? '', + 'rule' => $msg['ruleId'] ?? '', + ]; + } + } + + return $findings; + } finally { + @unlink($eslintConfigPath); + } + } + + /** + * Returns the major version number of the configured ESLint binary, or 0 on failure. + */ + private function getEslintMajorVersion(string $binary): int + { + $process = new Process([$binary, '--version']); + $process->setTimeout(10); + $process->run(); + // Output is like "v10.3.0\n" + if (preg_match('/v?(\d+)/', trim($process->getOutput()), $m)) { + return (int) $m[1]; + } + return 0; + } + + /** + * Resolves the absolute directory path for a Node module, searching relative + * to the eslint binary's own node_modules first, then falling back to the + * global npm root. + */ + private function resolveNodeModule(string $module, string $eslintBinary): ?string + { + $eslintCmd = explode(' ', $eslintBinary)[0]; + $eslintFound = (new ExecutableFinder())->find($eslintCmd); + + // If the binary is an absolute path it won't be found via PATH search; use it directly. + if ($eslintFound === null && (str_starts_with($eslintCmd, '/') || preg_match('/^[a-zA-Z]:[\\\\\/]/', $eslintCmd))) { + $eslintFound = $eslintCmd; + } + + $candidates = []; + if ($eslintFound !== null) { + $eslintDir = dirname($eslintFound); + $candidates[] = $eslintDir . '/../lib/node_modules/' . $module; + $candidates[] = $eslintDir . '/../../' . $module; + } + + // Global npm root as final fallback + $npmRoot = trim((string) shell_exec('npm root -g 2>/dev/null')); + if ($npmRoot !== '') { + $candidates[] = $npmRoot . '/' . $module; + } + + foreach ($candidates as $candidate) { + if (is_dir($candidate)) { + return realpath($candidate) ?: null; + } + } + + return null; + } + + /** + * Returns a JS string literal (double-quoted, with backslashes and double-quotes escaped). + */ + private function jsString(string $value): string + { + return '"' . addcslashes($value, '"\\') . '"'; + } + + // ------------------------------------------------------------------------- + // Semgrep scan (optional) + // ------------------------------------------------------------------------- + + /** + * @return array> + */ + private function runSemgrepScan(string $dir): array + { + if (!config('extensions.scan.semgrep_enabled', false)) { + return []; + } + + $binary = (string) config('extensions.scan.semgrep_binary', 'semgrep'); + + if (!$this->binaryExists($binary)) { + Log::info('ExtensionSecurityScanner: semgrep binary not found, skipping.', ['binary' => $binary]); + return []; + } + + $process = new Process([ + $binary, + ...array_map( + fn (string $r) => '--config=' . $r, + array_filter(array_map('trim', explode(',', (string) config('extensions.scan.semgrep_rulesets', 'p/php-security,p/javascript')))) + ), + '--json', + $dir, + ]); + $process->setTimeout(180); + $process->run(); + + $output = $process->getOutput(); + if (empty($output)) { + return []; + } + + try { + $decoded = json_decode($output, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return []; + } + + $findings = []; + foreach ((array) ($decoded['results'] ?? []) as $result) { + $findings[] = [ + 'file' => $result['path'] ?? '', + 'line' => $result['start']['line'] ?? 0, + 'severity' => strtoupper((string) ($result['extra']['severity'] ?? 'WARNING')), + 'message' => $result['extra']['message'] ?? '', + 'rule' => $result['check_id'] ?? '', + ]; + } + + return $findings; + } + + // ------------------------------------------------------------------------- + // Report + // ------------------------------------------------------------------------- + + /** + * @param array> $phpFindings + * @param array> $jsFindings + * @param array> $semgrepFindings + */ + private function writeReport( + string $slug, + string $outcome, + array $phpFindings, + array $jsFindings, + array $semgrepFindings, + ): string { + // BLOCKED extensions are never installed, so writing the report into the + // install directory would leave orphaned files. We route blocked reports to + // a dedicated "blocked-scans" directory for audit purposes while keeping + // the installed directory clean. + if ($outcome === ScanResult::BLOCKED) { + $reportDir = rtrim(config('extensions.scan.install_dir', storage_path('app/extensions/installed')), '/'); + $reportDir = dirname($reportDir) . '/blocked-scans/' . $slug; + } else { + $reportDir = rtrim(config('extensions.scan.install_dir', storage_path('app/extensions/installed')), '/') . '/' . $slug; + } + + File::ensureDirectoryExists($reportDir); + + $reportPath = $reportDir . '/scan-report.json'; + + $high = $this->countHigh($phpFindings, $jsFindings, $semgrepFindings); + $warn = $this->countWarnings($phpFindings, $jsFindings, $semgrepFindings); + + $report = [ + 'scanned_at' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM), + 'outcome' => $outcome, + 'php_findings' => $phpFindings, + 'js_findings' => $jsFindings, + 'semgrep_findings' => $semgrepFindings, + 'summary' => [ + 'high' => $high, + 'warnings' => $warn, + ], + ]; + + file_put_contents($reportPath, json_encode($report, JSON_PRETTY_PRINT)); + + return $reportPath; + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * @param array $extensions + * @return array + */ + private function findFiles(string $dir, array $extensions): array + { + $found = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS) + ); + + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if ($file->isFile() && in_array(strtolower($file->getExtension()), $extensions, true)) { + $found[] = $file->getPathname(); + } + } + + return $found; + } + + private function binaryExists(string $binary): bool + { + // For compound commands like "npx eslint", check only the first token. + $cmd = explode(' ', $binary)[0]; + + // If an absolute path was provided, check directly rather than searching PATH, + // because ExecutableFinder::find() only resolves bare command names via PATH. + // Covers Unix (/path/to/bin) and Windows (C:\path\to\bin or C:/path/to/bin). + if (str_starts_with($cmd, '/') || preg_match('/^[a-zA-Z]:[\\\\\/]/', $cmd)) { + return is_file($cmd) && is_executable($cmd); + } + + $finder = new ExecutableFinder(); + + return $finder->find($cmd) !== null; + } + + /** + * @param array> $phpFindings + * @param array> $jsFindings + * @param array> $semgrepFindings + */ + private function countHigh(array $phpFindings, array $jsFindings, array $semgrepFindings): int + { + return count(array_filter($phpFindings, fn ($f) => ($f['severity'] ?? '') === 'ERROR')) + + count(array_filter($jsFindings, fn ($f) => ($f['severity'] ?? 0) === 2)) + + count(array_filter($semgrepFindings, fn ($f) => ($f['severity'] ?? '') === 'ERROR')); + } + + /** + * @param array> $phpFindings + * @param array> $jsFindings + * @param array> $semgrepFindings + */ + private function countWarnings(array $phpFindings, array $jsFindings, array $semgrepFindings): int + { + return count(array_filter($phpFindings, fn ($f) => ($f['severity'] ?? '') === 'WARNING')) + + count(array_filter($jsFindings, fn ($f) => ($f['severity'] ?? 0) === 1)) + + count(array_filter($semgrepFindings, fn ($f) => ($f['severity'] ?? '') === 'WARNING')); + } +} diff --git a/app/Services/Extensions/MinecraftPlayerManager/MinecraftPing.php b/app/Services/Extensions/MinecraftPlayerManager/MinecraftPing.php new file mode 100644 index 0000000000..5ae7a2f510 --- /dev/null +++ b/app/Services/Extensions/MinecraftPlayerManager/MinecraftPing.php @@ -0,0 +1,167 @@ +ServerAddress = $Address; + $this->ServerPort = $Port; + $this->Timeout = $Timeout; + + if ($ResolveSRV) { + $this->ResolveSRV(); + } + } + + public function __destruct() + { + $this->Close(); + } + + public function Close(): void + { + if ($this->Socket !== null) { + \fclose($this->Socket); + $this->Socket = null; + } + } + + public function Connect(): void + { + $Socket = @\fsockopen($this->ServerAddress, $this->ServerPort, $errno, $errstr, $this->Timeout); + + if ($Socket === false) { + throw new MinecraftPingException("Failed to connect or create a socket: $errno ($errstr)"); + } + + $this->Socket = $Socket; + \stream_set_timeout($this->Socket, (int) $this->Timeout); + } + + /** @return array|false */ + public function Query(): array|bool + { + if ($this->Socket === null) { + throw new MinecraftPingException('Socket is not open.'); + } + + $TimeStart = \microtime(true); + + $Data = "\x00"; // packet ID = 0 (varint) + $Data .= "\xff\xff\xff\xff\x0f"; // Protocol version (varint) + $Data .= \pack('c', \strlen($this->ServerAddress)) . $this->ServerAddress; + $Data .= \pack('n', $this->ServerPort); + $Data .= "\x01"; // Next state: status (varint) + + $Data = \pack('c', \strlen($Data)) . $Data; + + fwrite($this->Socket, $Data . "\x01\x00"); + + $Length = $this->ReadVarInt(); + + if ($Length < 10) { + return false; + } + + $this->ReadVarInt(); // packet type + + $Length = $this->ReadVarInt(); // string length + + if ($Length < 2) { + return false; + } + + $Data = ""; + while (\strlen($Data) < $Length) { + if (\microtime(true) - $TimeStart > $this->Timeout) { + throw new MinecraftPingException('Server read timed out'); + } + + $Remainder = $Length - \strlen($Data); + + if ($Remainder <= 0) { + break; + } + + $block = \fread($this->Socket, $Remainder); + if (!$block) { + throw new MinecraftPingException('Server returned too few data'); + } + + $Data .= $block; + } + + $Data = \json_decode($Data, true); + + if (\json_last_error() !== JSON_ERROR_NONE) { + throw new MinecraftPingException('JSON parsing failed: ' . \json_last_error_msg()); + } + + if (!\is_array($Data)) { + return false; + } + + return $Data; + } + + private function ReadVarInt(): int + { + $i = 0; + $j = 0; + + while (true) { + $k = @\fgetc($this->Socket); + + if ($k === false) { + return 0; + } + + $k = \ord($k); + + $i |= ($k & 0x7F) << $j++ * 7; + + if ($j > 5) { + throw new MinecraftPingException('VarInt too big'); + } + + if (($k & 0x80) != 128) { + break; + } + } + + return $i; + } + + private function ResolveSRV(): void + { + if (\ip2long($this->ServerAddress) !== false) { + return; + } + + $Record = @\dns_get_record('_minecraft._tcp.' . $this->ServerAddress, DNS_SRV); + + if (empty($Record)) { + return; + } + + if (isset($Record[0]['target'])) { + $this->ServerAddress = $Record[0]['target']; + } + + if (isset($Record[0]['port'])) { + $this->ServerPort = (int) $Record[0]['port']; + } + } +} diff --git a/app/Services/Extensions/MinecraftPlayerManager/MinecraftPingException.php b/app/Services/Extensions/MinecraftPlayerManager/MinecraftPingException.php new file mode 100644 index 0000000000..abc5eaa0f9 --- /dev/null +++ b/app/Services/Extensions/MinecraftPlayerManager/MinecraftPingException.php @@ -0,0 +1,7 @@ +ResolveSRV($Ip, $Port); + } + + $Socket = @\fsockopen('udp://' . $Ip, $Port, $ErrNo, $ErrStr, $Timeout); + + if ($ErrNo || $Socket === false) { + throw new MinecraftQueryException('Could not create socket: ' . $ErrStr); + } + + $this->Socket = $Socket; + + \stream_set_timeout($this->Socket, (int) $Timeout); + \stream_set_blocking($this->Socket, true); + + try { + $Challenge = $this->GetChallenge(); + $this->GetStatus($Challenge); + } finally { + \fclose($Socket); + } + } + + /** @return array|false */ + public function GetInfo(): array|bool + { + return isset($this->Info) ? $this->Info : false; + } + + /** @return array|false */ + public function GetPlayers(): array|bool + { + return isset($this->Players) ? $this->Players : false; + } + + private function GetChallenge(): string + { + $Data = $this->WriteData(self::HANDSHAKE); + + if ($Data === false) { + throw new MinecraftQueryException('Failed to receive challenge.'); + } + + return \pack('N', $Data); + } + + private function GetStatus(string $Challenge): void + { + $Data = $this->WriteData(self::STATISTIC, $Challenge . \pack('c*', 0x00, 0x00, 0x00, 0x00)); + + if (!$Data) { + throw new MinecraftQueryException('Failed to receive status.'); + } + + $Info = []; + + $Data = \substr($Data, 11); + $Data = \explode("\x00\x00\x01player_\x00\x00", $Data); + + if (\count($Data) !== 2) { + throw new MinecraftQueryException("Failed to parse server's response."); + } + + $Players = \substr($Data[1], 0, -2); + $Data = \explode("\x00", $Data[0]); + + $Keys = [ + 'hostname' => 'HostName', + 'gametype' => 'GameType', + 'version' => 'Version', + 'plugins' => 'Plugins', + 'map' => 'Map', + 'numplayers' => 'Players', + 'maxplayers' => 'MaxPlayers', + 'hostport' => 'HostPort', + 'hostip' => 'HostIp', + 'game_id' => 'GameName' + ]; + + $Last = ''; + foreach ($Data as $Key => $Value) { + if (~$Key & 1) { + if (!isset($Keys[$Value])) { + $Last = false; + continue; + } + + $Last = $Keys[$Value]; + $Info[$Last] = ''; + } elseif ($Last != false) { + $Info[$Last] = \mb_convert_encoding($Value, 'UTF-8'); + } + } + + $Info['Players'] = (int) ($Info['Players'] ?? 0); + $Info['MaxPlayers'] = (int) ($Info['MaxPlayers'] ?? 0); + $Info['HostPort'] = (int) ($Info['HostPort'] ?? 0); + + if (isset($Info['Plugins'])) { + $Data = \explode(": ", $Info['Plugins'], 2); + + $Info['RawPlugins'] = $Info['Plugins']; + $Info['Software'] = $Data[0]; + + if (\count($Data) == 2) { + $Info['Plugins'] = \explode("; ", $Data[1]); + } + } else { + $Info['Software'] = 'Vanilla'; + } + + $this->Info = $Info; + + if (empty($Players)) { + $this->Players = null; + } else { + $this->Players = \explode("\x00", $Players); + } + } + + private function WriteData(int $Command, string $Append = ""): mixed + { + if ($this->Socket === null) { + throw new MinecraftQueryException('Socket is not open.'); + } + + $Command = \pack('c*', 0xFE, 0xFD, $Command, 0x01, 0x02, 0x03, 0x04) . $Append; + $Length = \strlen($Command); + + if ($Length !== \fwrite($this->Socket, $Command, $Length)) { + throw new MinecraftQueryException("Failed to write on socket."); + } + + $Data = \fread($this->Socket, 4096); + + if (empty($Data)) { + throw new MinecraftQueryException("Failed to read from socket."); + } + + if (\strlen($Data) < 5 || $Data[0] != $Command[2]) { + return false; + } + + return \substr($Data, 5); + } + + private function ResolveSRV(string &$Address, int &$Port): void + { + if (\ip2long($Address) !== false) { + return; + } + + $Record = @\dns_get_record('_minecraft._tcp.' . $Address, DNS_SRV); + + if (empty($Record)) { + return; + } + + if (isset($Record[0]['target'])) { + $Address = $Record[0]['target']; + } + + if (isset($Record[0]['port'])) { + $Port = (int) $Record[0]['port']; + } + } +} diff --git a/app/Services/Extensions/MinecraftPlayerManager/MinecraftQueryException.php b/app/Services/Extensions/MinecraftPlayerManager/MinecraftQueryException.php new file mode 100644 index 0000000000..8e8e97c4b5 --- /dev/null +++ b/app/Services/Extensions/MinecraftPlayerManager/MinecraftQueryException.php @@ -0,0 +1,7 @@ +data = gzdecode($compressed); + } else { + $this->data = $compressed; + } + + if ($this->data === false) { + throw new \Exception("Failed to decompress NBT file"); + } + + $this->offset = 0; + return $this->readTag(); + } + + /** + * Parse NBT data from raw bytes. + */ + public function parse(string $data): array + { + $this->data = $data; + $this->offset = 0; + return $this->readTag(); + } + + private function readTag(): array + { + $type = $this->readByte(); + + if ($type === self::TAG_END) { + return ['type' => 'end']; + } + + $name = $this->readString(); + $value = $this->readPayload($type); + + return [ + 'name' => $name, + 'value' => $value, + ]; + } + + private function readPayload(int $type): mixed + { + return match ($type) { + self::TAG_END => null, + self::TAG_BYTE => $this->readByte(), + self::TAG_SHORT => $this->readShort(), + self::TAG_INT => $this->readInt(), + self::TAG_LONG => $this->readLong(), + self::TAG_FLOAT => $this->readFloat(), + self::TAG_DOUBLE => $this->readDouble(), + self::TAG_BYTE_ARRAY => $this->readByteArray(), + self::TAG_STRING => $this->readString(), + self::TAG_LIST => $this->readList(), + self::TAG_COMPOUND => $this->readCompound(), + self::TAG_INT_ARRAY => $this->readIntArray(), + self::TAG_LONG_ARRAY => $this->readLongArray(), + default => throw new \Exception("Unknown NBT tag type: $type"), + }; + } + + private function readByte(): int + { + $value = ord($this->data[$this->offset]); + $this->offset++; + // Convert to signed byte + return $value > 127 ? $value - 256 : $value; + } + + private function readUnsignedByte(): int + { + $value = ord($this->data[$this->offset]); + $this->offset++; + return $value; + } + + private function readShort(): int + { + $bytes = substr($this->data, $this->offset, 2); + $this->offset += 2; + $value = unpack('n', $bytes)[1]; + // Convert to signed short + return $value > 32767 ? $value - 65536 : $value; + } + + private function readInt(): int + { + $bytes = substr($this->data, $this->offset, 4); + $this->offset += 4; + $value = unpack('N', $bytes)[1]; + // Convert to signed int (PHP handles this) + if ($value > 2147483647) { + $value -= 4294967296; + } + return $value; + } + + private function readLong(): int|string + { + $bytes = substr($this->data, $this->offset, 8); + $this->offset += 8; + $value = unpack('J', $bytes)[1]; + return $value; + } + + private function readFloat(): float + { + $bytes = substr($this->data, $this->offset, 4); + $this->offset += 4; + // Reverse bytes for big-endian + $bytes = strrev($bytes); + return unpack('f', $bytes)[1]; + } + + private function readDouble(): float + { + $bytes = substr($this->data, $this->offset, 8); + $this->offset += 8; + // Reverse bytes for big-endian + $bytes = strrev($bytes); + return unpack('d', $bytes)[1]; + } + + private function readString(): string + { + $length = $this->readShort(); + if ($length < 0) { + $length = 0; + } + $value = substr($this->data, $this->offset, $length); + $this->offset += $length; + return $value; + } + + private function readByteArray(): array + { + $length = $this->readInt(); + $values = []; + for ($i = 0; $i < $length; $i++) { + $values[] = $this->readByte(); + } + return $values; + } + + private function readIntArray(): array + { + $length = $this->readInt(); + $values = []; + for ($i = 0; $i < $length; $i++) { + $values[] = $this->readInt(); + } + return $values; + } + + private function readLongArray(): array + { + $length = $this->readInt(); + $values = []; + for ($i = 0; $i < $length; $i++) { + $values[] = $this->readLong(); + } + return $values; + } + + private function readList(): array + { + $itemType = $this->readUnsignedByte(); + $length = $this->readInt(); + + $values = []; + for ($i = 0; $i < $length; $i++) { + $values[] = $this->readPayload($itemType); + } + return $values; + } + + private function readCompound(): array + { + $values = []; + + while (true) { + $type = $this->readUnsignedByte(); + + if ($type === self::TAG_END) { + break; + } + + $name = $this->readString(); + $values[$name] = $this->readPayload($type); + } + + return $values; + } + + /** + * Extract player inventory from parsed NBT data. + */ + public static function extractInventory(array $nbt): array + { + $data = $nbt['value'] ?? $nbt; + $inventory = []; + + // Main inventory (slots 0-35) + if (isset($data['Inventory']) && is_array($data['Inventory'])) { + foreach ($data['Inventory'] as $item) { + $inventory[] = self::parseItem($item); + } + } + + return $inventory; + } + + /** + * Extract armor from parsed NBT data. + */ + public static function extractArmor(array $nbt): array + { + $data = $nbt['value'] ?? $nbt; + $armor = [ + 'helmet' => null, + 'chestplate' => null, + 'leggings' => null, + 'boots' => null, + ]; + + // Method 1: Check equipment field (Minecraft 1.20.5+) + // Equipment format: {head: {}, chest: {}, legs: {}, feet: {}, mainhand: {}, offhand: {}} + // Or as a list: [{slot: "head", item: {}}, ...] + if (isset($data['equipment']) && is_array($data['equipment'])) { + $equipment = $data['equipment']; + + // Check for named keys format (1.21+) + if (isset($equipment['head']) && is_array($equipment['head']) && !empty($equipment['head'])) { + $armor['helmet'] = self::parseItem($equipment['head']); + } + if (isset($equipment['chest']) && is_array($equipment['chest']) && !empty($equipment['chest'])) { + $armor['chestplate'] = self::parseItem($equipment['chest']); + } + if (isset($equipment['legs']) && is_array($equipment['legs']) && !empty($equipment['legs'])) { + $armor['leggings'] = self::parseItem($equipment['legs']); + } + if (isset($equipment['feet']) && is_array($equipment['feet']) && !empty($equipment['feet'])) { + $armor['boots'] = self::parseItem($equipment['feet']); + } + + // Check for list format with slot names + if (isset($equipment[0])) { + foreach ($equipment as $slot) { + if (!is_array($slot)) continue; + $slotName = $slot['slot'] ?? ''; + $item = $slot['item'] ?? $slot; + + if (empty($item) || !isset($item['id'])) continue; + + switch ($slotName) { + case 'head': + case 'minecraft:head': + $armor['helmet'] = self::parseItem($item); + break; + case 'chest': + case 'minecraft:chest': + $armor['chestplate'] = self::parseItem($item); + break; + case 'legs': + case 'minecraft:legs': + $armor['leggings'] = self::parseItem($item); + break; + case 'feet': + case 'minecraft:feet': + $armor['boots'] = self::parseItem($item); + break; + } + } + } + } + + // Method 2: Fallback to Inventory slots 100-103 (pre-1.20.5) + if (isset($data['Inventory']) && is_array($data['Inventory'])) { + foreach ($data['Inventory'] as $item) { + $slot = $item['Slot'] ?? -1; + + // Handle if slot is wrapped in an array or value key + if (is_array($slot)) { + $slot = $slot['value'] ?? $slot[0] ?? -1; + } + + // Convert to int + $slot = (int) $slot; + + // Handle negative values (signed byte interpretation) + if ($slot < 0) { + $slot = $slot + 256; + } + + switch ($slot) { + case 100: + if ($armor['boots'] === null) { + $armor['boots'] = self::parseItem($item); + } + break; + case 101: + if ($armor['leggings'] === null) { + $armor['leggings'] = self::parseItem($item); + } + break; + case 102: + if ($armor['chestplate'] === null) { + $armor['chestplate'] = self::parseItem($item); + } + break; + case 103: + if ($armor['helmet'] === null) { + $armor['helmet'] = self::parseItem($item); + } + break; + } + } + } + + return $armor; + } + + /** + * Extract ender chest contents from parsed NBT data. + */ + public static function extractEnderChest(array $nbt): array + { + $data = $nbt['value'] ?? $nbt; + $enderChest = []; + + if (isset($data['EnderItems']) && is_array($data['EnderItems'])) { + foreach ($data['EnderItems'] as $item) { + $enderChest[] = self::parseItem($item); + } + } + + return $enderChest; + } + + /** + * Extract player location from parsed NBT data. + */ + public static function extractLocation(array $nbt): array + { + $data = $nbt['value'] ?? $nbt; + + $pos = $data['Pos'] ?? [0, 0, 0]; + $rotation = $data['Rotation'] ?? [0, 0]; + $dimension = $data['Dimension'] ?? 'minecraft:overworld'; + + // Handle old-style dimension IDs + if (is_int($dimension)) { + $dimension = match ($dimension) { + -1 => 'minecraft:the_nether', + 0 => 'minecraft:overworld', + 1 => 'minecraft:the_end', + default => 'minecraft:overworld', + }; + } + + return [ + 'x' => round($pos[0] ?? 0, 2), + 'y' => round($pos[1] ?? 0, 2), + 'z' => round($pos[2] ?? 0, 2), + 'yaw' => round($rotation[0] ?? 0, 2), + 'pitch' => round($rotation[1] ?? 0, 2), + 'dimension' => $dimension, + 'world' => self::getDimensionName($dimension), + ]; + } + + /** + * Extract player health and food data. + */ + public static function extractStats(array $nbt): array + { + $data = $nbt['value'] ?? $nbt; + + return [ + 'health' => $data['Health'] ?? 20, + 'maxHealth' => 20, // Default, can be modified by attributes + 'food' => $data['foodLevel'] ?? 20, + 'saturation' => round($data['foodSaturationLevel'] ?? 5, 2), + 'xpLevel' => $data['XpLevel'] ?? 0, + 'xpTotal' => $data['XpTotal'] ?? 0, + 'xpProgress' => round(($data['XpP'] ?? 0) * 100, 1), + 'gamemode' => self::getGamemodeName($data['playerGameType'] ?? 0), + 'score' => $data['Score'] ?? 0, + ]; + } + + /** + * Parse a single item from NBT (public wrapper). + */ + public static function parseItemPublic(array $item): array + { + return self::parseItem($item); + } + + /** + * Parse a single item from NBT. + */ + private static function parseItem(array $item): array + { + $id = $item['id'] ?? $item['Id'] ?? 'minecraft:air'; + + // Handle numeric IDs (legacy) + if (is_int($id)) { + $id = "minecraft:legacy_$id"; + } + + // Remove minecraft: prefix for display + $displayId = str_replace('minecraft:', '', $id); + + $parsed = [ + 'id' => $id, + 'displayId' => $displayId, + 'name' => self::getItemName($displayId), + 'slot' => $item['Slot'] ?? 0, + 'count' => $item['Count'] ?? $item['count'] ?? 1, + 'damage' => $item['Damage'] ?? 0, + 'enchantments' => [], + 'storedEnchantments' => [], + 'customName' => null, + 'lore' => [], + 'durability' => null, + 'contents' => [], + ]; + + // Parse tag data (contains enchantments, custom name, etc.) + $tag = $item['tag'] ?? $item['components'] ?? []; + + if (!empty($tag)) { + // Custom name + if (isset($tag['display']['Name'])) { + $name = $tag['display']['Name']; + // Try to parse JSON text component + if (str_starts_with($name, '{') || str_starts_with($name, '"')) { + $decoded = json_decode($name, true); + $parsed['customName'] = $decoded['text'] ?? $name; + } else { + $parsed['customName'] = $name; + } + } + + // Custom name (1.20.5+ format) + if (isset($tag['minecraft:custom_name'])) { + $name = $tag['minecraft:custom_name']; + if (is_string($name)) { + $decoded = json_decode($name, true); + $parsed['customName'] = $decoded['text'] ?? $name; + } + } + + // Lore + if (isset($tag['display']['Lore']) && is_array($tag['display']['Lore'])) { + foreach ($tag['display']['Lore'] as $line) { + if (str_starts_with($line, '{') || str_starts_with($line, '"')) { + $decoded = json_decode($line, true); + $parsed['lore'][] = $decoded['text'] ?? $line; + } else { + $parsed['lore'][] = $line; + } + } + } + + // Enchantments (multiple formats for different versions) + // Pre-1.20.5: tag.Enchantments or tag.ench (array of {id, lvl}) + // 1.20.5+: tag.minecraft:enchantments (object {minecraft:enchant_id: level}) + $enchants = $tag['Enchantments'] ?? $tag['ench'] ?? []; + + // Handle 1.20.5+ format: minecraft:enchantments is an object directly + if (empty($enchants) && isset($tag['minecraft:enchantments'])) { + $enchantsData = $tag['minecraft:enchantments']; + // It could be {levels: {...}} or directly {...} + if (isset($enchantsData['levels']) && is_array($enchantsData['levels'])) { + $enchants = $enchantsData['levels']; + } elseif (is_array($enchantsData)) { + $enchants = $enchantsData; + } + } + + if (is_array($enchants)) { + foreach ($enchants as $key => $enchant) { + if (is_array($enchant)) { + // Old format: {id: "minecraft:mending", lvl: 1} + $enchId = $enchant['id'] ?? ''; + $level = $enchant['lvl'] ?? 1; + } else { + // 1.20.5+ format: key is enchant id, value is level + $enchId = $key; + $level = (int) $enchant; + } + + // Remove minecraft: prefix + $enchId = str_replace('minecraft:', '', $enchId); + + if (!empty($enchId)) { + $parsed['enchantments'][] = [ + 'id' => $enchId, + 'name' => self::getEnchantmentName($enchId), + 'level' => $level, + 'levelRoman' => self::toRoman($level), + ]; + } + } + } + + // Stored Enchantments (for enchanted books) + // Pre-1.20.5: tag.StoredEnchantments (array of {id, lvl}) + // 1.20.5+: tag.minecraft:stored_enchantments (object {minecraft:enchant_id: level}) + $storedEnchants = $tag['StoredEnchantments'] ?? []; + + // Handle 1.20.5+ format + if (empty($storedEnchants) && isset($tag['minecraft:stored_enchantments'])) { + $storedData = $tag['minecraft:stored_enchantments']; + if (isset($storedData['levels']) && is_array($storedData['levels'])) { + $storedEnchants = $storedData['levels']; + } elseif (is_array($storedData)) { + $storedEnchants = $storedData; + } + } + + if (is_array($storedEnchants)) { + foreach ($storedEnchants as $key => $enchant) { + if (is_array($enchant)) { + $enchId = $enchant['id'] ?? ''; + $level = $enchant['lvl'] ?? 1; + } else { + // 1.20.5+ format + $enchId = $key; + $level = (int) $enchant; + } + + $enchId = str_replace('minecraft:', '', $enchId); + + if (!empty($enchId)) { + $parsed['storedEnchantments'][] = [ + 'id' => $enchId, + 'name' => self::getEnchantmentName($enchId), + 'level' => $level, + 'levelRoman' => self::toRoman($level), + ]; + } + } + } + + // Bundle contents (1.17+) + $bundleContents = $tag['Items'] ?? $tag['minecraft:bundle_contents'] ?? []; + if (is_array($bundleContents) && !empty($bundleContents)) { + foreach ($bundleContents as $contentItem) { + // 1.20.5+ format: each item might have 'item' wrapper + if (isset($contentItem['item'])) { + $parsed['contents'][] = self::parseItem($contentItem['item']); + } else { + $parsed['contents'][] = self::parseItem($contentItem); + } + } + } + + // Shulker box / Block entity contents + // Pre-1.20.5: tag.BlockEntityTag.Items + // 1.20.5+: components.minecraft:container (array of {slot, item}) + $blockEntityTag = $tag['BlockEntityTag'] ?? null; + $containerComponent = $tag['minecraft:container'] ?? null; + + // Handle pre-1.20.5 format (BlockEntityTag.Items) + if ($blockEntityTag !== null && is_array($blockEntityTag)) { + $containerItems = $blockEntityTag['Items'] ?? []; + if (is_array($containerItems) && !empty($containerItems)) { + foreach ($containerItems as $contentItem) { + if (isset($contentItem['id'])) { + $parsed['contents'][] = self::parseItem($contentItem); + } + } + } + } + + // Handle 1.20.5+ format (minecraft:container array of {slot, item}) + if ($containerComponent !== null && is_array($containerComponent)) { + foreach ($containerComponent as $slotData) { + // Format: [{slot: 0, item: {id: "minecraft:...", ...}}, ...] + if (isset($slotData['item'])) { + $parsed['contents'][] = self::parseItem($slotData['item']); + } elseif (isset($slotData['id'])) { + // Direct item format + $parsed['contents'][] = self::parseItem($slotData); + } + } + } + + // Durability + if (isset($tag['Damage'])) { + $parsed['damage'] = $tag['Damage']; + } + if (isset($tag['minecraft:damage'])) { + $parsed['damage'] = $tag['minecraft:damage']; + } + + // Max durability + $maxDurability = self::getMaxDurability($displayId); + if ($maxDurability > 0) { + $parsed['durability'] = [ + 'current' => $maxDurability - ($parsed['damage'] ?? 0), + 'max' => $maxDurability, + 'percentage' => round((($maxDurability - ($parsed['damage'] ?? 0)) / $maxDurability) * 100, 1), + ]; + } + } + + return $parsed; + } + + /** + * Get human-readable dimension name. + */ + private static function getDimensionName(string $dimension): string + { + return match ($dimension) { + 'minecraft:overworld' => 'Overworld', + 'minecraft:the_nether' => 'The Nether', + 'minecraft:the_end' => 'The End', + default => ucwords(str_replace(['minecraft:', '_'], ['', ' '], $dimension)), + }; + } + + /** + * Get human-readable gamemode name. + */ + private static function getGamemodeName(int $mode): string + { + return match ($mode) { + 0 => 'Survival', + 1 => 'Creative', + 2 => 'Adventure', + 3 => 'Spectator', + default => 'Unknown', + }; + } + + /** + * Convert item ID to readable name. + */ + private static function getItemName(string $id): string + { + // Convert snake_case to Title Case + $name = str_replace('_', ' ', $id); + return ucwords($name); + } + + /** + * Convert enchantment ID to readable name. + */ + private static function getEnchantmentName(string $id): string + { + $names = [ + 'protection' => 'Protection', + 'fire_protection' => 'Fire Protection', + 'feather_falling' => 'Feather Falling', + 'blast_protection' => 'Blast Protection', + 'projectile_protection' => 'Projectile Protection', + 'respiration' => 'Respiration', + 'aqua_affinity' => 'Aqua Affinity', + 'thorns' => 'Thorns', + 'depth_strider' => 'Depth Strider', + 'frost_walker' => 'Frost Walker', + 'binding_curse' => 'Curse of Binding', + 'soul_speed' => 'Soul Speed', + 'swift_sneak' => 'Swift Sneak', + 'sharpness' => 'Sharpness', + 'smite' => 'Smite', + 'bane_of_arthropods' => 'Bane of Arthropods', + 'knockback' => 'Knockback', + 'fire_aspect' => 'Fire Aspect', + 'looting' => 'Looting', + 'sweeping' => 'Sweeping Edge', + 'sweeping_edge' => 'Sweeping Edge', + 'efficiency' => 'Efficiency', + 'silk_touch' => 'Silk Touch', + 'unbreaking' => 'Unbreaking', + 'fortune' => 'Fortune', + 'power' => 'Power', + 'punch' => 'Punch', + 'flame' => 'Flame', + 'infinity' => 'Infinity', + 'luck_of_the_sea' => 'Luck of the Sea', + 'lure' => 'Lure', + 'loyalty' => 'Loyalty', + 'impaling' => 'Impaling', + 'riptide' => 'Riptide', + 'channeling' => 'Channeling', + 'multishot' => 'Multishot', + 'quick_charge' => 'Quick Charge', + 'piercing' => 'Piercing', + 'mending' => 'Mending', + 'vanishing_curse' => 'Curse of Vanishing', + 'density' => 'Density', + 'breach' => 'Breach', + 'wind_burst' => 'Wind Burst', + ]; + + return $names[$id] ?? ucwords(str_replace('_', ' ', $id)); + } + + /** + * Get max durability for an item. + */ + private static function getMaxDurability(string $id): int + { + $durabilities = [ + // Tools - Wood + 'wooden_sword' => 59, 'wooden_pickaxe' => 59, 'wooden_axe' => 59, + 'wooden_shovel' => 59, 'wooden_hoe' => 59, + // Tools - Stone + 'stone_sword' => 131, 'stone_pickaxe' => 131, 'stone_axe' => 131, + 'stone_shovel' => 131, 'stone_hoe' => 131, + // Tools - Iron + 'iron_sword' => 250, 'iron_pickaxe' => 250, 'iron_axe' => 250, + 'iron_shovel' => 250, 'iron_hoe' => 250, + // Tools - Gold + 'golden_sword' => 32, 'golden_pickaxe' => 32, 'golden_axe' => 32, + 'golden_shovel' => 32, 'golden_hoe' => 32, + // Tools - Diamond + 'diamond_sword' => 1561, 'diamond_pickaxe' => 1561, 'diamond_axe' => 1561, + 'diamond_shovel' => 1561, 'diamond_hoe' => 1561, + // Tools - Netherite + 'netherite_sword' => 2031, 'netherite_pickaxe' => 2031, 'netherite_axe' => 2031, + 'netherite_shovel' => 2031, 'netherite_hoe' => 2031, + // Armor - Leather + 'leather_helmet' => 55, 'leather_chestplate' => 80, + 'leather_leggings' => 75, 'leather_boots' => 65, + // Armor - Chain + 'chainmail_helmet' => 165, 'chainmail_chestplate' => 240, + 'chainmail_leggings' => 225, 'chainmail_boots' => 195, + // Armor - Iron + 'iron_helmet' => 165, 'iron_chestplate' => 240, + 'iron_leggings' => 225, 'iron_boots' => 195, + // Armor - Gold + 'golden_helmet' => 77, 'golden_chestplate' => 112, + 'golden_leggings' => 105, 'golden_boots' => 91, + // Armor - Diamond + 'diamond_helmet' => 363, 'diamond_chestplate' => 528, + 'diamond_leggings' => 495, 'diamond_boots' => 429, + // Armor - Netherite + 'netherite_helmet' => 407, 'netherite_chestplate' => 592, + 'netherite_leggings' => 555, 'netherite_boots' => 481, + // Other + 'bow' => 384, 'crossbow' => 465, 'trident' => 250, + 'shield' => 336, 'elytra' => 432, + 'fishing_rod' => 64, 'shears' => 238, 'flint_and_steel' => 64, + 'carrot_on_a_stick' => 25, 'warped_fungus_on_a_stick' => 100, + 'brush' => 64, 'mace' => 500, + ]; + + return $durabilities[$id] ?? 0; + } + + /** + * Convert number to Roman numeral. + */ + private static function toRoman(int $num): string + { + if ($num <= 0 || $num > 255) { + return (string) $num; + } + + $map = [ + 100 => 'C', 90 => 'XC', 50 => 'L', 40 => 'XL', + 10 => 'X', 9 => 'IX', 5 => 'V', 4 => 'IV', 1 => 'I', + ]; + + $result = ''; + foreach ($map as $value => $roman) { + while ($num >= $value) { + $result .= $roman; + $num -= $value; + } + } + return $result; + } +} diff --git a/app/Services/Extensions/ScanResult.php b/app/Services/Extensions/ScanResult.php new file mode 100644 index 0000000000..6195d5acec --- /dev/null +++ b/app/Services/Extensions/ScanResult.php @@ -0,0 +1,64 @@ +> $phpFindings + * @param array> $jsFindings + * @param array> $semgrepFindings + */ + public function __construct( + public readonly string $outcome, + public readonly array $phpFindings, + public readonly array $jsFindings, + public readonly array $semgrepFindings, + public readonly string $reportPath, + public readonly \DateTimeImmutable $scannedAt, + ) { + } + + public function isBlocked(): bool + { + return $this->outcome === self::BLOCKED; + } + + public function hasSevereFindings(): bool + { + return $this->outcome !== self::PASSED; + } + + /** + * @return array + */ + public function toArray(): array + { + $high = count(array_filter($this->phpFindings, fn ($f) => ($f['severity'] ?? '') === 'ERROR')) + + count(array_filter($this->jsFindings, fn ($f) => ($f['severity'] ?? 0) === 2)) + + count(array_filter($this->semgrepFindings, fn ($f) => ($f['severity'] ?? '') === 'ERROR')); + + $warnings = count(array_filter($this->phpFindings, fn ($f) => ($f['severity'] ?? '') === 'WARNING')) + + count(array_filter($this->jsFindings, fn ($f) => ($f['severity'] ?? 0) === 1)) + + count(array_filter($this->semgrepFindings, fn ($f) => ($f['severity'] ?? '') === 'WARNING')); + + return [ + 'scanned_at' => $this->scannedAt->format(\DateTimeInterface::ATOM), + 'outcome' => $this->outcome, + 'php_findings' => $this->phpFindings, + 'js_findings' => $this->jsFindings, + 'semgrep_findings' => $this->semgrepFindings, + 'report_path' => $this->reportPath, + 'summary' => [ + 'high' => $high, + 'warnings' => $warnings, + ], + ]; + } +} diff --git a/app/Services/Nodes/WingsDetectionService.php b/app/Services/Nodes/WingsDetectionService.php new file mode 100644 index 0000000000..2054164c81 --- /dev/null +++ b/app/Services/Nodes/WingsDetectionService.php @@ -0,0 +1,110 @@ +configurationRepository->setNode($node); + + $overviewData = $this->fetchWingsRsOverview($repository); + $systemData = $repository->getSystemInformation(); + + $isSupercharged = !is_null($overviewData) + || !empty($systemData['supercharged']) + || $this->isWingsRsVersion($systemData['version'] ?? ''); + + $wingsVersion = $overviewData['version'] + ?? $systemData['version'] + ?? null; + + $node->update([ + 'wings_type' => $isSupercharged ? Node::WINGS_TYPE_RS : Node::WINGS_TYPE_DEFAULT, + 'wings_version' => $wingsVersion, + 'wings_detected_at' => CarbonImmutable::now(), + ]); + + return $isSupercharged; + } catch (\Exception $e) { + Log::warning('Failed to detect Wings type for node ' . $node->name, [ + 'node_id' => $node->id, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + /** + * Attempt to fetch Wings-RS system overview. + * Returns null when endpoint is not available (normal Wings) or on error. + */ + private function fetchWingsRsOverview(DaemonConfigurationRepository $repository): ?array + { + try { + $response = $repository->getHttpClient()->get('/api/system/overview'); + $data = json_decode($response->getBody()->__toString(), true); + + if (!is_array($data)) { + return null; + } + + return $data; + } catch (\Exception) { + return null; + } + } + + /** + * Check if the version string indicates Wings-RS. + */ + private function isWingsRsVersion(string $version): bool + { + // Wings-RS uses Rust-style version strings or contains 'rs' identifier + return str_contains(strtolower($version), 'rs') + || str_contains(strtolower($version), 'rust') + || str_contains(strtolower($version), 'supercharged'); + } + + /** + * Detect Wings-RS for a node and return system overview data if available. + * This calls /api/system/overview which is Wings-RS exclusive. + */ + public function getOverview(Node $node): ?array + { + if (!$node->isSupercharged()) { + return null; + } + + try { + $response = $this->configurationRepository->setNode($node) + ->getHttpClient() + ->get('/api/system/overview'); + + return json_decode($response->getBody()->__toString(), true); + } catch (\Exception $e) { + Log::debug('Failed to get Wings-RS overview for node ' . $node->name, [ + 'node_id' => $node->id, + 'error' => $e->getMessage(), + ]); + + return null; + } + } +} diff --git a/app/Services/Servers/BuildModificationService.php b/app/Services/Servers/BuildModificationService.php index 6d72148f52..99a0a159ec 100644 --- a/app/Services/Servers/BuildModificationService.php +++ b/app/Services/Servers/BuildModificationService.php @@ -53,6 +53,7 @@ public function handle(Server $server, array $data): Server 'backup_limit' => Arr::get($data, 'backup_limit', 0) ?? 0, 'database_limit' => Arr::get($data, 'database_limit', 0) ?? null, 'subuser_limit' => Arr::get($data, 'subuser_limit', 0) ?? null, + 'subdomain_limit' => Arr::get($data, 'subdomain_limit', $server->subdomain_limit), ]))->saveOrFail(); return $server->refresh(); @@ -89,21 +90,15 @@ private function processAllocations(Server $server, array &$data): void // Handle the addition of allocations to this server. Only assign allocations that are not currently // assigned to a different server, and only allocations on the same node as the server. if (!empty($data['add_allocations'])) { - // Get all matching allocations first to track the first ID for potential primary allocation reassignment - $allocations = $server->node->allocations() + $query = $server->node->allocations() ->whereIn('id', $data['add_allocations']) - ->whereNull('server_id') - ->get(); + ->whereNull('server_id'); - // Keep track of the first allocation we're adding so that we can use it - // to reset the default allocation if needed. - $freshlyAllocated = $allocations->first()->id ?? null; + // Keep track of all the allocations we're just now adding so that we can use the first + // one to reset the default allocation to. + $freshlyAllocated = $query->first()->id ?? null; - // Update all matched allocations to assign them to this server - $server->node->allocations() - ->whereIn('id', $data['add_allocations']) - ->whereNull('server_id') - ->update(['server_id' => $server->id, 'notes' => null]); + $query->update(['server_id' => $server->id, 'notes' => null]); } if (!empty($data['remove_allocations'])) { diff --git a/app/Services/Servers/GetUserPermissionsService.php b/app/Services/Servers/GetUserPermissionsService.php index 4f67cff74b..0aa4e404d4 100644 --- a/app/Services/Servers/GetUserPermissionsService.php +++ b/app/Services/Servers/GetUserPermissionsService.php @@ -4,6 +4,7 @@ use Everest\Models\User; use Everest\Models\Server; +use Everest\Models\Permission; class GetUserPermissionsService { @@ -29,6 +30,6 @@ public function handle(Server $server, User $user): array /** @var \Everest\Models\Subuser|null $subuserPermissions */ $subuserPermissions = $server->subusers()->where('user_id', $user->id)->first(); - return $subuserPermissions ? $subuserPermissions->permissions : []; + return $subuserPermissions ? Permission::expandPermissions($subuserPermissions->permissions ?? []) : []; } } diff --git a/app/Services/Servers/ServerCreationService.php b/app/Services/Servers/ServerCreationService.php index 76ef2bebcd..4924bbceb6 100644 --- a/app/Services/Servers/ServerCreationService.php +++ b/app/Services/Servers/ServerCreationService.php @@ -165,6 +165,7 @@ private function createModel(array $data): Server 'allocation_limit' => Arr::get($data, 'allocation_limit') ?? 0, 'backup_limit' => Arr::get($data, 'backup_limit') ?? 0, 'subuser_limit' => Arr::get($data, 'subuser_limit') ?? 0, + 'subdomain_limit' => Arr::get($data, 'subdomain_limit', 1), ]); return $model; diff --git a/app/Services/Servers/ServerDeletionService.php b/app/Services/Servers/ServerDeletionService.php index e6dbffc041..cd6e802c00 100644 --- a/app/Services/Servers/ServerDeletionService.php +++ b/app/Services/Servers/ServerDeletionService.php @@ -6,6 +6,7 @@ use Illuminate\Http\Response; use Illuminate\Support\Facades\Log; use Illuminate\Database\ConnectionInterface; +use Everest\Jobs\CustomDomains\CleanupServerCustomDomainsJob; use Everest\Repositories\Wings\DaemonServerRepository; use Everest\Services\Databases\DatabaseManagementService; use Everest\Exceptions\Http\Connection\DaemonConnectionException; @@ -56,6 +57,17 @@ public function handle(Server $server): void Log::warning($exception); } + // Clean up custom domain DNS records BEFORE deleting the server. + // The server_custom_domains table has a cascadeOnDelete FK on server_id, which means + // the DB cascade removes the rows at the same time the server row is deleted. By the + // time the async CleanupServerCustomDomainsJob (dispatched in ServerObserver::deleted) + // runs, the rows are already gone and Cloudflare DNS records are never removed. + // Running the job synchronously here — outside the transaction and before the server + // row is deleted — ensures the rows still exist when cleanup runs. + if (config('modules.custom_domains.cleanup_on_delete', true)) { + CleanupServerCustomDomainsJob::dispatchSync($server->id); + } + $this->connection->transaction(function () use ($server) { foreach ($server->databases as $database) { try { diff --git a/app/Transformers/Api/Application/ProductTransformer.php b/app/Transformers/Api/Application/ProductTransformer.php index 2f7f7e0319..a471a524ca 100644 --- a/app/Transformers/Api/Application/ProductTransformer.php +++ b/app/Transformers/Api/Application/ProductTransformer.php @@ -43,6 +43,7 @@ public function transform(Product $model): array 'backup' => $model->backup_limit, 'database' => $model->database_limit, 'allocation' => $model->allocation_limit, + 'subdomain' => $model->subdomain_limit, ], 'created_at' => $model->created_at->toIso8601String(), 'updated_at' => $model->updated_at->toIso8601String() ? $model->updated_at->toIso8601String() : null, diff --git a/app/Transformers/Api/Application/ServerTransformer.php b/app/Transformers/Api/Application/ServerTransformer.php index 71d873d5ba..f8f20c1b1a 100644 --- a/app/Transformers/Api/Application/ServerTransformer.php +++ b/app/Transformers/Api/Application/ServerTransformer.php @@ -74,6 +74,7 @@ public function transform(Server $model): array 'backups' => $model->backup_limit, 'databases' => $model->database_limit, 'subusers' => $model->subuser_limit, + 'subdomains' => $model->subdomain_limit ?? $model->product?->subdomain_limit, ], 'owner_id' => $model->owner_id, 'node_id' => $model->node_id, diff --git a/app/Transformers/Api/Application/SubuserTransformer.php b/app/Transformers/Api/Application/SubuserTransformer.php index 1f7426aaf7..2ab55cd29c 100644 --- a/app/Transformers/Api/Application/SubuserTransformer.php +++ b/app/Transformers/Api/Application/SubuserTransformer.php @@ -3,6 +3,7 @@ namespace Everest\Transformers\Api\Application; use Everest\Models\Subuser; +use Everest\Models\Permission; use League\Fractal\Resource\Item; use Everest\Services\Acl\Api\AdminAcl; use Everest\Transformers\Api\Transformer; @@ -32,7 +33,7 @@ public function transform(Subuser $model): array 'id' => $model->id, 'user_id' => $model->user_id, 'server_id' => $model->server_id, - 'permissions' => $model->permissions, + 'permissions' => Permission::expandPermissions($model->permissions ?? []), 'created_at' => $model->created_at->toIso8601String(), 'updated_at' => $model->updated_at->toIso8601String(), ]; diff --git a/app/Transformers/Api/Client/ProductTransformer.php b/app/Transformers/Api/Client/ProductTransformer.php index a288ca77ad..05d915dc8f 100644 --- a/app/Transformers/Api/Client/ProductTransformer.php +++ b/app/Transformers/Api/Client/ProductTransformer.php @@ -40,6 +40,7 @@ public function transform(Product $model): array 'backup' => $model->backup_limit, 'database' => $model->database_limit, 'allocation' => $model->allocation_limit, + 'subdomain' => $model->subdomain_limit, ], ]; } diff --git a/app/Transformers/Api/Client/ServerTransformer.php b/app/Transformers/Api/Client/ServerTransformer.php index 735944e34b..42c8fa020c 100644 --- a/app/Transformers/Api/Client/ServerTransformer.php +++ b/app/Transformers/Api/Client/ServerTransformer.php @@ -6,6 +6,7 @@ use Everest\Models\Server; use Everest\Models\Allocation; use Everest\Models\Permission; +use Everest\Models\ExtensionConfig; use League\Fractal\Resource\Item; use Illuminate\Container\Container; use League\Fractal\Resource\Collection; @@ -57,6 +58,10 @@ public function transform(Server $server): array $modpacksSupported = $hasProjectId && $hasVersionId; } + // Check if any extensions are enabled for this server + $extensionsEnabled = config('modules.extensions.enabled', false) && + !empty(ExtensionConfig::getEnabledForServer($server)); + return [ 'server_owner' => $user->id === $server->owner_id, 'identifier' => $server->uuidShort, @@ -67,6 +72,7 @@ public function transform(Server $server): array 'node' => $server->node->name, 'node_id' => $server->node_id, 'is_node_under_maintenance' => $server->node->isUnderMaintenance(), + 'is_node_supercharged' => $server->node->isSupercharged(), 'sftp_details' => [ 'ip' => $server->node->fqdn, 'port' => $server->node->public_port_sftp, @@ -86,6 +92,7 @@ public function transform(Server $server): array 'egg_features' => $server->egg->inherit_features, 'egg_id' => $server->egg_id, 'modpacks_supported' => $modpacksSupported, + 'extensions_enabled' => $extensionsEnabled, 'billing_product_id' => $server->billing_product_id, 'billing_days' => $server->billing_days, 'feature_limits' => [ @@ -93,6 +100,7 @@ public function transform(Server $server): array 'allocations' => $server->allocation_limit, 'backups' => $server->backup_limit, 'subusers' => $server->subuser_limit, + 'subdomains' => $server->subdomain_limit ?? $server->product?->subdomain_limit, ], 'status' => $server->status, 'renewal_date' => $server->renewal_date, diff --git a/app/Transformers/Api/Client/SubuserTransformer.php b/app/Transformers/Api/Client/SubuserTransformer.php index c8e8673e30..399ddf3e2f 100644 --- a/app/Transformers/Api/Client/SubuserTransformer.php +++ b/app/Transformers/Api/Client/SubuserTransformer.php @@ -3,6 +3,7 @@ namespace Everest\Transformers\Api\Client; use Everest\Models\Subuser; +use Everest\Models\Permission; use Everest\Transformers\Api\Transformer; class SubuserTransformer extends Transformer @@ -22,7 +23,7 @@ public function transform(Subuser $model): array { return array_merge( (new UserTransformer())->transform($model->user), - ['permissions' => $model->permissions] + ['permissions' => Permission::expandPermissions($model->permissions ?? [])] ); } } diff --git a/composer.json b/composer.json index b3f587a557..0bbe8cad18 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,6 @@ "fakerphp/faker": "~1.21.0", "friendsofphp/php-cs-fixer": "~3.14.4", "itsgoingd/clockwork": "~5.1.12", - "laravel/sail": "~1.21.0", "mockery/mockery": "~1.5.1", "nunomaduro/collision": "~7.0.5", "nunomaduro/larastan": "~2.4.1", diff --git a/config/extensions.php b/config/extensions.php new file mode 100644 index 0000000000..d2ada52005 --- /dev/null +++ b/config/extensions.php @@ -0,0 +1,14 @@ + [ + 'phpcs_binary' => env('EXTENSIONS_PHPCS_BIN', 'phpcs'), + 'eslint_binary' => env('EXTENSIONS_ESLINT_BIN', 'npx eslint'), + 'semgrep_binary' => env('EXTENSIONS_SEMGREP_BIN', 'semgrep'), + 'semgrep_enabled' => env('EXTENSIONS_SEMGREP_ENABLED', false), + 'semgrep_rulesets' => env('EXTENSIONS_SEMGREP_RULESETS', 'p/php-security,p/javascript'), + 'block_on_high' => env('EXTENSIONS_BLOCK_ON_HIGH', true), + 'temp_dir' => storage_path('app/extension-scans'), + 'install_dir' => storage_path('app/extensions/installed'), + ], +]; diff --git a/config/modules/custom_domains.php b/config/modules/custom_domains.php new file mode 100644 index 0000000000..f22f29d6f3 --- /dev/null +++ b/config/modules/custom_domains.php @@ -0,0 +1,26 @@ + env('CUSTOM_DOMAINS_ENABLED', true), + + 'cloudflare' => [ + 'token' => env('CUSTOM_DOMAINS_CLOUDFLARE_TOKEN', ''), + 'base_url' => env('CUSTOM_DOMAINS_CLOUDFLARE_BASE_URL', 'https://api.cloudflare.com/client/v4'), + 'retries' => (int) env('CUSTOM_DOMAINS_CLOUDFLARE_RETRIES', 3), + 'retry_sleep_ms' => (int) env('CUSTOM_DOMAINS_CLOUDFLARE_RETRY_SLEEP_MS', 250), + 'proxied' => (bool) env('CUSTOM_DOMAINS_CLOUDFLARE_PROXIED', false), + ], + + 'cleanup_on_delete' => (bool) env('CUSTOM_DOMAINS_CLEANUP_ON_DELETE', true), + + 'security' => [ + 'allow_wildcard' => (bool) env('CUSTOM_DOMAINS_ALLOW_WILDCARD', false), + 'max_wildcards_per_user' => (int) env('CUSTOM_DOMAINS_MAX_WILDCARDS_PER_USER', 1), + ], + + 'rate_limits' => [ + 'create_per_minute' => (int) env('CUSTOM_DOMAINS_RATE_LIMIT_CREATE_PER_MINUTE', 10), + 'sync_per_minute' => (int) env('CUSTOM_DOMAINS_RATE_LIMIT_SYNC_PER_MINUTE', 5), + 'billing_options_per_minute' => (int) env('CUSTOM_DOMAINS_RATE_LIMIT_BILLING_OPTIONS_PER_MINUTE', 20), + ], +]; diff --git a/config/modules/extensions.php b/config/modules/extensions.php new file mode 100644 index 0000000000..805759dfa0 --- /dev/null +++ b/config/modules/extensions.php @@ -0,0 +1,167 @@ + env('EXTENSIONS_ENABLED', false), + + /* + * Available extensions configuration. + * Each extension can be enabled/disabled independently. + * + * --------------------------- + * Extension Settings (Admin UI) + * --------------------------- + * Extensions may define arbitrary admin-configurable settings using a `settings_schema`. + * + * - The admin panel renders the schema into a form automatically. + * - Saved values are persisted in the database table `extension_configs.settings` (JSON), per extension id. + * - These settings are GLOBAL for the extension (not per-server). + * - The client extension endpoints can then read those saved values and apply them as defaults. + * + * Schema format (array of fields): + * - key: string (required) + * - label: string (required) + * - type: one of: text | password | textarea | select | boolean | number + * - help: string (optional) + * - placeholder: string (optional) + * - options: array<{ label: string, value: string|number|boolean }> (select only) + * + * Where the schema is used: + * - Admin API returns `settingsSchema` from this config file. + * - Admin UI reads that schema and shows a "Settings" section in the Configure modal. + * - When you click Save, it sends a `settings` object back to the API which is stored in `extension_configs.settings`. + * + * Reading settings later (backend / extensions): + * - Read from the DB via `ExtensionConfig`: + * $config = \Everest\Models\ExtensionConfig::getByExtensionId('your_extension_id'); + * $settings = is_array($config?->settings) ? $config->settings : []; + * $value = $settings['your_key'] ?? null; + * + * Concrete examples: + * + * 1) URL string setting (text) + * Schema: + * 'settings_schema' => [ + * ['key' => 'jar_url', 'label' => 'Jar URL', 'type' => 'text'], + * ] + * Read + apply precedence (request override -> admin setting -> fallback): + * $jarUrl = $request->input('jar_url'); + * if (!$jarUrl) $jarUrl = $settings['jar_url'] ?? null; + * if (!$jarUrl) $jarUrl = $fallbackUrl; + * + * 2) Feature toggle (boolean) + * Schema: + * 'settings_schema' => [ + * ['key' => 'enable_fast_mode', 'label' => 'Enable Fast Mode', 'type' => 'boolean'], + * ] + * Stored value is `true`/`false` JSON in the DB. + * Read (PHP): + * $fastMode = (bool) ($settings['enable_fast_mode'] ?? false); + * + * If you ever integrate with systems that represent booleans as 1/0, + * treat them as truthy/falsey on read: + * $raw = $settings['enable_fast_mode'] ?? 0; + * $fastMode = (int) $raw === 1 || $raw === true; + * + * 3) Select / enum-like setting (select) + * Schema: + * 'settings_schema' => [ + * [ + * 'key' => 'log_level', + * 'label' => 'Log Level', + * 'type' => 'select', + * 'options' => [ + * ['label' => 'Info', 'value' => 'info'], + * ['label' => 'Debug', 'value' => 'debug'], + * ], + * ], + * ] + * Note: the browser will submit select values as strings; validate/cast if needed. + * Read (PHP): + * $level = (string) ($settings['log_level'] ?? 'info'); + * if (!in_array($level, ['info', 'debug'], true)) $level = 'info'; + * + * 4) Number setting (number) + * Schema: + * 'settings_schema' => [ + * ['key' => 'timeout_seconds', 'label' => 'Timeout (seconds)', 'type' => 'number'], + * ] + * Read (PHP): + * $timeout = (int) ($settings['timeout_seconds'] ?? 15); + * + * Notes: + * - These settings are not automatically validated server-side beyond "must be an array". + * If a setting is security-sensitive, validate it in your request/controller. + * - If you need PER-SERVER settings, do not use this store; create a server-scoped table or use a server metadata mechanism. + * + */ + 'available' => [ + /* + * Example extension (copy/paste template) + * + * 1) Pick a unique ID (array key). This becomes the extension_id everywhere. + * 2) Create routes at: routes/extensions/client/.php + * and ensure the route prefix matches the `route` value below. + * 3) Add frontend route entry in the extensions registry (server UI). + * 4) Optional: define `settings_schema` to get schema-driven admin settings. + * + * NOTE: This block is commented out — it does nothing until you remove the comment. + */ + + // 'example_extension' => [ + // 'name' => 'Example Extension', + // 'description' => 'An example extension showing how to wire settings + routes.', + // 'version' => '0.1.0', + // 'author' => 'YourName', + // // Icon key (shown in admin + server extension lists). + // // Available: puzzle|users|gamepad|cube|server|discord|link|wrench|shield|terminal|globe|database|chart|bell|robot|cloud|folder|file|key|bolt|cogs|lock|scroll + // 'icon' => 'puzzle', + // 'route' => 'example_extension', + // + // // If you want a default enable flag from env: + // 'enabled' => env('EXTENSION_EXAMPLE_EXTENSION_ENABLED', false), + // + // // Eligibility (admin can override these in the UI) + // // Empty arrays mean "all nests/eggs". + // 'allowed_nests' => [], + // 'allowed_eggs' => [], + // + // // Optional admin-configurable settings (saved to extension_configs.settings) + // 'settings_schema' => [ + // [ + // 'key' => 'api_base_url', + // 'label' => 'API Base URL', + // 'type' => 'text', + // 'placeholder' => 'https://api.example.com', + // 'help' => 'Used as the default base URL for outbound API calls.', + // ], + // [ + // 'key' => 'enabled_mode', + // 'label' => 'Mode', + // 'type' => 'select', + // 'help' => 'Example select field. Stored in DB as a string.', + // 'options' => [ + // ['label' => 'Safe', 'value' => 'safe'], + // ['label' => 'Fast', 'value' => 'fast'], + // ], + // ], + // [ + // 'key' => 'feature_flag', + // 'label' => 'Enable Feature', + // 'type' => 'boolean', + // 'help' => 'Example boolean toggle. Stored as true/false JSON.', + // ], + // ], + // ], + + ], + + /* + * Extension permissions prefix. + * All extension permissions will be prefixed with this. + */ + 'permission_prefix' => 'extension', +]; diff --git a/database/migrations/2026_02_03_000000_create_extension_configs_table.php b/database/migrations/2026_02_03_000000_create_extension_configs_table.php new file mode 100644 index 0000000000..a60969eff1 --- /dev/null +++ b/database/migrations/2026_02_03_000000_create_extension_configs_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('extension_id')->index(); + $table->boolean('enabled')->default(false); + $table->json('allowed_nests')->nullable(); + $table->json('allowed_eggs')->nullable(); + $table->json('settings')->nullable(); + $table->timestamps(); + + $table->unique('extension_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('extension_configs'); + } +}; diff --git a/database/migrations/2026_02_09_000000_add_disabled_extensions_to_subusers_table.php b/database/migrations/2026_02_09_000000_add_disabled_extensions_to_subusers_table.php new file mode 100644 index 0000000000..69de4359b1 --- /dev/null +++ b/database/migrations/2026_02_09_000000_add_disabled_extensions_to_subusers_table.php @@ -0,0 +1,28 @@ +json('disabled_extensions')->nullable()->after('permissions'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('subusers', function (Blueprint $table) { + $table->dropColumn('disabled_extensions'); + }); + } +}; diff --git a/database/migrations/2026_02_09_000001_create_extension_file_snapshots_table.php b/database/migrations/2026_02_09_000001_create_extension_file_snapshots_table.php new file mode 100644 index 0000000000..a41215e747 --- /dev/null +++ b/database/migrations/2026_02_09_000001_create_extension_file_snapshots_table.php @@ -0,0 +1,44 @@ +limit(1)->exists(); + + if ($hasRows) { + if (!Schema::hasTable('extension_file_snapshots_legacy')) { + Schema::rename('extension_file_snapshots', 'extension_file_snapshots_legacy'); + } else { + Schema::drop('extension_file_snapshots'); + } + } else { + Schema::drop('extension_file_snapshots'); + } + } + + Schema::create('extension_file_snapshots', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('server_id')->index(); + $table->unsignedInteger('actor_id')->nullable()->index(); + $table->string('extension_id')->index(); + $table->string('action')->index(); + $table->longText('files'); + $table->timestamps(); + + $table->foreign('server_id')->references('id')->on('servers')->cascadeOnDelete(); + $table->foreign('actor_id')->references('id')->on('users')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('extension_file_snapshots'); + } +}; diff --git a/database/migrations/2026_02_17_120000_create_custom_domains_table.php b/database/migrations/2026_02_17_120000_create_custom_domains_table.php new file mode 100644 index 0000000000..54be8c2378 --- /dev/null +++ b/database/migrations/2026_02_17_120000_create_custom_domains_table.php @@ -0,0 +1,24 @@ +id(); + $table->string('domain')->unique(); + $table->string('cloudflare_zone_id')->nullable(); + $table->boolean('wildcard_enabled')->default(false); + $table->boolean('enabled')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('custom_domains'); + } +}; diff --git a/database/migrations/2026_02_17_120100_create_server_custom_domains_table.php b/database/migrations/2026_02_17_120100_create_server_custom_domains_table.php new file mode 100644 index 0000000000..df2beb5986 --- /dev/null +++ b/database/migrations/2026_02_17_120100_create_server_custom_domains_table.php @@ -0,0 +1,40 @@ +id(); + $table->unsignedInteger('server_id'); + $table->unsignedInteger('allocation_id')->nullable(); + $table->foreignId('custom_domain_id')->constrained('custom_domains')->cascadeOnDelete(); + $table->string('subdomain'); + $table->string('full_domain'); + $table->unsignedInteger('port'); + $table->enum('protocol', ['tcp', 'udp', 'both'])->default('both'); + $table->boolean('ssl_enabled')->default(false); + $table->enum('ssl_status', ['disabled', 'pending', 'issued', 'failed'])->default('disabled'); + $table->enum('status', ['pending', 'active', 'failed'])->default('pending'); + $table->json('dns_records')->nullable(); + $table->text('last_error')->nullable(); + $table->timestamp('last_synced_at')->nullable(); + $table->timestamps(); + + $table->unique(['full_domain', 'port', 'protocol'], 'server_custom_domains_unique_target'); + $table->index(['server_id', 'status']); + $table->index('allocation_id'); + + $table->foreign('server_id')->references('id')->on('servers')->cascadeOnDelete(); + $table->foreign('allocation_id')->references('id')->on('allocations')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('server_custom_domains'); + } +}; diff --git a/database/migrations/2026_02_17_120200_create_custom_domain_dns_logs_table.php b/database/migrations/2026_02_17_120200_create_custom_domain_dns_logs_table.php new file mode 100644 index 0000000000..7495835347 --- /dev/null +++ b/database/migrations/2026_02_17_120200_create_custom_domain_dns_logs_table.php @@ -0,0 +1,29 @@ +id(); + $table->unsignedInteger('server_id')->nullable(); + $table->foreignId('server_custom_domain_id')->nullable()->constrained('server_custom_domains')->nullOnDelete(); + $table->enum('action', ['create', 'update', 'delete', 'sync', 'ssl']); + $table->enum('status', ['success', 'failed']); + $table->json('payload')->nullable(); + $table->text('message')->nullable(); + $table->timestamps(); + + $table->index(['server_id', 'created_at']); + $table->foreign('server_id')->references('id')->on('servers')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('custom_domain_dns_logs'); + } +}; diff --git a/database/migrations/2026_02_17_120300_add_domain_payload_to_orders_table.php b/database/migrations/2026_02_17_120300_add_domain_payload_to_orders_table.php new file mode 100644 index 0000000000..50e0cd6e0d --- /dev/null +++ b/database/migrations/2026_02_17_120300_add_domain_payload_to_orders_table.php @@ -0,0 +1,21 @@ +json('domain_payload')->nullable()->after('variables'); + }); + } + + public function down(): void + { + Schema::table('orders', function (Blueprint $table) { + $table->dropColumn('domain_payload'); + }); + } +}; diff --git a/database/migrations/2026_02_18_000000_create_custom_domain_api_keys_table.php b/database/migrations/2026_02_18_000000_create_custom_domain_api_keys_table.php new file mode 100644 index 0000000000..20c3458e66 --- /dev/null +++ b/database/migrations/2026_02_18_000000_create_custom_domain_api_keys_table.php @@ -0,0 +1,23 @@ +id(); + $table->string('name')->unique(); + $table->text('token'); + $table->boolean('enabled')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('custom_domain_api_keys'); + } +}; diff --git a/database/migrations/2026_02_18_000001_add_subdomain_limit_to_servers_and_products.php b/database/migrations/2026_02_18_000001_add_subdomain_limit_to_servers_and_products.php new file mode 100644 index 0000000000..ce973ea8a3 --- /dev/null +++ b/database/migrations/2026_02_18_000001_add_subdomain_limit_to_servers_and_products.php @@ -0,0 +1,43 @@ +unsignedInteger('subdomain_limit')->nullable()->default(1)->after('subuser_limit'); + } + }); + + Schema::table('products', function (Blueprint $table) { + if (!Schema::hasColumn('products', 'subdomain_limit')) { + $table->unsignedInteger('subdomain_limit')->nullable()->default(1)->after('allocation_limit'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + if (Schema::hasColumn('servers', 'subdomain_limit')) { + $table->dropColumn('subdomain_limit'); + } + }); + + Schema::table('products', function (Blueprint $table) { + if (Schema::hasColumn('products', 'subdomain_limit')) { + $table->dropColumn('subdomain_limit'); + } + }); + } +}; diff --git a/database/migrations/2026_02_18_000100_update_custom_domains_for_api_keys_and_targeting.php b/database/migrations/2026_02_18_000100_update_custom_domains_for_api_keys_and_targeting.php new file mode 100644 index 0000000000..61bd169a08 --- /dev/null +++ b/database/migrations/2026_02_18_000100_update_custom_domains_for_api_keys_and_targeting.php @@ -0,0 +1,41 @@ +foreignId('api_key_id')->nullable()->after('cloudflare_zone_id')->constrained('custom_domain_api_keys')->nullOnDelete(); + $table->json('allowed_nest_ids')->nullable()->after('api_key_id'); + $table->json('allowed_egg_ids')->nullable()->after('allowed_nest_ids'); + $table->string('service_tag')->nullable()->after('allowed_egg_ids'); + }); + + Schema::table('server_custom_domains', function (Blueprint $table) { + $table->dropColumn('ssl_enabled'); + $table->dropColumn('ssl_status'); + $table->string('service_tag')->nullable()->after('protocol'); + }); + } + + public function down(): void + { + Schema::table('server_custom_domains', function (Blueprint $table) { + $table->dropColumn('service_tag'); + + $table->boolean('ssl_enabled')->default(false); + $table->enum('ssl_status', ['disabled', 'pending', 'issued', 'failed'])->default('disabled'); + }); + + Schema::table('custom_domains', function (Blueprint $table) { + $table->dropColumn('service_tag'); + $table->dropColumn('allowed_egg_ids'); + $table->dropColumn('allowed_nest_ids'); + + $table->dropConstrainedForeignId('api_key_id'); + }); + } +}; diff --git a/database/migrations/2026_02_18_000200_add_egg_service_tags_to_custom_domains_table.php b/database/migrations/2026_02_18_000200_add_egg_service_tags_to_custom_domains_table.php new file mode 100644 index 0000000000..cc327c686e --- /dev/null +++ b/database/migrations/2026_02_18_000200_add_egg_service_tags_to_custom_domains_table.php @@ -0,0 +1,21 @@ +json('egg_service_tags')->nullable()->after('service_tag'); + }); + } + + public function down(): void + { + Schema::table('custom_domains', function (Blueprint $table) { + $table->dropColumn('egg_service_tags'); + }); + } +}; diff --git a/database/migrations/2026_02_18_001000_add_record_type_to_server_custom_domains_table.php b/database/migrations/2026_02_18_001000_add_record_type_to_server_custom_domains_table.php new file mode 100644 index 0000000000..4fe06265cc --- /dev/null +++ b/database/migrations/2026_02_18_001000_add_record_type_to_server_custom_domains_table.php @@ -0,0 +1,21 @@ +enum('record_type', ['srv', 'cname'])->nullable()->after('protocol'); + }); + } + + public function down(): void + { + Schema::table('server_custom_domains', function (Blueprint $table) { + $table->dropColumn('record_type'); + }); + } +}; diff --git a/database/migrations/2026_02_28_000001_add_wings_rs_columns_to_nodes.php b/database/migrations/2026_02_28_000001_add_wings_rs_columns_to_nodes.php new file mode 100644 index 0000000000..c28277834c --- /dev/null +++ b/database/migrations/2026_02_28_000001_add_wings_rs_columns_to_nodes.php @@ -0,0 +1,42 @@ +string('wings_type', 20)->default('default')->after('maintenance_mode'); + } + if (!Schema::hasColumn('nodes', 'wings_version')) { + $table->string('wings_version', 50)->nullable()->after('wings_type'); + } + if (!Schema::hasColumn('nodes', 'wings_detected_at')) { + $table->timestamp('wings_detected_at')->nullable()->after('wings_version'); + } + }); + } + + public function down(): void + { + Schema::table('nodes', function (Blueprint $table) { + $columns = []; + if (Schema::hasColumn('nodes', 'wings_type')) { + $columns[] = 'wings_type'; + } + if (Schema::hasColumn('nodes', 'wings_version')) { + $columns[] = 'wings_version'; + } + if (Schema::hasColumn('nodes', 'wings_detected_at')) { + $columns[] = 'wings_detected_at'; + } + if (!empty($columns)) { + $table->dropColumn($columns); + } + }); + } +}; diff --git a/database/migrations/2026_04_11_000000_create_extension_repositories_table.php b/database/migrations/2026_04_11_000000_create_extension_repositories_table.php new file mode 100644 index 0000000000..8d57625d51 --- /dev/null +++ b/database/migrations/2026_04_11_000000_create_extension_repositories_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('slug')->unique(); + $table->string('name'); + $table->text('manifest_url'); + $table->text('homepage_url')->nullable(); + $table->boolean('enabled')->default(true); + $table->boolean('is_official')->default(false); + $table->timestamp('risk_acknowledged_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('extension_repositories'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_11_000001_create_extension_packages_table.php b/database/migrations/2026_04_11_000001_create_extension_packages_table.php new file mode 100644 index 0000000000..7628c5d29f --- /dev/null +++ b/database/migrations/2026_04_11_000001_create_extension_packages_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('extension_id')->unique(); + $table->string('package_id')->index(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('author')->nullable(); + $table->string('icon')->default('puzzle'); + $table->string('route')->nullable(); + $table->string('installed_version'); + $table->foreignId('source_repository_id')->nullable()->constrained('extension_repositories')->nullOnDelete(); + $table->string('source_repository_name')->nullable(); + $table->text('source_registry_url')->nullable(); + $table->text('source_archive_url')->nullable(); + $table->string('package_checksum', 64)->nullable(); + $table->json('manifest'); + $table->timestamp('installed_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('extension_packages'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_11_000002_create_extension_package_files_table.php b/database/migrations/2026_04_11_000002_create_extension_package_files_table.php new file mode 100644 index 0000000000..87651bfd54 --- /dev/null +++ b/database/migrations/2026_04_11_000002_create_extension_package_files_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('extension_package_id')->constrained('extension_packages')->cascadeOnDelete(); + $table->string('path')->unique(); + $table->string('operation'); + $table->string('installed_checksum', 64); + $table->text('backup_path')->nullable(); + $table->string('backup_checksum', 64)->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('extension_package_files'); + } +}; \ No newline at end of file diff --git a/docker-compose.example.yml b/docker-compose.example.yml deleted file mode 100644 index fe7c9bb210..0000000000 --- a/docker-compose.example.yml +++ /dev/null @@ -1,69 +0,0 @@ -version: '3.8' -x-common: - database: - &db-environment - # Do not remove the "&db-password" from the end of the line below, it is important - # for Panel functionality. - MYSQL_PASSWORD: &db-password "CHANGE_ME" - MYSQL_ROOT_PASSWORD: "CHANGE_ME_TOO" - panel: - &panel-environment - APP_URL: "http://example.com" - # A list of valid timezones can be found here: http://php.net/manual/en/timezones.php - APP_TIMEZONE: "UTC" - APP_SERVICE_AUTHOR: "noreply@example.com" - # Uncomment the line below and set to a non-empty value if you want to use Let's Encrypt - # to generate an SSL certificate for the Panel. - # LE_EMAIL: "" - -# -# ------------------------------------------------------------------------------------------ -# DANGER ZONE BELOW -# -# The remainder of this file likely does not need to be changed. Please only make modifications -# below if you understand what you are doing. -# -services: - database: - image: mariadb:10.5 - restart: always - command: --default-authentication-plugin=mysql_native_password - volumes: - - "/srv/pterodactyl/database:/var/lib/mysql" - environment: - <<: *db-environment - MYSQL_DATABASE: "panel" - MYSQL_USER: "pterodactyl" - cache: - image: redis:alpine - restart: always - panel: - image: ghcr.io/pterodactyl/panel:latest - restart: always - ports: - - "80:80" - - "443:443" - links: - - database - - cache - volumes: - - "/srv/pterodactyl/var/:/app/var/" - - "/srv/pterodactyl/nginx/:/etc/nginx/http.d/" - - "/srv/pterodactyl/certs/:/etc/letsencrypt/" - - "/srv/pterodactyl/logs/:/app/storage/logs" - environment: - <<: [*panel-environment] - DB_PASSWORD: *db-password - APP_ENV: "production" - APP_ENVIRONMENT_ONLY: "false" - CACHE_DRIVER: "redis" - SESSION_DRIVER: "redis" - QUEUE_DRIVER: "redis" - REDIS_HOST: "cache" - DB_HOST: "database" - DB_PORT: "3306" -networks: - default: - ipam: - config: - - subnet: 172.20.0.0/16 diff --git a/ESLINT9_UPGRADE.md b/docs/TODO/ESLINT9_UPGRADE.md similarity index 100% rename from ESLINT9_UPGRADE.md rename to docs/TODO/ESLINT9_UPGRADE.md diff --git a/HEROICONS_V2_MIGRATION.md b/docs/TODO/HEROICONS_V2_MIGRATION.md similarity index 100% rename from HEROICONS_V2_MIGRATION.md rename to docs/TODO/HEROICONS_V2_MIGRATION.md diff --git a/LARAVEL11_UPGRADE.md b/docs/TODO/LARAVEL11_UPGRADE.md similarity index 100% rename from LARAVEL11_UPGRADE.md rename to docs/TODO/LARAVEL11_UPGRADE.md diff --git a/PACKAGE_AUDIT.md b/docs/TODO/PACKAGE_AUDIT.md similarity index 100% rename from PACKAGE_AUDIT.md rename to docs/TODO/PACKAGE_AUDIT.md diff --git a/TYPESCRIPT5_UPGRADE.md b/docs/TODO/TYPESCRIPT5_UPGRADE.md similarity index 100% rename from TYPESCRIPT5_UPGRADE.md rename to docs/TODO/TYPESCRIPT5_UPGRADE.md diff --git a/VITE5_UPGRADE.md b/docs/TODO/VITE5_UPGRADE.md similarity index 100% rename from VITE5_UPGRADE.md rename to docs/TODO/VITE5_UPGRADE.md diff --git a/docs/wings-rs-integration.md b/docs/wings-rs-integration.md new file mode 100644 index 0000000000..2c30599a8e --- /dev/null +++ b/docs/wings-rs-integration.md @@ -0,0 +1,132 @@ +# Wings-RS Integration + +This document describes the Wings-RS (Supercharged) integration for the Jexactyl panel. + +## Overview + +Wings-RS is a Rust-based alternative daemon that provides enhanced features compared to the standard Pterodactyl Wings daemon. When a node is running Wings-RS, the panel automatically detects it and unlocks supercharged features. + +## Architecture + +### Detection Flow + +1. The panel calls `GET /api/system` on the node +2. If the response contains `"supercharged": true` or a Wings-RS version string, the node is detected as supercharged +3. Node record is updated with `wings_type = 'wings-rs'`, version, and detection timestamp +4. All Wings-RS exclusive features become available in the admin and client UIs + +### Backend Components + +| File | Purpose | +|------|---------| +| `app/Models/Node.php` | Updated with `WINGS_TYPE_RS`, `WINGS_TYPE_DEFAULT` constants, `isSupercharged()` method | +| `app/Services/Nodes/WingsDetectionService.php` | Detects Wings-RS nodes and fetches system overview | +| `app/Repositories/Wings/DaemonWingsRsRepository.php` | Repository for all Wings-RS exclusive API endpoints | +| `app/Http/Controllers/Api/Application/Nodes/NodeWingsRsController.php` | Admin API for node management | +| `app/Http/Controllers/Api/Client/Servers/WingsRsController.php` | Client API for server-level features | +| `database/migrations/2026_02_28_000001_add_wings_rs_columns_to_nodes.php` | Database migration | + +### Frontend Components + +| File | Purpose | +|------|---------| +| `resources/scripts/api/routes/admin/nodes/wingsRs.ts` | Admin API functions | +| `resources/scripts/api/routes/server/wingsRs.ts` | Client API functions | +| `resources/scripts/components/admin/management/nodes/NodeWingsRsContainer.tsx` | Admin Wings-RS tab page | +| `resources/scripts/components/admin/management/nodes/NodeStatsContainer.tsx` | Real-time system stats | +| `resources/scripts/components/admin/management/nodes/NodeLogsContainer.tsx` | System log viewer | +| `resources/scripts/components/server/wingsrs/WingsRsContainer.tsx` | Server-level Wings-RS features | +| `resources/scripts/components/server/files/CompressFormatDialog.tsx` | Advanced compression with format selection | +| `resources/scripts/components/server/files/FileSearchDialog.tsx` | Advanced file search (glob/regex) | +| `resources/scripts/components/server/files/FileFingerprintDialog.tsx` | File checksum generation | +| `resources/scripts/components/server/files/SshInfoPanel.tsx` | SSH access guidance | + +## API Endpoints + +### Application API (Admin) + +All endpoints under `/api/application/nodes/{node}/wings-rs/`: + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/detect` | Detect if node is running Wings-RS | +| GET | `/overview` | Get Wings-RS system overview (version, features, uptime) | +| GET | `/stats` | Get real-time system stats (CPU, memory, disk, network) | +| GET | `/logs` | List available log files | +| GET | `/logs/{file}` | Get contents of a specific log file | +| POST | `/upgrade` | Trigger Wings-RS self-upgrade | + +### Client API (Server) + +All endpoints under `/api/client/servers/{server}/wings-rs/`: + +| Method | Path | Description | Permission | +|--------|------|-------------|-----------| +| GET | `/status` | Get supercharged status and features | - | +| POST | `/fingerprints` | Compute file checksums | `file.read` | +| POST | `/search` | Advanced file search (glob/regex) | `file.read` | +| POST | `/compress` | Compress with format selection | `file.archive` | +| DELETE | `/operations/{operation}` | Cancel an ongoing operation | `file.update` | +| POST | `/script` | Execute a shell script | `startup.update` | +| POST | `/abort-install` | Abort ongoing installation | `settings.reinstall` | +| GET | `/install-logs` | View installation logs | `control.console` | +| GET | `/ssh` | Get SSH connection details | `file.sftp` | + +## Supported Archive Formats + +Wings-RS supports these archive formats for compression: + +- `.tar` — Uncompressed tar +- `.tar.gz` — Gzip compressed tar (default) +- `.tar.xz` — XZ compressed tar +- `.tar.bz2` — Bzip2 compressed tar +- `.tar.lz4` — LZ4 compressed tar (fastest) +- `.tar.zst` — Zstandard compressed tar +- `.zip` — ZIP archive +- `.7z` — 7-Zip archive + +## Fingerprint Algorithms + +Supported hash algorithms for file checksums: + +- SHA-256 (default) +- SHA-1 +- MD5 +- BLAKE3 + +## Graceful Fallback + +All Wings-RS features are conditionally enabled: + +- **Backend**: Every Wings-RS controller method validates `$server->node->isSupercharged()` and returns HTTP 400 if the node is not supercharged +- **Admin UI**: The Wings-RS tab appears for all nodes but shows a detection button for non-RS nodes +- **Client UI**: The Wings-RS sidebar tab only appears when `isNodeSupercharged` is true on the server +- **File Manager**: Advanced compress, search, and checksum features only appear for supercharged nodes + +Standard Wings nodes continue to work exactly as before with zero impact. + +## Database Changes + +The migration adds three columns to the `nodes` table: + +| Column | Type | Default | Description | +|--------|------|---------|-------------| +| `wings_type` | string | `'default'` | Either `'default'` or `'wings-rs'` | +| `wings_version` | string (nullable) | null | The Wings-RS version string | +| `wings_detected_at` | timestamp (nullable) | null | When Wings-RS was last detected | + +## Running Tests + +```bash +php artisan test --filter=WingsDetection +php artisan test --filter=WingsRsController +``` + +## Security Considerations + +- All admin endpoints require application API key authentication +- All client endpoints require user authentication and appropriate permissions +- The `assertSupercharged()` method in `DaemonWingsRsRepository` prevents calls to Wings-RS endpoints on standard nodes +- Script execution requires `startup.update` permission +- Install abort requires `settings.reinstall` permission +- File operations respect existing permission scopes (file.read, file.archive, etc.) diff --git a/openapi.txt b/openapi.txt new file mode 100644 index 0000000000..2c347bb2d6 --- /dev/null +++ b/openapi.txt @@ -0,0 +1 @@ +{"openapi":"3.1.0","info":{"title":"Pterodactyl Wings API","version":"1.0.0-pre.2"},"paths":{"/api/backups/{backup}":{"delete":{"operationId":"delete_api_backups_backup","parameters":[{"name":"backup","in":"path","description":"The backup uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["adapter"],"properties":{"adapter":{"$ref":"#/components/schemas/BackupAdapter"}}}}},"required":true},"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/deauthorize-user":{"post":{"operationId":"post_api_deauthorize-user","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["servers","user"],"properties":{"servers":{"type":"array","items":{"type":"string","format":"uuid"},"uniqueItems":true},"user":{"type":"string","format":"uuid"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"409":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"string"}}}}}}}}},"/api/servers":{"get":{"operationId":"get_api_servers","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Server"}}}}}}},"post":{"operationId":"post_api_servers","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["uuid"],"properties":{"uuid":{"type":"string","format":"uuid"},"start_on_completion":{"type":"boolean"},"skip_scripts":{"type":"boolean"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/power":{"post":{"operationId":"post_api_servers_power","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["servers","action"],"properties":{"servers":{"type":"array","items":{"type":"string","format":"uuid"},"uniqueItems":true},"action":{"$ref":"#/components/schemas/ServerPowerAction"},"wait_seconds":{"type":["integer","null"],"format":"int64","minimum":0}}}}},"required":true},"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["affected"],"properties":{"affected":{"type":"integer","minimum":0}}}}}}}}},"/api/servers/{server}":{"get":{"operationId":"get_api_servers_server","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Server"}}}}}},"delete":{"operationId":"delete_api_servers_server","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/api/servers/{server}/backup":{"post":{"operationId":"post_api_servers_server_backup","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["adapter","uuid","ignore"],"properties":{"adapter":{"$ref":"#/components/schemas/BackupAdapter"},"uuid":{"type":"string","format":"uuid"},"ignore":{"$ref":"#/components/schemas/CompactString"}}}}},"required":true},"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/backup/{backup}":{"delete":{"operationId":"delete_api_servers_server_backup_backup","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"backup","in":"path","description":"The backup uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/backup/{backup}/restore":{"post":{"operationId":"post_api_servers_server_backup_backup_restore","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"backup","in":"path","description":"The backup uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["adapter","truncate_directory"],"properties":{"adapter":{"$ref":"#/components/schemas/BackupAdapter"},"truncate_directory":{"type":"boolean"},"download_url":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/CompactString"}]}}}}},"required":true},"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/commands":{"post":{"operationId":"post_api_servers_server_commands","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["commands"],"properties":{"commands":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/chmod":{"post":{"operationId":"post_api_servers_server_files_chmod","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["files"],"properties":{"root":{"$ref":"#/components/schemas/CompactString"},"files":{"type":"array","items":{"type":"object","required":["file","mode"],"properties":{"file":{"$ref":"#/components/schemas/CompactString"},"mode":{"$ref":"#/components/schemas/CompactString"}}}}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["updated"],"properties":{"updated":{"type":"integer","minimum":0}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/compress":{"post":{"operationId":"post_api_servers_server_files_compress","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["files"],"properties":{"format":{"$ref":"#/components/schemas/ArchiveFormat"},"name":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/CompactString"}]},"root":{"$ref":"#/components/schemas/CompactString"},"files":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}},"foreground":{"type":"boolean"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DirectoryEntry"}}}},"202":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["identifier"],"properties":{"identifier":{"type":"string","format":"uuid"}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/contents":{"get":{"operationId":"get_api_servers_server_files_contents","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"file","in":"query","description":"The file to view contents of","required":true,"schema":{"type":"string"}},{"name":"download","in":"query","description":"Whether to add 'download headers' to the file","required":true,"schema":{"type":"boolean"}},{"name":"max_size","in":"query","description":"The maximum size of the file to return. If the file is larger than this, an error will be returned.","required":false,"schema":{"type":"integer","format":"int64","minimum":0}}],"responses":{"200":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"413":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/copy":{"post":{"operationId":"post_api_servers_server_files_copy","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["path"],"properties":{"path":{"$ref":"#/components/schemas/CompactString"},"name":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/CompactString"}]},"foreground":{"type":"boolean"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DirectoryEntry"}}}},"202":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["identifier"],"properties":{"identifier":{"type":"string","format":"uuid"}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/copy-many":{"post":{"operationId":"post_api_servers_server_files_copy-many","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["files"],"properties":{"root":{"$ref":"#/components/schemas/CompactString"},"files":{"type":"array","items":{"type":"object","required":["from","to"],"properties":{"from":{"$ref":"#/components/schemas/CompactString"},"to":{"$ref":"#/components/schemas/CompactString"}}}},"foreground":{"type":"boolean"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["copied"],"properties":{"copied":{"type":"integer","minimum":0}}}}}},"202":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["identifier"],"properties":{"identifier":{"type":"string","format":"uuid"}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/copy-remote":{"post":{"operationId":"post_api_servers_server_files_copy-remote","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["url","token","files","destination_server","destination_path"],"properties":{"url":{"type":"string"},"token":{"type":"string"},"archive_format":{"$ref":"#/components/schemas/TransferArchiveFormat"},"compression_level":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/CompressionLevel"}]},"root":{"$ref":"#/components/schemas/CompactString"},"files":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}},"destination_server":{"type":"string","format":"uuid"},"destination_path":{"$ref":"#/components/schemas/CompactString"},"foreground":{"type":"boolean"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"202":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["identifier"],"properties":{"identifier":{"type":"string","format":"uuid"}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/create-directory":{"post":{"operationId":"post_api_servers_server_files_create-directory","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["root","name"],"properties":{"root":{"$ref":"#/components/schemas/CompactString"},"name":{"$ref":"#/components/schemas/CompactString"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/decompress":{"post":{"operationId":"post_api_servers_server_files_decompress","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["file"],"properties":{"root":{"$ref":"#/components/schemas/CompactString"},"file":{"$ref":"#/components/schemas/CompactString"},"foreground":{"type":"boolean"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"202":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["identifier"],"properties":{"identifier":{"type":"string","format":"uuid"}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/delete":{"post":{"operationId":"post_api_servers_server_files_delete","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["files"],"properties":{"root":{"$ref":"#/components/schemas/CompactString"},"files":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["deleted"],"properties":{"deleted":{"type":"integer","minimum":0}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/fingerprints":{"get":{"operationId":"get_api_servers_server_files_fingerprints","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"algorithm","in":"query","description":"The algorithm to use for the fingerprint","required":true,"schema":{"$ref":"#/components/schemas/Algorithm"}},{"name":"files","in":"query","description":"The list of files to fingerprint","required":true,"schema":{"type":"array","items":{"type":"string"}}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["fingerprints"],"properties":{"fingerprints":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/CompactString"},"propertyNames":{"type":"string"}}}}}}}}}},"/api/servers/{server}/files/list":{"get":{"operationId":"get_api_servers_server_files_list","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"directory","in":"query","description":"The directory to list files from","required":true,"schema":{"type":"string"}},{"name":"ignored","in":"query","description":"Additional ignored files","required":true,"schema":{"type":"array","items":{"type":"string"}}},{"name":"per_page","in":"query","description":"The number of entries to return per page","required":true,"schema":{"type":"integer","minimum":0}},{"name":"page","in":"query","description":"The page number to return","required":true,"schema":{"type":"integer","minimum":0}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["total","filesystem_writable","filesystem_fast","entries"],"properties":{"total":{"type":"integer","minimum":0},"filesystem_writable":{"type":"boolean"},"filesystem_fast":{"type":"boolean"},"entries":{"type":"array","items":{"$ref":"#/components/schemas/DirectoryEntry"}}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/list-directory":{"get":{"operationId":"get_api_servers_server_files_list-directory","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"directory","in":"query","description":"The directory to list files from","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/DirectoryEntry"}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}},"deprecated":true}},"/api/servers/{server}/files/operations/{operation}":{"delete":{"operationId":"delete_api_servers_server_files_operations_operation","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"operation","in":"path","description":"The operation uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/pull":{"get":{"operationId":"get_api_servers_server_files_pull","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["downloads"],"properties":{"downloads":{"type":"array","items":{"$ref":"#/components/schemas/Download"}}}}}}}},"deprecated":true},"post":{"operationId":"post_api_servers_server_files_pull","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"root":{"$ref":"#/components/schemas/CompactString"},"url":{"$ref":"#/components/schemas/CompactString"},"file_name":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/CompactString"}]},"use_header":{"type":"boolean"},"foreground":{"type":"boolean"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"202":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["identifier"],"properties":{"identifier":{"type":"string","format":"uuid"}}}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/pull/query":{"post":{"operationId":"post_api_servers_server_files_pull_query","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"$ref":"#/components/schemas/CompactString"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["final_url","headers"],"properties":{"file_name":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/CompactString"}]},"file_size":{"type":["integer","null"],"format":"int64","minimum":0},"final_url":{"$ref":"#/components/schemas/CompactString"},"headers":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/CompactString"},"propertyNames":{"type":"string"}}}}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/pull/{pull}":{"delete":{"operationId":"delete_api_servers_server_files_pull_pull","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"pull","in":"path","description":"The pull uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}},"deprecated":true}},"/api/servers/{server}/files/rename":{"put":{"operationId":"put_api_servers_server_files_rename","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["files"],"properties":{"root":{"$ref":"#/components/schemas/CompactString"},"files":{"type":"array","items":{"type":"object","required":["from","to"],"properties":{"from":{"$ref":"#/components/schemas/CompactString"},"to":{"$ref":"#/components/schemas/CompactString"}}}}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["renamed"],"properties":{"renamed":{"type":"integer","minimum":0}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/search":{"post":{"operationId":"post_api_servers_server_files_search","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["per_page"],"properties":{"root":{"$ref":"#/components/schemas/CompactString"},"path_filter":{"oneOf":[{"type":"null"},{"type":"object","required":["include"],"properties":{"include":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}},"exclude":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}},"case_insensitive":{"type":"boolean"}}}]},"size_filter":{"oneOf":[{"type":"null"},{"type":"object","required":["max"],"properties":{"min":{"type":"integer","format":"int64","minimum":0},"max":{"type":"integer","format":"int64","minimum":0}}}]},"content_filter":{"oneOf":[{"type":"null"},{"type":"object","required":["query","max_search_size"],"properties":{"query":{"$ref":"#/components/schemas/CompactString"},"max_search_size":{"type":"integer","format":"int64","minimum":0},"include_unmatched":{"type":"boolean"},"case_insensitive":{"type":"boolean"}}}]},"per_page":{"type":"integer","minimum":0}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["results"],"properties":{"results":{"type":"array","items":{"$ref":"#/components/schemas/DirectoryEntry"}}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/files/write":{"post":{"operationId":"post_api_servers_server_files_write","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"file","in":"query","description":"The file to view contents of","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"text/plain":{"schema":{"type":"string"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/install/abort":{"post":{"operationId":"post_api_servers_server_install_abort","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/logs":{"get":{"operationId":"get_api_servers_server_logs","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"lines","in":"query","description":"The number of lines to tail from the log","required":false,"schema":{"type":"integer","minimum":0},"example":"100"}],"responses":{"200":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/api/servers/{server}/logs/install":{"get":{"operationId":"get_api_servers_server_logs_install","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"lines","in":"query","description":"The number of lines to tail from the log","required":false,"schema":{"type":"integer","minimum":0},"example":"100"}],"responses":{"200":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/power":{"post":{"operationId":"post_api_servers_server_power","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["action"],"properties":{"action":{"$ref":"#/components/schemas/ServerPowerAction"},"wait_seconds":{"type":["integer","null"],"format":"int64","minimum":0}}}}},"required":true},"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/api/servers/{server}/reinstall":{"post":{"operationId":"post_api_servers_server_reinstall","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"truncate_directory":{"type":"boolean"},"installation_script":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/InstallationScript"}]}}}}},"required":true},"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/schedules/{schedule}":{"get":{"operationId":"get_api_servers_server_schedules_schedule","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"schedule","in":"path","description":"The schedule uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["status"],"properties":{"status":{"$ref":"#/components/schemas/ScheduleStatus"}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/schedules/{schedule}/abort":{"post":{"operationId":"post_api_servers_server_schedules_schedule_abort","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"schedule","in":"path","description":"The schedule uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/schedules/{schedule}/trigger":{"post":{"operationId":"post_api_servers_server_schedules_schedule_trigger","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"schedule","in":"path","description":"The schedule uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"skip_condition":{"type":"boolean"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/script":{"post":{"operationId":"post_api_servers_server_script","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallationScript"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["stdout","stderr"],"properties":{"stdout":{"type":"string"},"stderr":{"type":"string"}}}}}}}}},"/api/servers/{server}/sync":{"post":{"operationId":"post_api_servers_server_sync","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["server"],"properties":{"server":{}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/api/servers/{server}/transfer":{"post":{"operationId":"post_api_servers_server_transfer","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["url","token"],"properties":{"url":{"type":"string"},"token":{"type":"string"},"archive_format":{"$ref":"#/components/schemas/TransferArchiveFormat"},"compression_level":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/CompressionLevel"}]},"backups":{"type":"array","items":{"type":"string","format":"uuid"}},"delete_backups":{"type":"boolean"},"multiplex_streams":{"type":"integer","minimum":0}}}}},"required":true},"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"delete":{"operationId":"delete_api_servers_server_transfer","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/version":{"get":{"operationId":"get_api_servers_server_version","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"},{"name":"game","in":"query","description":"The game logic to use for the sha256 hash","required":true,"schema":{"$ref":"#/components/schemas/Game"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["hash"],"properties":{"hash":{"$ref":"#/components/schemas/CompactString"}}}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/servers/{server}/ws/broadcast":{"post":{"operationId":"post_api_servers_server_ws_broadcast","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["users","permissions","message"],"properties":{"users":{"type":"array","items":{"type":"string","format":"uuid"},"uniqueItems":true},"permissions":{"type":"array","items":{"type":"string"}},"message":{"$ref":"#/components/schemas/WebsocketMessage"}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/api/servers/{server}/ws/deny":{"post":{"operationId":"post_api_servers_server_ws_deny","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["jtis"],"properties":{"jtis":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/api/servers/{server}/ws/permissions":{"post":{"operationId":"post_api_servers_server_ws_permissions","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["user_permissions"],"properties":{"user_permissions":{"type":"array","items":{"type":"object","required":["user","permissions"],"properties":{"user":{"type":"string","format":"uuid"},"permissions":{"type":"array","items":{"type":"string"}},"ignored_files":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}}}}}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}}}}},"/api/system":{"get":{"operationId":"get_api_system","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["architecture","cpu_count","kernel_version","os","version"],"properties":{"architecture":{"type":"string"},"cpu_count":{"type":"integer","minimum":0},"kernel_version":{"type":"string"},"os":{"type":"string"},"version":{"type":"string"}}}}}}}}},"/api/system/config":{"get":{"operationId":"get_api_system_config","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["remote"],"properties":{"debug":{"type":"boolean"},"app_name":{"type":"string"},"uuid":{"type":"string","format":"uuid"},"token_id":{"type":"string"},"token":{"type":"string"},"api":{"type":"object","properties":{"host":{"type":"string","default":"0.0.0.0"},"port":{"type":"integer","format":"int32","default":8080,"minimum":0},"ssl":{"oneOf":[{"type":"object","properties":{"enabled":{"type":"boolean","default":false},"cert":{"type":"string","default":""},"key":{"type":"string","default":""}}}],"default":{"enabled":false,"cert":"","key":""}},"redirects":{"type":"object","default":{},"additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"disable_openapi_docs":{"type":"boolean","default":false},"disable_remote_download":{"type":"boolean","default":false},"server_remote_download_limit":{"type":"integer","default":3,"minimum":0},"remote_download_blocked_cidrs":{"type":"array","items":{"type":"string"},"default":["127.0.0.0/8","10.0.0.0/8","172.16.0.0/12","192.168.0.0/16","169.254.0.0/16","::1","fe80::/10","fc00::/7"]},"disable_directory_size":{"type":"boolean","default":false},"directory_entry_limit":{"type":"integer","default":10000,"minimum":0},"send_offline_server_logs":{"type":"boolean","default":false},"file_search_threads":{"type":"integer","default":4,"minimum":0},"file_copy_threads":{"type":"integer","default":4,"minimum":0},"file_decompression_threads":{"type":"integer","default":2,"minimum":0},"file_compression_threads":{"type":"integer","default":2,"minimum":0},"upload_limit":{"oneOf":[{"$ref":"#/components/schemas/MiB"}],"default":100},"max_jwt_uses":{"type":"integer","default":5,"minimum":0},"trusted_proxies":{"type":"array","items":{"type":"string"},"default":[]}}},"system":{"type":"object","properties":{"root_directory":{"type":"string","default":"/var/lib/pterodactyl"},"log_directory":{"type":"string","default":"/var/log/pterodactyl"},"vmount_directory":{"type":"string","default":"/var/lib/pterodactyl/vmounts"},"data":{"type":"string","default":"/var/lib/pterodactyl/volumes"},"archive_directory":{"type":"string","default":"/var/lib/pterodactyl/archives"},"backup_directory":{"type":"string","default":"/var/lib/pterodactyl/backups"},"tmp_directory":{"type":"string","default":"/tmp/pterodactyl"},"username":{"oneOf":[{"$ref":"#/components/schemas/CompactString"}],"default":"pterodactyl"},"timezone":{"oneOf":[{"$ref":"#/components/schemas/CompactString"}],"default":"Europe/Vilnius"},"user":{"oneOf":[{"type":"object","properties":{"rootless":{"oneOf":[{"type":"object","properties":{"enabled":{"type":"boolean","default":false},"container_uid":{"type":"integer","format":"int32","default":0,"minimum":0},"container_gid":{"type":"integer","format":"int32","default":0,"minimum":0}}}],"default":{"enabled":false,"container_uid":0,"container_gid":0}},"uid":{"type":"integer","format":"int32","default":0,"minimum":0},"gid":{"type":"integer","format":"int32","default":0,"minimum":0}}}],"default":{"rootless":{"enabled":false,"container_uid":0,"container_gid":0},"uid":0,"gid":0}},"passwd":{"oneOf":[{"type":"object","properties":{"enabled":{"type":"boolean","default":false},"directory":{"type":"string","default":"/run/wings/etc"}}}],"default":{"enabled":false,"directory":"/run/wings/etc"}},"disk_check_interval":{"type":"integer","format":"int64","default":150,"minimum":0},"disk_check_threads":{"type":"integer","default":2,"minimum":0},"disk_limiter_mode":{"oneOf":[{"$ref":"#/components/schemas/DiskLimiterMode"}],"default":"none"},"activity_send_interval":{"type":"integer","format":"int64","default":60,"minimum":0},"activity_send_count":{"type":"integer","default":100,"minimum":0},"check_permissions_on_boot":{"type":"boolean","default":true},"check_permissions_on_boot_threads":{"type":"integer","default":4,"minimum":0},"websocket_log_count":{"type":"integer","default":150,"minimum":0},"sftp":{"oneOf":[{"type":"object","properties":{"enabled":{"type":"boolean","default":true},"bind_address":{"type":"string","default":"0.0.0.0"},"bind_port":{"type":"integer","format":"int32","default":2022,"minimum":0},"read_only":{"type":"boolean","default":false},"key_algorithm":{"type":"string","default":"ssh-ed25519"},"disable_password_auth":{"type":"boolean","default":false},"directory_entry_limit":{"type":"integer","format":"int64","default":20000,"minimum":0},"directory_entry_send_amount":{"type":"integer","default":500,"minimum":0},"limits":{"oneOf":[{"type":"object","properties":{"authentication_password_attempts":{"type":"integer","default":3,"minimum":0},"authentication_pubkey_attempts":{"type":"integer","default":20,"minimum":0},"authentication_cooldown":{"type":"integer","format":"int64","default":60,"minimum":0}}}],"default":{"authentication_password_attempts":3,"authentication_pubkey_attempts":20,"authentication_cooldown":60}},"shell":{"oneOf":[{"type":"object","properties":{"enabled":{"type":"boolean","default":true},"cli":{"oneOf":[{"type":"object","properties":{"name":{"type":"string","default":".wings"}}}],"default":{"name":".wings"}}}}],"default":{"enabled":true,"cli":{"name":".wings"}}},"activity":{"oneOf":[{"type":"object","properties":{"log_logins":{"type":"boolean","default":false},"log_file_reads":{"type":"boolean","default":false}}}],"default":{"log_logins":false,"log_file_reads":false}}}}],"default":{"enabled":true,"bind_address":"0.0.0.0","bind_port":2022,"read_only":false,"key_algorithm":"ssh-ed25519","disable_password_auth":false,"directory_entry_limit":20000,"directory_entry_send_amount":500,"limits":{"authentication_password_attempts":3,"authentication_pubkey_attempts":20,"authentication_cooldown":60},"shell":{"enabled":true,"cli":{"name":".wings"}},"activity":{"log_logins":false,"log_file_reads":false}}},"crash_detection":{"oneOf":[{"type":"object","properties":{"enabled":{"type":"boolean","default":true},"detect_clean_exit_as_crash":{"type":"boolean","default":true},"timeout":{"type":"integer","format":"int64","default":60,"minimum":0}}}],"default":{"enabled":true,"detect_clean_exit_as_crash":true,"timeout":60}},"backups":{"oneOf":[{"type":"object","properties":{"write_limit":{"oneOf":[{"$ref":"#/components/schemas/MiB"}],"default":0},"read_limit":{"oneOf":[{"$ref":"#/components/schemas/MiB"}],"default":0},"compression_level":{"oneOf":[{"$ref":"#/components/schemas/CompressionLevel"}],"default":"best_speed"},"mounting":{"oneOf":[{"type":"object","properties":{"enabled":{"type":"boolean","default":true},"path":{"type":"string","default":".backups"}}}],"default":{"enabled":true,"path":".backups"}},"wings":{"oneOf":[{"type":"object","properties":{"create_threads":{"type":"integer","default":4,"minimum":0},"restore_threads":{"type":"integer","default":4,"minimum":0},"archive_format":{"oneOf":[{"$ref":"#/components/schemas/ArchiveFormat"}],"default":"tar_gz"}}}],"default":{"create_threads":4,"restore_threads":4,"archive_format":"tar_gz"}},"s3":{"oneOf":[{"type":"object","properties":{"create_threads":{"type":"integer","default":4,"minimum":0},"part_upload_timeout":{"type":"integer","format":"int64","default":7200,"minimum":0},"retry_limit":{"type":"integer","format":"int64","default":10,"minimum":0}}}],"default":{"create_threads":4,"part_upload_timeout":7200,"retry_limit":10}},"ddup_bak":{"oneOf":[{"type":"object","properties":{"create_threads":{"type":"integer","default":4,"minimum":0},"compression_format":{"oneOf":[{"$ref":"#/components/schemas/SystemBackupsDdupBakCompressionFormat"}],"default":"deflate"}}}],"default":{"create_threads":4,"compression_format":"deflate"}},"restic":{"oneOf":[{"type":"object","properties":{"repository":{"type":"string","default":"/var/lib/pterodactyl/backups/restic"},"password_file":{"type":"string","default":"/var/lib/pterodactyl/backups/restic_password"},"retry_lock_seconds":{"type":"integer","format":"int64","default":60,"minimum":0},"environment":{"type":"object","default":{},"additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}}}}],"default":{"repository":"/var/lib/pterodactyl/backups/restic","password_file":"/var/lib/pterodactyl/backups/restic_password","retry_lock_seconds":60,"environment":{}}},"btrfs":{"oneOf":[{"type":"object","properties":{"restore_threads":{"type":"integer","default":4,"minimum":0},"create_read_only":{"type":"boolean","default":true}}}],"default":{"restore_threads":4,"create_read_only":true}},"zfs":{"oneOf":[{"type":"object","properties":{"restore_threads":{"type":"integer","default":4,"minimum":0}}}],"default":{"restore_threads":4}}}}],"default":{"write_limit":0,"read_limit":0,"compression_level":"best_speed","mounting":{"enabled":true,"path":".backups"},"wings":{"create_threads":4,"restore_threads":4,"archive_format":"tar_gz"},"s3":{"create_threads":4,"part_upload_timeout":7200,"retry_limit":10},"ddup_bak":{"create_threads":4,"compression_format":"deflate"},"restic":{"repository":"/var/lib/pterodactyl/backups/restic","password_file":"/var/lib/pterodactyl/backups/restic_password","retry_lock_seconds":60,"environment":{}},"btrfs":{"restore_threads":4,"create_read_only":true},"zfs":{"restore_threads":4}}},"transfers":{"oneOf":[{"type":"object","properties":{"download_limit":{"oneOf":[{"$ref":"#/components/schemas/MiB"}],"default":0}}}],"default":{"download_limit":0}}}},"docker":{"type":"object","properties":{"socket":{"type":"string","default":"/var/run/docker.sock"},"server_name_in_container_name":{"type":"boolean","default":false},"delete_container_on_stop":{"type":"boolean","default":true},"network":{"oneOf":[{"type":"object","properties":{"interface":{"type":"string","default":"172.18.0.1"},"disable_interface_binding":{"type":"boolean","default":false},"dns":{"type":"array","items":{"type":"string"},"default":["1.1.1.1","1.0.0.1"]},"name":{"type":"string","default":"pterodactyl_nw"},"ispn":{"type":"boolean","default":false},"driver":{"type":"string","default":"bridge"},"mode":{"type":"string","default":"pterodactyl_nw"},"is_internal":{"type":"boolean","default":false},"enable_icc":{"type":"boolean","default":true},"network_mtu":{"type":"integer","format":"int64","default":1500,"minimum":0},"interfaces":{"oneOf":[{"type":"object","properties":{"v4":{"oneOf":[{"type":"object","properties":{"subnet":{"type":"string","default":"172.18.0.0/16"},"gateway":{"type":"string","default":"172.18.0.1"}}}],"default":{"subnet":"172.18.0.0/16","gateway":"172.18.0.1"}},"v6":{"oneOf":[{"type":"object","properties":{"subnet":{"type":"string","default":"fdba:17c8:6c94::/64"},"gateway":{"type":"string","default":"fdba:17c8:6c94::1011"}}}],"default":{"subnet":"fdba:17c8:6c94::/64","gateway":"fdba:17c8:6c94::1011"}}}}],"default":{"v4":{"subnet":"172.18.0.0/16","gateway":"172.18.0.1"},"v6":{"subnet":"fdba:17c8:6c94::/64","gateway":"fdba:17c8:6c94::1011"}}}}}],"default":{"interface":"172.18.0.1","disable_interface_binding":false,"dns":["1.1.1.1","1.0.0.1"],"name":"pterodactyl_nw","ispn":false,"driver":"bridge","mode":"pterodactyl_nw","is_internal":false,"enable_icc":true,"network_mtu":1500,"interfaces":{"v4":{"subnet":"172.18.0.0/16","gateway":"172.18.0.1"},"v6":{"subnet":"fdba:17c8:6c94::/64","gateway":"fdba:17c8:6c94::1011"}}}},"domainname":{"type":"string","default":""},"registries":{"type":"object","default":{},"additionalProperties":{"type":"object","required":["username","password"],"properties":{"username":{"type":"string"},"password":{"type":"string"}}},"propertyNames":{"type":"string"}},"tmpfs_size":{"type":"integer","format":"int64","default":100,"minimum":0},"container_pid_limit":{"type":"integer","format":"int64","default":5120,"minimum":0},"installer_limits":{"oneOf":[{"type":"object","properties":{"timeout":{"type":"integer","format":"int64","default":1800,"minimum":0},"memory":{"oneOf":[{"$ref":"#/components/schemas/MiB"}],"default":1024},"cpu":{"type":"integer","format":"int64","description":"%","default":100,"minimum":0}}}],"default":{"timeout":1800,"memory":1024,"cpu":100}},"overhead":{"oneOf":[{"type":"object","properties":{"override":{"type":"boolean","default":false},"default_multiplier":{"type":"number","format":"double","default":1.05},"multipliers":{"type":"object","description":"Memory Limit MiB -> Multiplier","default":{},"additionalProperties":{"type":"number","format":"double"},"propertyNames":{"type":"integer","format":"int64","description":"Represents a size in Mebibytes (MiB). The inner value is the number of MiB (not bytes!!).","minimum":0}}}}],"default":{"override":false,"default_multiplier":1.05,"multipliers":{}}},"userns_mode":{"type":"string","default":""},"log_config":{"oneOf":[{"type":"object","properties":{"type":{"type":"string","default":"local"},"config":{"type":"object","default":{"compress":"false","max-file":"1","max-size":"5m","mode":"non-blocking"},"additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}}}}],"default":{"type":"local","config":{"compress":"false","max-file":"1","max-size":"5m","mode":"non-blocking"}}}}},"throttles":{"type":"object","properties":{"enabled":{"type":"boolean","default":true},"lines":{"type":"integer","format":"int64","default":2000,"minimum":0},"line_reset_interval":{"type":"integer","format":"int64","description":"ms","default":100,"minimum":0}}},"remote":{"type":"string"},"remote_headers":{"type":"object","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"remote_query":{"type":"object","properties":{"timeout":{"type":"integer","format":"int64","default":30,"minimum":0},"boot_servers_per_page":{"type":"integer","format":"int64","default":50,"minimum":0},"retry_limit":{"type":"integer","format":"int64","default":10,"minimum":0}}},"allowed_mounts":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}},"allowed_origins":{"type":"array","items":{"type":"string"}},"allow_cors_private_network":{"type":"boolean"},"ignore_panel_config_updates":{"type":"boolean"}}}}}}}}},"/api/system/logs":{"get":{"operationId":"get_api_system_logs","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["log_files"],"properties":{"log_files":{"type":"array","items":{"type":"object","required":["name","size","last_modified"],"properties":{"name":{"$ref":"#/components/schemas/CompactString"},"size":{"type":"integer","format":"int64","minimum":0},"last_modified":{"type":"string","format":"date-time"}}}}}}}}}}}},"/api/system/logs/{file}":{"get":{"operationId":"get_api_system_logs_file","parameters":[{"name":"file","in":"path","description":"The log file name","required":true,"schema":{"type":"string"},"example":"wings.log"},{"name":"lines","in":"query","description":"The number of lines to tail from the log file","required":false,"schema":{"type":"integer","minimum":0},"example":"100"}],"responses":{"200":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/system/overview":{"get":{"operationId":"get_api_system_overview","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["version","container_type","cpu","memory","servers","architecture","kernel_version"],"properties":{"version":{"type":"string"},"container_type":{"$ref":"#/components/schemas/AppContainerType"},"cpu":{"type":"object","required":["name","brand","vendor_id","frequency_mhz","cpu_count"],"properties":{"name":{"type":"string"},"brand":{"type":"string"},"vendor_id":{"type":"string"},"frequency_mhz":{"type":"integer","format":"int64","minimum":0},"cpu_count":{"type":"integer","minimum":0}}},"memory":{"type":"object","required":["total_bytes","free_bytes","used_bytes","used_bytes_process"],"properties":{"total_bytes":{"type":"integer","format":"int64","minimum":0},"free_bytes":{"type":"integer","format":"int64","minimum":0},"used_bytes":{"type":"integer","format":"int64","minimum":0},"used_bytes_process":{"type":"integer","format":"int64","minimum":0}}},"servers":{"type":"object","required":["total","online","offline"],"properties":{"total":{"type":"integer","minimum":0},"online":{"type":"integer","minimum":0},"offline":{"type":"integer","minimum":0}}},"architecture":{"type":"string"},"kernel_version":{"type":"string"}}}}}}}}},"/api/system/stats":{"get":{"operationId":"get_api_system_stats","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["stats"],"properties":{"stats":{"$ref":"#/components/schemas/SystemStats"}}}}}}}}},"/api/system/upgrade":{"post":{"operationId":"post_api_system_upgrade","requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["url","headers","sha256","restart_command","restart_command_args"],"properties":{"url":{"$ref":"#/components/schemas/CompactString"},"headers":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/CompactString"},"propertyNames":{"type":"string"}},"sha256":{"$ref":"#/components/schemas/CompactString"},"restart_command":{"$ref":"#/components/schemas/CompactString"},"restart_command_args":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}}}}}},"required":true},"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"409":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["error"],"properties":{"error":{"type":"string"}}}}}}}}},"/api/transfers":{"post":{"operationId":"post_api_transfers","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/transfers/files":{"post":{"operationId":"post_api_transfers_files","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/transfers/{server}":{"delete":{"operationId":"delete_api_transfers_server","parameters":[{"name":"server","in":"path","description":"The server uuid","required":true,"schema":{"type":"string","format":"uuid"},"example":"123e4567-e89b-12d3-a456-426614174000"}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/api/update":{"post":{"operationId":"post_api_update","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"debug":{"type":["boolean","null"]},"app_name":{"type":["string","null"]},"api":{"oneOf":[{"type":"null"},{"type":"object","properties":{"host":{"type":["string","null"]},"port":{"type":["integer","null"],"format":"int32","minimum":0},"ssl":{"oneOf":[{"type":"null"},{"type":"object","properties":{"enabled":{"type":["boolean","null"]},"cert":{"type":["string","null"]},"key":{"type":["string","null"]}}}]},"upload_limit":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/MiB"}]}}}]},"system":{"oneOf":[{"type":"null"},{"type":"object","properties":{"sftp":{"oneOf":[{"type":"null"},{"type":"object","properties":{"bind_address":{"type":["string","null"]},"bind_port":{"type":["integer","null"],"format":"int32","minimum":0}}}]}}}]},"allowed_origins":{"type":["array","null"],"items":{"type":"string"}},"allow_cors_private_network":{"type":["boolean","null"]},"ignore_panel_config_updates":{"type":["boolean","null"]}}}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","required":["applied"],"properties":{"applied":{"type":"boolean"}}}}}}}}},"/download/backup":{"get":{"operationId":"get_download_backup","parameters":[{"name":"token","in":"query","description":"The JWT token to use for authentication","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"401":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"404":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"417":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/download/directory":{"get":{"operationId":"get_download_directory","parameters":[{"name":"token","in":"query","description":"The JWT token to use for authentication","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"401":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"404":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"417":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/download/file":{"get":{"operationId":"get_download_file","parameters":[{"name":"token","in":"query","description":"The JWT token to use for authentication","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"401":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"404":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"417":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/download/files":{"get":{"operationId":"get_download_files","parameters":[{"name":"token","in":"query","description":"The JWT token to use for authentication","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"401":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"404":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}},"417":{"description":"","content":{"text/plain":{"schema":{"type":"string"}}}}}}},"/upload/file":{"post":{"operationId":"post_upload_file","parameters":[{"name":"token","in":"query","description":"The JWT token to use for authentication","required":true,"schema":{"type":"string"}},{"name":"directory","in":"query","description":"The directory to upload the file to","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"text/plain":{"schema":{"type":"string"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"417":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}}},"components":{"schemas":{"ApiError":{"type":"object","required":["error"],"properties":{"error":{"type":"string"}}},"AppContainerType":{"type":"string","enum":["official","unknown","none"]},"ArchiveFormat":{"type":"string","enum":["tar","tar_gz","tar_xz","tar_lzip","tar_bz2","tar_lz4","tar_zstd","zip","seven_zip"]},"BackupAdapter":{"type":"string","enum":["wings","s3","ddup-bak","btrfs","zfs","restic"]},"CompactString":{"type":"string"},"CompressionLevel":{"type":"string","enum":["best_speed","good_speed","good_compression","best_compression"]},"DirectoryEntry":{"type":"object","required":["name","created","modified","mode","mode_bits","size","directory","file","symlink","mime"],"properties":{"name":{"$ref":"#/components/schemas/CompactString"},"created":{"type":"string","format":"date-time"},"modified":{"type":"string","format":"date-time"},"mode":{"$ref":"#/components/schemas/CompactString"},"mode_bits":{"$ref":"#/components/schemas/CompactString"},"size":{"type":"integer","format":"int64","minimum":0},"directory":{"type":"boolean"},"file":{"type":"boolean"},"symlink":{"type":"boolean"},"mime":{"type":"string"}}},"DiskLimiterMode":{"type":"string","enum":["none","btrfs_subvolume","zfs_dataset","xfs_quota","fuse_quota"]},"Download":{"type":"object","required":["identifier","destination","progress","total"],"properties":{"identifier":{"type":"string","format":"uuid"},"destination":{"type":"string"},"progress":{"type":"integer","format":"int64","minimum":0},"total":{"type":"integer","format":"int64","minimum":0}}},"InstallationScript":{"type":"object","required":["container_image","entrypoint","script"],"properties":{"container_image":{"$ref":"#/components/schemas/CompactString"},"entrypoint":{"$ref":"#/components/schemas/CompactString"},"script":{"type":"string"},"environment":{"type":"object","additionalProperties":{},"propertyNames":{"type":"string"}}}},"MiB":{"type":"integer","format":"int64","description":"Represents a size in Mebibytes (MiB). The inner value is the number of MiB (not bytes!!).","minimum":0},"Mount":{"type":"object","required":["target","source","read_only"],"properties":{"target":{"$ref":"#/components/schemas/CompactString"},"source":{"$ref":"#/components/schemas/CompactString"},"read_only":{"type":"boolean"}}},"ResourceUsage":{"type":"object","required":["memory_bytes","memory_limit_bytes","disk_bytes","state","network","cpu_absolute","uptime"],"properties":{"memory_bytes":{"type":"integer","format":"int64","minimum":0},"memory_limit_bytes":{"type":"integer","format":"int64","minimum":0},"disk_bytes":{"type":"integer","format":"int64","minimum":0},"state":{"$ref":"#/components/schemas/ServerState"},"network":{"type":"object","required":["rx_bytes","tx_bytes"],"properties":{"rx_bytes":{"type":"integer","format":"int64","minimum":0},"tx_bytes":{"type":"integer","format":"int64","minimum":0}}},"cpu_absolute":{"type":"number","format":"double"},"uptime":{"type":"integer","format":"int64","minimum":0}}},"Schedule":{"type":"object","required":["uuid","triggers","condition","actions"],"properties":{"uuid":{"type":"string","format":"uuid"},"triggers":{},"condition":{},"actions":{"type":"array","items":{}}}},"ScheduleStatus":{"type":"object","required":["running","errors"],"properties":{"running":{"type":"boolean"},"errors":{"type":"object","additionalProperties":{"type":"string"},"propertyNames":{"type":"string","format":"uuid"}},"step":{"type":["string","null"],"format":"uuid"}}},"Server":{"type":"object","required":["state","is_suspended","utilization","configuration"],"properties":{"state":{"$ref":"#/components/schemas/ServerState"},"is_suspended":{"type":"boolean"},"utilization":{"$ref":"#/components/schemas/ResourceUsage"},"configuration":{"$ref":"#/components/schemas/ServerConfiguration"}}},"ServerAutoStartBehavior":{"type":"string","enum":["always","unless_stopped","never"]},"ServerConfiguration":{"type":"object","required":["uuid","meta","suspended","invocation","skip_egg_scripts","environment","allocations","build","mounts","egg","container"],"properties":{"uuid":{"type":"string","format":"uuid"},"start_on_completion":{"type":["boolean","null"]},"meta":{"type":"object","required":["name","description"],"properties":{"name":{"$ref":"#/components/schemas/CompactString"},"description":{"$ref":"#/components/schemas/CompactString"}}},"suspended":{"type":"boolean"},"invocation":{"$ref":"#/components/schemas/CompactString"},"skip_egg_scripts":{"type":"boolean"},"environment":{"type":"object","additionalProperties":{},"propertyNames":{"type":"string"}},"labels":{"type":"object","additionalProperties":{"type":"string"},"propertyNames":{"type":"string"}},"backups":{"type":"array","items":{"type":"string","format":"uuid"}},"schedules":{"type":"array","items":{"$ref":"#/components/schemas/Schedule"}},"allocations":{"type":"object","required":["force_outgoing_ip"],"properties":{"force_outgoing_ip":{"type":"boolean"},"default":{"oneOf":[{"type":"null"},{"type":"object","required":["ip","port"],"properties":{"ip":{"$ref":"#/components/schemas/CompactString"},"port":{"type":"integer","format":"int32","minimum":0}}}]},"mappings":{"type":"object","additionalProperties":{"type":"array","items":{"type":"integer","format":"int32","minimum":0}},"propertyNames":{"type":"string"}}}},"build":{"type":"object","required":["memory_limit","swap","cpu_limit","disk_space","oom_disabled"],"properties":{"memory_limit":{"type":"integer","format":"int64"},"overhead_memory":{"type":"integer","format":"int64"},"swap":{"type":"integer","format":"int64"},"io_weight":{"type":["integer","null"],"format":"int32","minimum":0},"cpu_limit":{"type":"integer","format":"int64"},"disk_space":{"type":"integer","format":"int64","minimum":0},"threads":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/CompactString"}]},"oom_disabled":{"type":"boolean"}}},"mounts":{"type":"array","items":{"$ref":"#/components/schemas/Mount"}},"egg":{"type":"object","required":["id"],"properties":{"id":{"type":"string","format":"uuid"},"file_denylist":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}}}},"container":{"type":"object","required":["image"],"properties":{"image":{"$ref":"#/components/schemas/CompactString"},"timezone":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/CompactString"}]},"hugepages_passthrough_enabled":{"type":"boolean"},"kvm_passthrough_enabled":{"type":"boolean"},"seccomp":{"type":"object","properties":{"remove_allowed":{"type":"array","items":{"$ref":"#/components/schemas/CompactString"}}}}}},"auto_kill":{"type":"object","properties":{"enabled":{"type":"boolean"},"seconds":{"type":"integer","format":"int64","minimum":0}}},"auto_start_behavior":{"$ref":"#/components/schemas/ServerAutoStartBehavior"}}},"ServerPowerAction":{"type":"string","enum":["start","stop","restart","kill"]},"ServerState":{"type":"string","enum":["offline","starting","stopping","running"]},"SystemBackupsDdupBakCompressionFormat":{"type":"string","enum":["none","deflate","gzip","brotli"]},"SystemStats":{"type":"object","required":["cpu","network","memory","disk"],"properties":{"cpu":{"type":"object","required":["used","threads","model"],"properties":{"used":{"type":"number","format":"float"},"threads":{"type":"integer","minimum":0},"model":{"type":"string"}}},"network":{"type":"object","required":["received","receiving_rate","sent","sending_rate"],"properties":{"received":{"type":"integer","format":"int64","minimum":0},"receiving_rate":{"type":"number","format":"double"},"sent":{"type":"integer","format":"int64","minimum":0},"sending_rate":{"type":"number","format":"double"}}},"memory":{"type":"object","required":["used","used_process","total"],"properties":{"used":{"type":"integer","format":"int64","minimum":0},"used_process":{"type":"integer","format":"int64","minimum":0},"total":{"type":"integer","format":"int64","minimum":0}}},"disk":{"type":"object","required":["used","total","read","reading_rate","written","writing_rate"],"properties":{"used":{"type":"integer","format":"int64","minimum":0},"total":{"type":"integer","format":"int64","minimum":0},"read":{"type":"integer","format":"int64","minimum":0},"reading_rate":{"type":"number","format":"double"},"written":{"type":"integer","format":"int64","minimum":0},"writing_rate":{"type":"number","format":"double"}}}}},"TransferArchiveFormat":{"type":"string","enum":["tar","tar_gz","tar_xz","tar_lzip","tar_bz2","tar_lz4","tar_zstd"]},"WebsocketEvent":{"type":"string","enum":["auth success","token expiring","token expired","auth","configure socket","set state","send logs","send command","send stats","daemon error","jwt error","ping","pong","stats","status","custom event","console output","install output","image pull progress","image pull completed","install started","install completed","daemon message","backup started","backup progress","backup completed","backup restore started","backup restore progress","backup restore completed","transfer logs","transfer status","schedule started","schedule step status","schedule step error","schedule completed","operation progress","operation error","operation completed"]},"WebsocketMessage":{"type":"object","required":["event","args"],"properties":{"event":{"$ref":"#/components/schemas/WebsocketEvent"},"args":{"type":"array","items":{"type":"string"}}}}},"securitySchemes":{"api_key":{"type":"apiKey","in":"header","name": \ No newline at end of file diff --git a/package.json b/package.json index 28cf3de5cb..f2410a873a 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "scripts": { "build": "vite build", + "postbuild": "bash ./scripts/reload-dev-services.sh", "clean": "rimraf public/build", "coverage": "vitest run --coverage", "dev": "vite", @@ -101,7 +102,7 @@ "@testing-library/user-event": "14.4.3", "@types/debounce": "1.2.1", "@types/events": "3.0.0", - "@types/node": "18.14.1", + "@types/node": "20.19.39", "@types/react": "18.0.28", "@types/react-dom": "18.0.11", "@types/styled-components": "5.1.26", @@ -130,10 +131,11 @@ "prettier": "2.8.4", "prettier-plugin-tailwindcss": "0.2.3", "rimraf": "3.0.2", + "source-map-explorer": "^2.5.3", "tailwindcss": "3.2.7", "ts-essentials": "9.3.0", "twin.macro": "2.8.2", - "typescript": "4.9.5", + "typescript": "5.9.3", "vite": "^5.4.0", "vitest": "^1.6.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96d1480cd1..76d2947812 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,7 +163,7 @@ importers: version: 9.1.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0) i18next: specifier: 24.1.2 - version: 24.1.2(typescript@4.9.5) + version: 24.1.2(typescript@5.9.3) i18next-http-backend: specifier: 2.1.1 version: 2.1.1 @@ -193,7 +193,7 @@ importers: version: 3.2.0 react-i18next: specifier: 12.2.0 - version: 12.2.0(i18next@24.1.2(typescript@4.9.5))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 12.2.0(i18next@24.1.2(typescript@5.9.3))(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react-router: specifier: 6.30.2 version: 6.30.2(react@18.2.0) @@ -265,8 +265,8 @@ importers: specifier: 3.0.0 version: 3.0.0 '@types/node': - specifier: 18.14.1 - version: 18.14.1 + specifier: 20.19.39 + version: 20.19.39 '@types/react': specifier: 18.0.28 version: 18.0.28 @@ -281,13 +281,13 @@ importers: version: 0.29.14 '@typescript-eslint/eslint-plugin': specifier: 5.53.0 - version: 5.53.0(@typescript-eslint/parser@5.53.0(eslint@8.34.0)(typescript@4.9.5))(eslint@8.34.0)(typescript@4.9.5) + version: 5.53.0(@typescript-eslint/parser@5.53.0(eslint@8.34.0)(typescript@5.9.3))(eslint@8.34.0)(typescript@5.9.3) '@typescript-eslint/parser': specifier: 5.53.0 - version: 5.53.0(eslint@8.34.0)(typescript@4.9.5) + version: 5.53.0(eslint@8.34.0)(typescript@5.9.3) '@vitejs/plugin-react': specifier: ^4.3.0 - version: 4.7.0(vite@5.4.21(@types/node@18.14.1)) + version: 4.7.0(vite@5.4.21(@types/node@20.19.39)) autoprefixer: specifier: 10.4.13 version: 10.4.13(postcss@8.4.21) @@ -326,7 +326,7 @@ importers: version: 20.8.9 laravel-vite-plugin: specifier: ^1.0.0 - version: 1.3.0(vite@5.4.21(@types/node@18.14.1)) + version: 1.3.0(vite@5.4.21(@types/node@20.19.39)) pathe: specifier: 1.1.0 version: 1.1.0 @@ -351,24 +351,27 @@ importers: rimraf: specifier: 3.0.2 version: 3.0.2 + source-map-explorer: + specifier: ^2.5.3 + version: 2.5.3 tailwindcss: specifier: 3.2.7 version: 3.2.7(postcss@8.4.21) ts-essentials: specifier: 9.3.0 - version: 9.3.0(typescript@4.9.5) + version: 9.3.0(typescript@5.9.3) twin.macro: specifier: 2.8.2 version: 2.8.2 typescript: - specifier: 4.9.5 - version: 4.9.5 + specifier: 5.9.3 + version: 5.9.3 vite: specifier: ^5.4.0 - version: 5.4.21(@types/node@18.14.1) + version: 5.4.21(@types/node@20.19.39) vitest: specifier: ^1.6.0 - version: 1.6.1(@types/node@18.14.1)(happy-dom@20.8.9) + version: 1.6.1(@types/node@20.19.39)(happy-dom@20.8.9) packages: @@ -1321,11 +1324,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@18.14.1': - resolution: {integrity: sha512-QH+37Qds3E0eDlReeboBxfHbX9omAcBCXEzswCu6jySP642jiM3cYSIkU/REqwhCUqXdonHFuBfJDiAJxMNhaQ==} - - '@types/node@25.6.0': - resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/node@20.19.39': + resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -1547,6 +1547,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -1603,6 +1606,9 @@ packages: brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1612,6 +1618,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + btoa@1.2.1: + resolution: {integrity: sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==} + engines: {node: '>= 0.4.0'} + hasBin: true + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -1673,6 +1684,9 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -1891,6 +1905,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + easy-peasy@5.2.0: resolution: {integrity: sha512-QHRRJTsF15ZLK5vLcCLDF3A7sMrE4+7S/8ggZempfMbXX3xNsH2R3HVA+Xo5rZA65Rg8Zf5X6fflEMGuzfgwTw==} peerDependencies: @@ -1909,9 +1926,17 @@ packages: react-native: optional: true + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + electron-to-chromium@1.5.335: resolution: {integrity: sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -1956,6 +1981,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2095,6 +2123,9 @@ packages: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -2178,6 +2209,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-func-name@2.0.2: resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} @@ -2235,6 +2270,10 @@ packages: grapheme-splitter@1.0.4: resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} + gzip-size@6.0.0: + resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} + engines: {node: '>=10'} + happy-dom@20.8.9: resolution: {integrity: sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==} engines: {node: '>=20.0.0'} @@ -2378,6 +2417,11 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2386,6 +2430,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -2454,12 +2502,21 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + js-sdsl@4.4.2: resolution: {integrity: sha512-dwXFwByc/ajSV6m5bcKAPwe4yDDF6D614pxmIi5odytzxRlwqF6nwoiCek80Ixc7Cvma5awClxrzFtxCQvcM8w==} @@ -2604,9 +2661,17 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -2711,6 +2776,10 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} + open@7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3237,6 +3306,10 @@ packages: resolution: {integrity: sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==} engines: {node: '>=8'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3261,6 +3334,11 @@ packages: rgba-regex@1.0.0: resolution: {integrity: sha512-zgn5OjNQXLUTdq8m17KdaicF6w89TZs8ZU8y0AYENIU6wG8GG6LLm0yLSiPY8DmaYmHdgRW8rnApjoT0fQRfMg==} + rimraf@2.6.3: + resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -3357,6 +3435,11 @@ packages: sockette@2.0.6: resolution: {integrity: sha512-W6iG8RGV6Zife3Cj+FhuyHV447E6fqFM2hKmnaQrTvg3OydINV3Msj3WPFbX76blUlUxvQSMMMdrJxce8NqI5Q==} + source-map-explorer@2.5.3: + resolution: {integrity: sha512-qfUGs7UHsOBE5p/lGfQdaAj/5U/GWYBw2imEpD6UQNkqElYonkow8t+HBL1qqIl3CuGZx7n8/CQo4x1HwSHhsg==} + engines: {node: '>=12'} + hasBin: true + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3365,6 +3448,10 @@ packages: resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -3379,6 +3466,10 @@ packages: resolution: {integrity: sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string.prototype.matchall@4.0.12: resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} engines: {node: '>= 0.4'} @@ -3473,6 +3564,10 @@ packages: peerDependencies: postcss: ^8.0.9 + temp@0.9.4: + resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} + engines: {node: '>=6.0.0'} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -3562,9 +3657,9 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} - typescript@4.9.5: - resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} - engines: {node: '>=4.2.0'} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} hasBin: true ufo@1.6.3: @@ -3574,8 +3669,8 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} - undici-types@7.19.2: - resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} @@ -3723,6 +3818,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -3770,6 +3869,10 @@ packages: resolution: {integrity: sha512-LovENH4WDzpwynj+OTkLyZgJPeDom9Gra4DMlGAgz6pZhIDCQ+YuO7yfwanY+gVbn/mmZIStNOnVRU/ikQuAEQ==} deprecated: This package is now deprecated. Move to @xterm/xterm instead. + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3777,6 +3880,14 @@ packages: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs@16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -4881,11 +4992,9 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node@18.14.1': {} - - '@types/node@25.6.0': + '@types/node@20.19.39': dependencies: - undici-types: 7.19.2 + undici-types: 6.21.0 '@types/parse-json@4.0.2': {} @@ -4923,16 +5032,16 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 18.14.1 + '@types/node': 20.19.39 '@types/yup@0.29.14': {} - '@typescript-eslint/eslint-plugin@5.53.0(@typescript-eslint/parser@5.53.0(eslint@8.34.0)(typescript@4.9.5))(eslint@8.34.0)(typescript@4.9.5)': + '@typescript-eslint/eslint-plugin@5.53.0(@typescript-eslint/parser@5.53.0(eslint@8.34.0)(typescript@5.9.3))(eslint@8.34.0)(typescript@5.9.3)': dependencies: - '@typescript-eslint/parser': 5.53.0(eslint@8.34.0)(typescript@4.9.5) + '@typescript-eslint/parser': 5.53.0(eslint@8.34.0)(typescript@5.9.3) '@typescript-eslint/scope-manager': 5.53.0 - '@typescript-eslint/type-utils': 5.53.0(eslint@8.34.0)(typescript@4.9.5) - '@typescript-eslint/utils': 5.53.0(eslint@8.34.0)(typescript@4.9.5) + '@typescript-eslint/type-utils': 5.53.0(eslint@8.34.0)(typescript@5.9.3) + '@typescript-eslint/utils': 5.53.0(eslint@8.34.0)(typescript@5.9.3) debug: 4.4.3(supports-color@5.5.0) eslint: 8.34.0 grapheme-splitter: 1.0.4 @@ -4940,21 +5049,21 @@ snapshots: natural-compare-lite: 1.4.0 regexpp: 3.2.0 semver: 7.7.4 - tsutils: 3.21.0(typescript@4.9.5) + tsutils: 3.21.0(typescript@5.9.3) optionalDependencies: - typescript: 4.9.5 + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@5.53.0(eslint@8.34.0)(typescript@4.9.5)': + '@typescript-eslint/parser@5.53.0(eslint@8.34.0)(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 5.53.0 '@typescript-eslint/types': 5.53.0 - '@typescript-eslint/typescript-estree': 5.53.0(typescript@4.9.5) + '@typescript-eslint/typescript-estree': 5.53.0(typescript@5.9.3) debug: 4.4.3(supports-color@5.5.0) eslint: 8.34.0 optionalDependencies: - typescript: 4.9.5 + typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -4963,21 +5072,21 @@ snapshots: '@typescript-eslint/types': 5.53.0 '@typescript-eslint/visitor-keys': 5.53.0 - '@typescript-eslint/type-utils@5.53.0(eslint@8.34.0)(typescript@4.9.5)': + '@typescript-eslint/type-utils@5.53.0(eslint@8.34.0)(typescript@5.9.3)': dependencies: - '@typescript-eslint/typescript-estree': 5.53.0(typescript@4.9.5) - '@typescript-eslint/utils': 5.53.0(eslint@8.34.0)(typescript@4.9.5) + '@typescript-eslint/typescript-estree': 5.53.0(typescript@5.9.3) + '@typescript-eslint/utils': 5.53.0(eslint@8.34.0)(typescript@5.9.3) debug: 4.4.3(supports-color@5.5.0) eslint: 8.34.0 - tsutils: 3.21.0(typescript@4.9.5) + tsutils: 3.21.0(typescript@5.9.3) optionalDependencies: - typescript: 4.9.5 + typescript: 5.9.3 transitivePeerDependencies: - supports-color '@typescript-eslint/types@5.53.0': {} - '@typescript-eslint/typescript-estree@5.53.0(typescript@4.9.5)': + '@typescript-eslint/typescript-estree@5.53.0(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 5.53.0 '@typescript-eslint/visitor-keys': 5.53.0 @@ -4985,19 +5094,19 @@ snapshots: globby: 11.1.0 is-glob: 4.0.3 semver: 7.7.4 - tsutils: 3.21.0(typescript@4.9.5) + tsutils: 3.21.0(typescript@5.9.3) optionalDependencies: - typescript: 4.9.5 + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@5.53.0(eslint@8.34.0)(typescript@4.9.5)': + '@typescript-eslint/utils@5.53.0(eslint@8.34.0)(typescript@5.9.3)': dependencies: '@types/json-schema': 7.0.15 '@types/semver': 7.7.1 '@typescript-eslint/scope-manager': 5.53.0 '@typescript-eslint/types': 5.53.0 - '@typescript-eslint/typescript-estree': 5.53.0(typescript@4.9.5) + '@typescript-eslint/typescript-estree': 5.53.0(typescript@5.9.3) eslint: 8.34.0 eslint-scope: 5.1.1 eslint-utils: 3.0.0(eslint@8.34.0) @@ -5015,7 +5124,7 @@ snapshots: dependencies: react: 18.2.0 - '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@18.14.1))': + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@20.19.39))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -5023,7 +5132,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 5.4.21(@types/node@18.14.1) + vite: 5.4.21(@types/node@20.19.39) transitivePeerDependencies: - supports-color @@ -5162,6 +5271,8 @@ snapshots: async-function@1.0.0: {} + async@3.2.6: {} + asynckit@0.4.0: {} autoprefixer@10.4.13(postcss@8.4.21): @@ -5233,6 +5344,10 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -5245,6 +5360,8 @@ snapshots: node-releases: 2.0.37 update-browserslist-db: 1.2.3(browserslist@4.28.2) + btoa@1.2.1: {} + bytes@3.1.2: {} cac@6.7.14: {} @@ -5313,6 +5430,12 @@ snapshots: client-only@0.0.1: {} + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -5523,6 +5646,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexer@0.1.2: {} + easy-peasy@5.2.0(@types/react-dom@18.0.11)(@types/react@18.0.28)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.28.6 @@ -5538,8 +5663,14 @@ snapshots: '@types/react-dom': 18.0.11 react-dom: 18.2.0(react@18.2.0) + ejs@3.1.10: + dependencies: + jake: 10.9.4 + electron-to-chromium@1.5.335: {} + emoji-regex@8.0.0: {} + entities@7.0.1: {} error-ex@1.3.4: @@ -5656,6 +5787,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} eslint-config-prettier@8.6.0(eslint@8.34.0): @@ -5840,6 +5973,10 @@ snapshots: dependencies: flat-cache: 3.2.0 + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -5924,6 +6061,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-func-name@2.0.2: {} get-intrinsic@1.3.0: @@ -5995,9 +6134,13 @@ snapshots: grapheme-splitter@1.0.4: {} + gzip-size@6.0.0: + dependencies: + duplexer: 0.1.2 + happy-dom@20.8.9: dependencies: - '@types/node': 25.6.0 + '@types/node': 20.19.39 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 entities: 7.0.1 @@ -6057,11 +6200,11 @@ snapshots: i18next-multiload-backend-adapter@2.2.0: {} - i18next@24.1.2(typescript@4.9.5): + i18next@24.1.2(typescript@5.9.3): dependencies: '@babel/runtime': 7.28.6 optionalDependencies: - typescript: 4.9.5 + typescript: 5.9.3 ignore@5.3.2: {} @@ -6144,12 +6287,16 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-docker@2.2.1: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -6216,10 +6363,20 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + isarray@2.0.5: {} isexe@2.0.0: {} + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + js-sdsl@4.4.2: {} js-tokens@4.0.0: {} @@ -6259,10 +6416,10 @@ snapshots: dependencies: json-buffer: 3.0.1 - laravel-vite-plugin@1.3.0(vite@5.4.21(@types/node@18.14.1)): + laravel-vite-plugin@1.3.0(vite@5.4.21(@types/node@20.19.39)): dependencies: picocolors: 1.0.1 - vite: 5.4.21(@types/node@18.14.1) + vite: 5.4.21(@types/node@20.19.39) vite-plugin-full-reload: 1.2.0 levn@0.4.1: @@ -6340,8 +6497,16 @@ snapshots: dependencies: brace-expansion: 1.1.12 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.0 + minimist@1.2.8: {} + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + mlly@1.8.0: dependencies: acorn: 8.16.0 @@ -6440,6 +6605,11 @@ snapshots: dependencies: mimic-fn: 4.0.0 + open@7.4.2: + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -6848,11 +7018,11 @@ snapshots: react-fast-compare@3.2.0: {} - react-i18next@12.2.0(i18next@24.1.2(typescript@4.9.5))(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + react-i18next@12.2.0(i18next@24.1.2(typescript@5.9.3))(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@babel/runtime': 7.28.6 html-parse-stringify: 3.0.1 - i18next: 24.1.2(typescript@4.9.5) + i18next: 24.1.2(typescript@5.9.3) react: 18.2.0 optionalDependencies: react-dom: 18.2.0(react@18.2.0) @@ -6952,6 +7122,8 @@ snapshots: regexpp@3.2.0: {} + require-directory@2.1.1: {} + resolve-from@4.0.0: {} resolve@1.22.11: @@ -6975,6 +7147,10 @@ snapshots: rgba-regex@1.0.0: {} + rimraf@2.6.3: + dependencies: + glob: 7.2.3 + rimraf@3.0.2: dependencies: glob: 7.2.3 @@ -7113,10 +7289,27 @@ snapshots: sockette@2.0.6: {} + source-map-explorer@2.5.3: + dependencies: + btoa: 1.2.1 + chalk: 4.1.2 + convert-source-map: 1.9.0 + ejs: 3.1.10 + escape-html: 1.0.3 + glob: 7.2.3 + gzip-size: 6.0.0 + lodash: 4.17.23 + open: 7.4.2 + source-map: 0.7.6 + temp: 0.9.4 + yargs: 16.2.0 + source-map-js@1.2.1: {} source-map@0.5.7: {} + source-map@0.7.6: {} + stackback@0.0.2: {} std-env@3.10.0: {} @@ -7128,6 +7321,12 @@ snapshots: string-similarity@4.0.4: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string.prototype.matchall@4.0.12: dependencies: call-bind: 1.0.8 @@ -7294,6 +7493,11 @@ snapshots: transitivePeerDependencies: - ts-node + temp@0.9.4: + dependencies: + mkdirp: 0.5.6 + rimraf: 2.6.3 + text-table@0.2.0: {} timsort@0.3.0: {} @@ -7318,9 +7522,9 @@ snapshots: tr46@0.0.3: {} - ts-essentials@9.3.0(typescript@4.9.5): + ts-essentials@9.3.0(typescript@5.9.3): dependencies: - typescript: 4.9.5 + typescript: 5.9.3 ts-toolbelt@9.6.0: {} @@ -7328,10 +7532,10 @@ snapshots: tslib@2.8.1: {} - tsutils@3.21.0(typescript@4.9.5): + tsutils@3.21.0(typescript@5.9.3): dependencies: tslib: 1.14.1 - typescript: 4.9.5 + typescript: 5.9.3 twin.macro@2.8.2: dependencies: @@ -7394,7 +7598,7 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript@4.9.5: {} + typescript@5.9.3: {} ufo@1.6.3: {} @@ -7405,7 +7609,7 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - undici-types@7.19.2: {} + undici-types@6.21.0: {} universalify@2.0.1: {} @@ -7436,13 +7640,13 @@ snapshots: util-deprecate@1.0.2: {} - vite-node@1.6.1(@types/node@18.14.1): + vite-node@1.6.1(@types/node@20.19.39): dependencies: cac: 6.7.14 debug: 4.4.3(supports-color@5.5.0) pathe: 1.1.2 picocolors: 1.0.1 - vite: 5.4.21(@types/node@18.14.1) + vite: 5.4.21(@types/node@20.19.39) transitivePeerDependencies: - '@types/node' - less @@ -7459,16 +7663,16 @@ snapshots: picocolors: 1.0.1 picomatch: 2.3.1 - vite@5.4.21(@types/node@18.14.1): + vite@5.4.21(@types/node@20.19.39): dependencies: esbuild: 0.21.5 postcss: 8.5.6 rollup: 4.60.1 optionalDependencies: - '@types/node': 18.14.1 + '@types/node': 20.19.39 fsevents: 2.3.3 - vitest@1.6.1(@types/node@18.14.1)(happy-dom@20.8.9): + vitest@1.6.1(@types/node@20.19.39)(happy-dom@20.8.9): dependencies: '@vitest/expect': 1.6.1 '@vitest/runner': 1.6.1 @@ -7487,11 +7691,11 @@ snapshots: strip-literal: 2.1.1 tinybench: 2.9.0 tinypool: 0.8.4 - vite: 5.4.21(@types/node@18.14.1) - vite-node: 1.6.1(@types/node@18.14.1) + vite: 5.4.21(@types/node@20.19.39) + vite-node: 1.6.1(@types/node@20.19.39) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 18.14.1 + '@types/node': 20.19.39 happy-dom: 20.8.9 transitivePeerDependencies: - less @@ -7568,6 +7772,12 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrappy@1.0.2: {} ws@8.20.0: {} @@ -7595,10 +7805,24 @@ snapshots: xterm@5.1.0: {} + y18n@5.0.8: {} + yallist@3.1.1: {} yaml@1.10.2: {} + yargs-parser@20.2.9: {} + + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + yocto-queue@0.1.0: {} yocto-queue@1.2.2: {} diff --git a/resources/lang/en/activity.php b/resources/lang/en/activity.php index 25ad929c97..6c9d8a9527 100644 --- a/resources/lang/en/activity.php +++ b/resources/lang/en/activity.php @@ -85,6 +85,8 @@ ], 'sftp' => [ 'denied' => 'Blocked SFTP access due to permissions', + 'login' => 'Logged in via SFTP (:method)', + 'logout' => 'Disconnected from SFTP session', 'create_one' => 'Created :files.0', 'create_other' => 'Created :count new files', 'write_one' => 'Modified the contents of :files.0', @@ -96,6 +98,12 @@ 'rename_one' => 'Renamed :files.0.from to :files.0.to', 'rename_other' => 'Renamed or moved :count files', ], + 'ssh' => [ + 'login' => 'Logged in via SSH (:type)', + 'logout' => 'Disconnected from SSH session', + 'command' => 'Executed ":command" via SSH', + 'power' => 'Sent :action power command via SSH', + ], 'allocation' => [ 'create' => 'Added :allocation to the server', 'notes' => 'Updated the notes for :allocation from ":old" to ":new"', diff --git a/resources/scripts/api/definitions/account/billing/models.d.ts b/resources/scripts/api/definitions/account/billing/models.d.ts index 695dc1720d..46d9b9e0e4 100644 --- a/resources/scripts/api/definitions/account/billing/models.d.ts +++ b/resources/scripts/api/definitions/account/billing/models.d.ts @@ -44,9 +44,11 @@ interface Product extends Model { name: string; icon?: string; price: number; + basePrice?: number; description?: string; eggId: number; allowedEggs: number[]; + allowEggChanges: boolean; limits: { cpu: number; memory: number; @@ -54,6 +56,7 @@ interface Product extends Model { backup: number; database: number; allocation: number; + subdomain: number | null; }; } diff --git a/resources/scripts/api/definitions/account/billing/transformers.ts b/resources/scripts/api/definitions/account/billing/transformers.ts index 69cf41ecd6..d86887e3eb 100644 --- a/resources/scripts/api/definitions/account/billing/transformers.ts +++ b/resources/scripts/api/definitions/account/billing/transformers.ts @@ -37,6 +37,7 @@ export default class Transformers { description: data.description, allowedEggs: data.allowedEggs || [data.eggId], allowEggChanges: data.allowEggChanges ?? true, + allowPlanChanges: data.allowPlanChanges ?? true, }); static toProduct = ({ attributes: data }: FractalResponseData): Models.Product => ({ @@ -55,6 +56,7 @@ export default class Transformers { backup: data.limits.backup, database: data.limits.database, allocation: data.limits.allocation, + subdomain: data.limits.subdomain ?? null, }, }); diff --git a/resources/scripts/api/definitions/admin/models.d.ts b/resources/scripts/api/definitions/admin/models.d.ts index f671ab7aaf..a85d8a51e2 100644 --- a/resources/scripts/api/definitions/admin/models.d.ts +++ b/resources/scripts/api/definitions/admin/models.d.ts @@ -198,6 +198,7 @@ interface Product extends Model { backup: number; database: number; allocation: number; + subdomain: number | null; }; createdAt: Date; diff --git a/resources/scripts/api/definitions/admin/transformers.ts b/resources/scripts/api/definitions/admin/transformers.ts index d405767089..910153e1b5 100644 --- a/resources/scripts/api/definitions/admin/transformers.ts +++ b/resources/scripts/api/definitions/admin/transformers.ts @@ -61,7 +61,9 @@ export default class Transformers { featureLimits: attributes.feature_limits, container: attributes.container, renewalDate: attributes.renewal_date ? new Date(attributes.renewal_date) : undefined, - deletionScheduledAt: attributes.deletion_scheduled_at ? new Date(attributes.deletion_scheduled_at) : undefined, + deletionScheduledAt: attributes.deletion_scheduled_at + ? new Date(attributes.deletion_scheduled_at) + : undefined, deletionCanceledAt: attributes.deletion_canceled_at ? new Date(attributes.deletion_canceled_at) : undefined, deletionScheduledBy: attributes.deletion_scheduled_by, isDeletionScheduled: attributes.is_deletion_scheduled ?? false, @@ -125,6 +127,7 @@ export default class Transformers { description: attributes.description, permissions: attributes.permissions, color: attributes.color, + relationships: {}, }); static toAdminRolePermission = ({ attributes }: FractalResponseData): Models.AdminRolePermission => ({ @@ -207,6 +210,7 @@ export default class Transformers { backup: attributes.limits.backup, database: attributes.limits.database, allocation: attributes.limits.allocation, + subdomain: attributes.limits.subdomain ?? null, }, createdAt: new Date(attributes.created_at), diff --git a/resources/scripts/api/definitions/server/models.d.ts b/resources/scripts/api/definitions/server/models.d.ts index 7aacd1d854..530dea7845 100644 --- a/resources/scripts/api/definitions/server/models.d.ts +++ b/resources/scripts/api/definitions/server/models.d.ts @@ -3,6 +3,7 @@ import { type SubuserPermission } from '@/state/server/subusers'; import { ServerStatus } from '@/api/routes/server'; interface Server { + serverOwner?: boolean; id: string; internalId: number | string; uuid: string; @@ -11,6 +12,7 @@ interface Server { name: string; node: string; isNodeUnderMaintenance: boolean; + isNodeSupercharged: boolean; status: ServerStatus; sftpDetails: { ip: string; @@ -30,6 +32,7 @@ interface Server { }; eggFeatures: string[]; modpacksSupported: boolean; + extensionsEnabled: boolean; billingProductId?: number; billingDays?: number; renewalDate?: Date | undefined; @@ -37,11 +40,13 @@ interface Server { deletionCanceledAt?: Date | undefined; deletionScheduledBy?: number | null; isDeletionScheduled?: boolean; + modsEnabled?: boolean; featureLimits: { databases: number; allocations: number; backups: number; subusers: number; + subdomains: number | null; }; isTransferring: boolean; variables: EggVariable[]; diff --git a/resources/scripts/api/definitions/server/transformers.ts b/resources/scripts/api/definitions/server/transformers.ts index 9cc274cf9f..0723e29bd1 100644 --- a/resources/scripts/api/definitions/server/transformers.ts +++ b/resources/scripts/api/definitions/server/transformers.ts @@ -3,6 +3,7 @@ import * as Models from '@definitions/server/models.d'; export default class Transformers { static toServer = ({ attributes: data }: FractalResponseData): Models.Server => ({ + serverOwner: data.server_owner, id: data.identifier, internalId: data.internal_id, groupId: data.group_id, @@ -11,6 +12,7 @@ export default class Transformers { node: data.node, nodeId: data.node_id, isNodeUnderMaintenance: data.is_node_under_maintenance, + isNodeSupercharged: data.is_node_supercharged, status: data.status, invocation: data.invocation, dockerImage: data.docker_image, @@ -23,6 +25,7 @@ export default class Transformers { limits: { ...data.limits }, eggFeatures: data.egg_features || [], modpacksSupported: data.modpacks_supported || false, + extensionsEnabled: data.extensions_enabled || false, billingProductId: data.billing_product_id, billingDays: data.billing_days, renewalDate: data.renewal_date ? new Date(data.renewal_date) : undefined, @@ -59,7 +62,10 @@ export default class Transformers { databaseHostId: attributes.database_host_id, connectionString: `${attributes.host.address}:${attributes.host.port}`, allowConnectionsFrom: attributes.connections_from, - password: attributes.relationships?.password?.attributes?.password, + password: + attributes.relationships?.password && 'attributes' in attributes.relationships.password + ? (attributes.relationships.password as FractalResponseData).attributes.password + : undefined, }); static toSubuser = (data: FractalResponseData): Models.Subuser => ({ @@ -105,22 +111,48 @@ export default class Transformers { modifiedAt: new Date(data.attributes.modified_at), isArchiveType: function () { + const lowerName = this.name.toLowerCase(); + + const archiveExtensions = [ + '.zip', + '.7z', + '.ddup', + '.rar', + '.tar', + '.tar.gz', + '.tgz', + '.tar.bz2', + '.tbz2', + '.tar.xz', + '.txz', + '.tar.zst', + '.tzst', + '.tar.lz4', + '.tlz4', + '.tar.br', + ]; + + const archiveMimeTypes = [ + 'application/vnd.rar', + 'application/x-rar-compressed', + 'application/x-tar', + 'application/x-br', + 'application/x-bzip2', + 'application/gzip', + 'application/x-gzip', + 'application/x-lzip', + 'application/x-sz', + 'application/x-xz', + 'application/zstd', + 'application/zip', + 'application/x-zip-compressed', + 'application/x-7z-compressed', + ]; + return ( this.isFile && - [ - 'application/vnd.rar', // .rar - 'application/x-rar-compressed', // .rar (2) - 'application/x-tar', // .tar - 'application/x-br', // .tar.br - 'application/x-bzip2', // .tar.bz2, .bz2 - 'application/gzip', // .tar.gz, .gz - 'application/x-gzip', - 'application/x-lzip', // .tar.lz4, .lz4 (not sure if this mime type is correct) - 'application/x-sz', // .tar.sz, .sz (not sure if this mime type is correct) - 'application/x-xz', // .tar.xz, .xz - 'application/zstd', // .tar.zst, .zst - 'application/zip', // .zip - ].indexOf(this.mimetype) >= 0 + (archiveExtensions.some(extension => lowerName.endsWith(extension)) || + archiveMimeTypes.indexOf(this.mimetype) >= 0) ); }, diff --git a/resources/scripts/api/extensions/scanExtension.ts b/resources/scripts/api/extensions/scanExtension.ts new file mode 100644 index 0000000000..dc4a2ee2a2 --- /dev/null +++ b/resources/scripts/api/extensions/scanExtension.ts @@ -0,0 +1,51 @@ +import http from '@/api/http'; + +export interface ScanFinding { + file: string; + line: number; + column?: number; + severity: string | number; + message: string; + source?: string; + rule?: string; +} + +export interface ScanSummary { + high: number; + warnings: number; +} + +export interface ScanReport { + scanned_at: string; + outcome: 'passed' | 'warned' | 'blocked'; + php_findings: ScanFinding[]; + js_findings: ScanFinding[]; + semgrep_findings: ScanFinding[]; + report_path?: string; + summary: ScanSummary; +} + +/** + * Upload a .M12LabsExtension file and run the security scanner against it. + * Resolves with the scan report on success (passed/warned). + * Rejects (with the scan report attached to the error) when the scan is blocked. + */ +export const scanExtension = async (file: File): Promise => { + const form = new FormData(); + form.append('extension_file', file); + + const { data } = await http.post('/api/client/extensions/scan', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 120_000, + }); + + return data; +}; + +/** + * Fetch the stored scan report for an already-installed extension by its slug. + */ +export const getScanReport = async (slug: string): Promise => { + const { data } = await http.get(`/api/client/extensions/${encodeURIComponent(slug)}/scan-report`); + return data; +}; diff --git a/resources/scripts/api/routes/account/billing/customDomains.ts b/resources/scripts/api/routes/account/billing/customDomains.ts new file mode 100644 index 0000000000..63a3920f19 --- /dev/null +++ b/resources/scripts/api/routes/account/billing/customDomains.ts @@ -0,0 +1,22 @@ +import http from '@/api/http'; + +export interface AvailableCustomDomain { + id: number; + domain: string; + wildcard_enabled: boolean; + default_service_tag: string | null; + recommended_record_type: 'srv' | 'cname'; + srv_supported: boolean; + allow_record_type_selection: boolean; + forced_record_type: 'srv' | 'cname' | null; + dns_mode: 'minecraft' | 'rust' | 'generic'; + recommendation_notice: string; + connection_hint: string; +} + +export const getAvailableCustomDomains = async (eggId?: number): Promise => { + const query = eggId ? `?egg_id=${eggId}` : ''; + const { data } = await http.get(`/api/client/billing/custom-domains/options${query}`); + + return data.data || []; +}; diff --git a/resources/scripts/api/routes/account/billing/orders/mollie.ts b/resources/scripts/api/routes/account/billing/orders/mollie.ts index 3861a1726e..026e750a7a 100644 --- a/resources/scripts/api/routes/account/billing/orders/mollie.ts +++ b/resources/scripts/api/routes/account/billing/orders/mollie.ts @@ -10,6 +10,7 @@ export interface MolliePaymentStatus { processed: boolean; failed: boolean; pending: boolean; + payment_status?: string; } export interface MolliePaymentFromToken { @@ -49,6 +50,7 @@ export const updateMolliePayment = ({ eggId, billingDays, name, + domainPayload, }: { id: number; paymentId: string; @@ -60,6 +62,11 @@ export const updateMolliePayment = ({ eggId?: number; billingDays?: number; name: string; + domainPayload?: Array<{ + domain_id: number; + subdomain: string; + record_type?: 'srv' | 'cname'; + }>; }): Promise => { return new Promise((resolve, reject) => { http.put(`/api/client/billing/products/${id}/mollie/payment`, { @@ -72,6 +79,7 @@ export const updateMolliePayment = ({ egg_id: eggId, billing_days: billingDays, name, + domain_payload: domainPayload, }) .then(() => resolve()) .catch(reject); diff --git a/resources/scripts/api/routes/account/billing/orders/paypal.ts b/resources/scripts/api/routes/account/billing/orders/paypal.ts index 23ce626c15..6615642288 100644 --- a/resources/scripts/api/routes/account/billing/orders/paypal.ts +++ b/resources/scripts/api/routes/account/billing/orders/paypal.ts @@ -58,6 +58,7 @@ export const updatePayPalOrder = ({ eggId, billingDays, name, + domainPayload, }: { id: number; orderId: string; @@ -69,6 +70,11 @@ export const updatePayPalOrder = ({ eggId?: number; billingDays?: number; name: string; + domainPayload?: Array<{ + domain_id: number; + subdomain: string; + record_type?: 'srv' | 'cname'; + }>; }): Promise => { return new Promise((resolve, reject) => { http.put(`/api/client/billing/products/${id}/paypal/order`, { @@ -81,6 +87,7 @@ export const updatePayPalOrder = ({ egg_id: eggId, billing_days: billingDays, name, + domain_payload: domainPayload, }) .then(() => resolve()) .catch(reject); diff --git a/resources/scripts/api/routes/account/billing/orders/process.ts b/resources/scripts/api/routes/account/billing/orders/process.ts index 7a710e6d84..3f06a73250 100644 --- a/resources/scripts/api/routes/account/billing/orders/process.ts +++ b/resources/scripts/api/routes/account/billing/orders/process.ts @@ -18,6 +18,11 @@ export const processUnpaidOrder = ( coupon_id?: number, egg_id?: number, name?: string, + domain_payload?: Array<{ + domain_id: number; + subdomain: string; + record_type?: 'srv' | 'cname'; + }>, ): Promise => { return new Promise((resolve, reject) => { http.post(`/api/client/billing/process/free`, { @@ -29,13 +34,19 @@ export const processUnpaidOrder = ( coupon_id, egg_id, name, + domain_payload, }) .then(({ data }) => resolve(data)) .catch(reject); }); }; -export const renewFreeServer = (product: number, server_id: number, coupon_id?: number, billing_days?: number): Promise => { +export const renewFreeServer = ( + product: number, + server_id: number, + coupon_id?: number, + billing_days?: number, +): Promise => { return new Promise((resolve, reject) => { http.post(`/api/client/billing/renew/free`, { product, server_id, coupon_id, billing_days }) .then(({ data }) => resolve(data)) diff --git a/resources/scripts/api/routes/account/billing/orders/stripe.ts b/resources/scripts/api/routes/account/billing/orders/stripe.ts index 2ec4300f3e..62351ef8b3 100644 --- a/resources/scripts/api/routes/account/billing/orders/stripe.ts +++ b/resources/scripts/api/routes/account/billing/orders/stripe.ts @@ -32,6 +32,7 @@ export const updateStripeIntent = ({ egg_id, name, billing_days, + domain_payload, }: UpdateStripeIntent): Promise => { return new Promise((resolve, reject) => { http.put(`/api/client/billing/products/${id}/intent`, { @@ -44,6 +45,7 @@ export const updateStripeIntent = ({ egg_id, name, billing_days, + domain_payload, }) .then(() => resolve()) .catch(reject); diff --git a/resources/scripts/api/routes/account/billing/orders/types.d.ts b/resources/scripts/api/routes/account/billing/orders/types.d.ts index 6141035e51..aae2e1ee83 100644 --- a/resources/scripts/api/routes/account/billing/orders/types.d.ts +++ b/resources/scripts/api/routes/account/billing/orders/types.d.ts @@ -26,4 +26,9 @@ export interface UpdateStripeIntent { egg_id?: number; name?: string; billing_days?: number; + domain_payload?: Array<{ + domain_id: number; + subdomain: string; + record_type?: 'srv' | 'cname'; + }>; } diff --git a/resources/scripts/api/routes/account/billing/products.ts b/resources/scripts/api/routes/account/billing/products.ts index 6d3ca8c4b7..83bc9cfb32 100644 --- a/resources/scripts/api/routes/account/billing/products.ts +++ b/resources/scripts/api/routes/account/billing/products.ts @@ -3,6 +3,8 @@ import { EggVariable } from '@definitions/server'; import http from '@/api/http'; import { Transformers as ServerTransformers } from '@definitions/server'; +export type { Product }; + export interface EggInfo { id: number; name: string; @@ -56,6 +58,7 @@ export interface BillingCycle { multiplier: number; discountPercent: number; isDefault: boolean; + label?: string; } export const getProductBillingCycles = (productId: number): Promise => { diff --git a/resources/scripts/api/routes/account/modpacks.ts b/resources/scripts/api/routes/account/modpacks.ts index 20a7e547ad..0522a66782 100644 --- a/resources/scripts/api/routes/account/modpacks.ts +++ b/resources/scripts/api/routes/account/modpacks.ts @@ -1,6 +1,5 @@ import http from '@/api/http'; import { - CurseForgeModpack, ModpackSearchParams, ModpackSearchResponse, ModpackResponse, diff --git a/resources/scripts/api/routes/admin/auth/jguard.ts b/resources/scripts/api/routes/admin/auth/jguard.ts index b0dfcd88a9..a4aeb4ffeb 100644 --- a/resources/scripts/api/routes/admin/auth/jguard.ts +++ b/resources/scripts/api/routes/admin/auth/jguard.ts @@ -12,9 +12,7 @@ export interface JGuardPendingUser { } export const getJGuardPending = (status = 'pending'): Promise => { - return http - .get('/api/application/auth/jguard/pending', { params: { status } }) - .then(({ data }) => data.data); + return http.get('/api/application/auth/jguard/pending', { params: { status } }).then(({ data }) => data.data); }; export const approveJGuardUser = (userId: number): Promise => { diff --git a/resources/scripts/api/routes/admin/billing/types.d.ts b/resources/scripts/api/routes/admin/billing/types.d.ts index c66cf78eb0..c58be41f4f 100644 --- a/resources/scripts/api/routes/admin/billing/types.d.ts +++ b/resources/scripts/api/routes/admin/billing/types.d.ts @@ -20,6 +20,7 @@ export interface ProductValues { backup: number; database: number; allocation: number; + subdomain: number | null; }; } diff --git a/resources/scripts/api/routes/admin/customDomains.ts b/resources/scripts/api/routes/admin/customDomains.ts new file mode 100644 index 0000000000..b8eb7853a6 --- /dev/null +++ b/resources/scripts/api/routes/admin/customDomains.ts @@ -0,0 +1,141 @@ +import http from '@/api/http'; + +export interface AdminCustomDomain { + id: number; + domain: string; + cloudflare_zone_id: string | null; + api_key_id: number | null; + api_key_name: string | null; + allowed_nest_ids: number[]; + allowed_egg_ids: number[]; + service_tag: string | null; + egg_service_tags: Record; + wildcard_enabled: boolean; + enabled: boolean; + created_at?: string; + updated_at?: string; +} + +export interface CustomDomainApiKey { + id: number; + name: string; + enabled: boolean; + created_at?: string; + updated_at?: string; +} + +export interface CreateAdminCustomDomainPayload { + domain: string; + cloudflare_zone_id?: string | null; + api_key_id?: number | null; + allowed_nest_ids?: number[]; + allowed_egg_ids?: number[]; + service_tag?: string | null; + egg_service_tags?: Record; + wildcard_enabled?: boolean; + enabled?: boolean; +} + +export interface UpdateAdminCustomDomainPayload { + domain?: string; + cloudflare_zone_id?: string | null; + api_key_id?: number | null; + allowed_nest_ids?: number[]; + allowed_egg_ids?: number[]; + service_tag?: string | null; + egg_service_tags?: Record; + wildcard_enabled?: boolean; + enabled?: boolean; +} + +export interface CustomDomainTargetOptions { + nests: Array<{ id: number; uuid: string; name: string; description: string | null }>; + eggs: Array<{ + id: number; + uuid: string; + nest_id: number; + nest_name: string; + name: string; + description: string | null; + default_service_tag: string | null; + }>; +} + +export interface CustomDomainSettings { + cloudflare_token: string; + allow_wildcard: boolean; + max_wildcards_per_user: number; + rate_limit_create_per_minute: number; + rate_limit_sync_per_minute: number; + rate_limit_billing_options_per_minute: number; +} + +export const getCustomDomains = async (): Promise => { + const { data } = await http.get('/api/application/custom-domains'); + + return data.data || []; +}; + +export const createCustomDomain = async (payload: CreateAdminCustomDomainPayload): Promise => { + const { data } = await http.post('/api/application/custom-domains', payload); + + return data.data; +}; + +export const updateCustomDomain = async ( + id: number, + payload: UpdateAdminCustomDomainPayload, +): Promise => { + const { data } = await http.patch(`/api/application/custom-domains/${id}`, payload); + + return data.data; +}; + +export const deleteCustomDomain = async (id: number): Promise => { + await http.delete(`/api/application/custom-domains/${id}`); +}; + +export const getCustomDomainApiKeys = async (): Promise => { + const { data } = await http.get('/api/application/custom-domains/api-keys'); + + return data.data || []; +}; + +export const createCustomDomainApiKey = async (payload: { + name: string; + token: string; + enabled?: boolean; +}): Promise => { + const { data } = await http.post('/api/application/custom-domains/api-keys', payload); + + return data.data; +}; + +export const updateCustomDomainApiKey = async ( + id: number, + payload: { name?: string; token?: string; enabled?: boolean }, +): Promise => { + const { data } = await http.patch(`/api/application/custom-domains/api-keys/${id}`, payload); + + return data.data; +}; + +export const deleteCustomDomainApiKey = async (id: number): Promise => { + await http.delete(`/api/application/custom-domains/api-keys/${id}`); +}; + +export const getCustomDomainTargetOptions = async (): Promise => { + const { data } = await http.get('/api/application/custom-domains/options'); + + return data.data; +}; + +export const getCustomDomainSettings = async (): Promise => { + const { data } = await http.get('/api/application/custom-domains/settings'); + + return data.data; +}; + +export const updateCustomDomainSettings = async (payload: Partial): Promise => { + await http.put('/api/application/custom-domains/settings', payload); +}; diff --git a/resources/scripts/api/routes/admin/eggs/getEgg.ts b/resources/scripts/api/routes/admin/eggs/getEgg.ts index 161ce946c0..eaa5325fa6 100644 --- a/resources/scripts/api/routes/admin/eggs/getEgg.ts +++ b/resources/scripts/api/routes/admin/eggs/getEgg.ts @@ -49,9 +49,9 @@ export interface Egg { scriptInstall: string | null; createdAt: Date; updatedAt: Date; - // configFrom: number | null; - // copyScriptFrom: number | null; - // scriptIsPrivileged: boolean; + configFrom: number | null; + copyScriptFrom: number | null; + scriptIsPrivileged: boolean; relations: { nest?: Nest; diff --git a/resources/scripts/api/routes/admin/email/index.ts b/resources/scripts/api/routes/admin/email/index.ts index 55a36fc9c6..518be25327 100644 --- a/resources/scripts/api/routes/admin/email/index.ts +++ b/resources/scripts/api/routes/admin/email/index.ts @@ -388,22 +388,29 @@ export const previewEmailTemplate = (key: string): Promise => { return new Promise((resolve, reject) => { http.get(`/api/application/email/templates/${encodeURIComponent(key)}/preview`, { responseType: 'text', - transformResponse: [(data) => data], + transformResponse: [data => data], }) .then(({ data }) => resolve(data)) .catch(reject); }); }; -export const getEmailTemplateSource = (key: string): Promise<{ key: string; content: string; is_customized: boolean }> => { +export const getEmailTemplateSource = ( + key: string, +): Promise<{ key: string; content: string; is_customized: boolean }> => { return new Promise((resolve, reject) => { - http.get<{ key: string; content: string; is_customized: boolean }>(`/api/application/email/templates/${encodeURIComponent(key)}/source`) + http.get<{ key: string; content: string; is_customized: boolean }>( + `/api/application/email/templates/${encodeURIComponent(key)}/source`, + ) .then(({ data }) => resolve(data)) .catch(reject); }); }; -export const saveEmailTemplateSource = (key: string, content: string): Promise<{ success: boolean; key: string; is_customized: boolean }> => { +export const saveEmailTemplateSource = ( + key: string, + content: string, +): Promise<{ success: boolean; key: string; is_customized: boolean }> => { return new Promise((resolve, reject) => { http.put<{ success: boolean; key: string; is_customized: boolean }>( `/api/application/email/templates/${encodeURIComponent(key)}/source`, @@ -414,7 +421,9 @@ export const saveEmailTemplateSource = (key: string, content: string): Promise<{ }); }; -export const revertEmailTemplate = (key: string): Promise<{ success: boolean; key: string; is_customized: boolean }> => { +export const revertEmailTemplate = ( + key: string, +): Promise<{ success: boolean; key: string; is_customized: boolean }> => { return new Promise((resolve, reject) => { http.delete<{ success: boolean; key: string; is_customized: boolean }>( `/api/application/email/templates/${encodeURIComponent(key)}/source`, diff --git a/resources/scripts/api/routes/admin/extensions/index.ts b/resources/scripts/api/routes/admin/extensions/index.ts new file mode 100644 index 0000000000..334905c5df --- /dev/null +++ b/resources/scripts/api/routes/admin/extensions/index.ts @@ -0,0 +1,263 @@ +import http from '@/api/http'; + +export interface ExtensionSettingOption { + label: string; + value: string | number | boolean; +} + +export type ExtensionSettingFieldType = 'text' | 'password' | 'textarea' | 'select' | 'boolean' | 'number'; + +export interface ExtensionSettingField { + key: string; + label: string; + type: ExtensionSettingFieldType; + help?: string; + placeholder?: string; + options?: ExtensionSettingOption[]; +} + +export interface ExtensionSourceInfo { + type: 'core' | 'repository'; + label: string; + official: boolean; + repositoryId: number | null; + repositoryName: string | null; + homepageUrl: string | null; + securityWarning: string | null; +} + +export interface ExtensionData { + id: string; + name: string; + description: string; + version: string; + latestVersion?: string; + author: string; + icon: string; + route?: string; + enabled: boolean; + allowedNests: number[]; + allowedEggs: number[]; + settings: Record; + settingsSchema?: ExtensionSettingField[]; + installed?: boolean; + installable?: boolean; + canUninstall?: boolean; + status?: 'core' | 'installed' | 'available'; + updateAvailable?: boolean; + compatiblePanelVersions?: string[]; + source?: ExtensionSourceInfo; +} + +export interface ExtensionRepositoryData { + id: number; + slug: string; + name: string; + manifestUrl: string; + homepageUrl: string | null; + enabled: boolean; + official: boolean; + packagesCount: number; + securityWarning: string; + status?: 'ok' | 'error' | 'disabled'; + error?: string; +} + +export interface NestOption { + id: number; + uuid: string; + name: string; + description: string | null; +} + +export interface EggOption { + id: number; + uuid: string; + name: string; + description: string | null; + nestId: number; + nestName: string; +} + +export interface NestsAndEggs { + nests: NestOption[]; + eggs: EggOption[]; +} + +export const getExtensions = async (): Promise => { + const { data } = await http.get('/api/application/extensions'); + return data.data; +}; + +export const refreshExtensions = async (): Promise => { + const { data } = await http.post('/api/application/extensions/refresh'); + return data.data; +}; + +export const getExtension = async (extensionId: string): Promise => { + const { data } = await http.get(`/api/application/extensions/${extensionId}`); + return data.attributes ?? data; +}; + +export const updateExtension = async ( + extensionId: string, + allowedNests: number[], + allowedEggs: number[], + settings: Record = {}, +): Promise => { + const { data } = await http.put(`/api/application/extensions/${extensionId}`, { + allowed_nests: allowedNests, + allowed_eggs: allowedEggs, + settings, + }); + return data.attributes ?? data; +}; + +export const toggleExtension = async (extensionId: string): Promise => { + const { data } = await http.post(`/api/application/extensions/${extensionId}/toggle`); + return data.attributes ?? data; +}; + +export interface InstallProgress { + action: 'install' | 'uninstall' | 'update' | 'batch-install' | 'batch-uninstall' | 'batch-update'; + extension_id: string; + stage: string; + updated_at: string; + batch_total?: number; + batch_current?: number; + batch_extensions?: string[]; +} + +export const getInstallProgress = async (): Promise => { + const { data } = await http.get('/api/application/extensions/progress'); + return data.progress ?? null; +}; + +export const installExtension = async ( + extensionId: string, + repositoryId: number, + version?: string, +): Promise => { + const { data } = await http.post( + `/api/application/extensions/${extensionId}/install`, + { repository_id: repositoryId, version }, + { timeout: 300000 }, + ); + + return data.attributes ?? data; +}; + +export const uninstallExtension = async (extensionId: string): Promise => { + const { data } = await http.post(`/api/application/extensions/${extensionId}/uninstall`, {}, { timeout: 300000 }); + + return data.attributes ?? data; +}; + +export const upgradeExtension = async ( + extensionId: string, + repositoryId: number, + version?: string, +): Promise => { + const { data } = await http.post( + `/api/application/extensions/${extensionId}/update-package`, + { repository_id: repositoryId, version }, + { timeout: 300000 }, + ); + + return data.attributes ?? data; +}; + +export const updateModuleSettings = async (enabled: boolean): Promise => { + await http.put('/api/application/extensions/settings', { key: 'enabled', value: enabled }); +}; + +export const getNestsAndEggs = async (): Promise => { + const { data } = await http.get('/api/application/extensions/nests-eggs'); + return data; +}; + +export const getRepositories = async (): Promise => { + const { data } = await http.get('/api/application/extensions/repositories'); + return data.data; +}; + +export const createRepository = async (payload: { + name: string; + manifestUrl: string; + homepageUrl?: string; + enabled?: boolean; + acknowledgeRisk: boolean; +}): Promise => { + const { data } = await http.post('/api/application/extensions/repositories', { + name: payload.name, + manifest_url: payload.manifestUrl, + homepage_url: payload.homepageUrl, + enabled: payload.enabled ?? true, + acknowledge_risk: payload.acknowledgeRisk, + }); + + return data.attributes ?? data; +}; + +export const updateRepository = async ( + repositoryId: number, + payload: Partial<{ name: string; manifestUrl: string; homepageUrl: string | null; enabled: boolean }>, +): Promise => { + const { data } = await http.patch(`/api/application/extensions/repositories/${repositoryId}`, { + name: payload.name, + manifest_url: payload.manifestUrl, + homepage_url: payload.homepageUrl, + enabled: payload.enabled, + }); + + return data.attributes ?? data; +}; + +export const deleteRepository = async (repositoryId: number): Promise => { + await http.delete(`/api/application/extensions/repositories/${repositoryId}`); +}; + +export interface BatchInstallItem { + extensionId: string; + repositoryId: number; + version?: string; +} + +export const batchInstallExtensions = async (items: BatchInstallItem[]): Promise => { + const { data } = await http.post( + '/api/application/extensions/batch-install', + { + extensions: items.map(item => ({ + extension_id: item.extensionId, + repository_id: item.repositoryId, + version: item.version, + })), + }, + { timeout: 1800000 }, + ); + return data.data; +}; + +export const batchUninstallExtensions = async (extensionIds: string[]): Promise => { + const { data } = await http.post( + '/api/application/extensions/batch-uninstall', + { extension_ids: extensionIds }, + { timeout: 1800000 }, + ); + return data.data; +}; + +export const batchUpdateExtensions = async (items: BatchInstallItem[]): Promise => { + const { data } = await http.post( + '/api/application/extensions/batch-update', + { + extensions: items.map(item => ({ + extension_id: item.extensionId, + repository_id: item.repositoryId, + version: item.version, + })), + }, + { timeout: 1800000 }, + ); + return data.data; +}; diff --git a/resources/scripts/api/routes/admin/mods/settings.ts b/resources/scripts/api/routes/admin/mods/settings.ts index dc5e274a0e..6d6c96a23b 100644 --- a/resources/scripts/api/routes/admin/mods/settings.ts +++ b/resources/scripts/api/routes/admin/mods/settings.ts @@ -42,5 +42,4 @@ export interface ModsAnalytics { export const getModsAnalytics = (): Promise => http.get(`/api/application/plugins/analytics`).then(({ data }) => data); -export const resetCurseForgeKey = (): Promise => - http.delete(`/api/application/plugins/key`).then(() => {}); +export const resetCurseForgeKey = (): Promise => http.delete(`/api/application/plugins/key`).then(() => {}); diff --git a/resources/scripts/api/routes/admin/nodes/getNodes.ts b/resources/scripts/api/routes/admin/nodes/getNodes.ts index 775a203717..ede7d9b827 100644 --- a/resources/scripts/api/routes/admin/nodes/getNodes.ts +++ b/resources/scripts/api/routes/admin/nodes/getNodes.ts @@ -27,6 +27,9 @@ export interface Node { daemonBase: string; deployable: boolean; deployableFree: boolean; + wingsType: string; + wingsVersion: string | null; + wingsDetectedAt: Date | null; createdAt: Date; updatedAt: Date; @@ -62,6 +65,9 @@ export const rawDataToNode = ({ attributes, meta }: FractalResponseData): Node = daemonBase: attributes.daemon_base, deployable: attributes.deployable, deployableFree: attributes.deployable_free, + wingsType: attributes.wings_type ?? 'default', + wingsVersion: attributes.wings_version ?? null, + wingsDetectedAt: attributes.wings_detected_at ? new Date(attributes.wings_detected_at) : null, createdAt: new Date(attributes.created_at), updatedAt: new Date(attributes.updated_at), diff --git a/resources/scripts/api/routes/admin/nodes/wingsRs.ts b/resources/scripts/api/routes/admin/nodes/wingsRs.ts new file mode 100644 index 0000000000..1178d70b0f --- /dev/null +++ b/resources/scripts/api/routes/admin/nodes/wingsRs.ts @@ -0,0 +1,152 @@ +import http from '@/api/http'; + +export interface WingsRsDetectionResult { + detected: boolean; + wings_type: string; + wings_version: string | null; +} + +export interface SystemOverview { + version: string; + rust_version?: string; + build_date?: string; + os: string; + arch: string; + kernel: string; + uptime?: number; + features: string[]; +} + +export interface SystemStats { + cpu: { + used: number; + threads: number; + model: string; + }; + network: { + received_rate: number; + sent_rate: number; + }; + memory: { + used: number; + process: number; + total: number; + }; + disk: { + used: number; + total: number; + read_rate: number; + write_rate: number; + }; +} + +export interface LogFile { + name: string; + size: number; + modified: string; +} + +export const detectWingsRs = (nodeId: number): Promise => { + return http.post(`/api/application/nodes/${nodeId}/wings-rs/detect`).then(({ data }) => data); +}; + +export const getSystemOverview = (nodeId: number): Promise => { + return http.get(`/api/application/nodes/${nodeId}/wings-rs/overview`).then(({ data }) => ({ + version: data?.version ?? 'unknown', + rust_version: data?.rust_version ?? data?.rust, + build_date: data?.build_date ?? data?.build, + os: data?.os ?? data?.container_type ?? 'unknown', + arch: data?.arch ?? data?.architecture ?? 'unknown', + kernel: data?.kernel ?? data?.kernel_version ?? 'unknown', + uptime: data?.uptime !== undefined ? Number(data?.uptime) : undefined, + features: Array.isArray(data?.features) ? data.features : [], + })); +}; + +export const getSystemStats = (nodeId: number): Promise => { + return http.get(`/api/application/nodes/${nodeId}/wings-rs/stats`).then(({ data }) => { + const stats = data?.stats ?? data; + + return { + cpu: { + used: Number(stats?.cpu?.used ?? stats?.cpu_used ?? 0), + threads: Number(stats?.cpu?.threads ?? stats?.cpu_threads ?? 0), + model: stats?.cpu?.model ?? stats?.cpu_model ?? 'Unknown', + }, + network: { + received_rate: Number( + stats?.network?.receiving_rate ?? + stats?.network?.received_rate ?? + stats?.network_receiving_rate ?? + 0, + ), + sent_rate: Number( + stats?.network?.sending_rate ?? stats?.network?.sent_rate ?? stats?.network_sending_rate ?? 0, + ), + }, + memory: { + used: Number(stats?.memory?.used ?? stats?.memory_used ?? 0), + process: Number(stats?.memory?.used_process ?? stats?.memory?.process ?? stats?.memory_process ?? 0), + total: Number(stats?.memory?.total ?? stats?.memory_total ?? 0), + }, + disk: { + used: Number(stats?.disk?.used ?? stats?.disk_used ?? 0), + total: Number(stats?.disk?.total ?? stats?.disk_total ?? 0), + read_rate: Number(stats?.disk?.reading_rate ?? stats?.disk?.read_rate ?? stats?.disk_reading_rate ?? 0), + write_rate: Number( + stats?.disk?.writing_rate ?? stats?.disk?.write_rate ?? stats?.disk_writing_rate ?? 0, + ), + }, + }; + }); +}; + +export const getSystemLogs = (nodeId: number): Promise => { + return http.get(`/api/application/nodes/${nodeId}/wings-rs/logs`).then(({ data }) => { + const items = Array.isArray(data) ? data : data?.log_files ?? data?.files; + + if (!Array.isArray(items)) { + return []; + } + + return items.map((entry: any) => ({ + name: entry?.name ?? entry?.file ?? 'unknown.log', + size: Number(entry?.size ?? 0), + modified: entry?.modified ?? entry?.updated_at ?? '', + })); + }); +}; + +export const getSystemLogContents = (nodeId: number, file: string, lines?: number): Promise => { + return http + .get(`/api/application/nodes/${nodeId}/wings-rs/logs/${file}`, { params: { lines } }) + .then(({ data }) => { + if (Array.isArray(data)) { + return data; + } + + if (Array.isArray(data?.content)) { + return data.content; + } + + if (typeof data?.content === 'string') { + return data.content.split('\n'); + } + + if (typeof data === 'string') { + return data.split('\n'); + } + + return []; + }); +}; + +export interface UpgradeRequest { + url: string; + sha256?: string; + restart_command?: string; +} + +export const upgradeNode = (nodeId: number, data: UpgradeRequest): Promise => { + return http.post(`/api/application/nodes/${nodeId}/wings-rs/upgrade`, data); +}; diff --git a/resources/scripts/api/routes/admin/server.ts b/resources/scripts/api/routes/admin/server.ts index 202ead5604..86523630bd 100644 --- a/resources/scripts/api/routes/admin/server.ts +++ b/resources/scripts/api/routes/admin/server.ts @@ -49,6 +49,7 @@ export interface Server extends Model { allocations: number; backups: number; subusers: number; + subdomains: number | null; }; container: { startup: string | null; diff --git a/resources/scripts/api/routes/admin/servers/createServer.ts b/resources/scripts/api/routes/admin/servers/createServer.ts index 6e76db15d6..153217a214 100644 --- a/resources/scripts/api/routes/admin/servers/createServer.ts +++ b/resources/scripts/api/routes/admin/servers/createServer.ts @@ -23,6 +23,7 @@ export interface CreateServerRequest { backups: number; databases: number; subusers: number; + subdomains: number | null; }; allocation: { @@ -64,6 +65,7 @@ export default (r: CreateServerRequest, include: string[] = []): Promise backups: r.featureLimits.backups, databases: r.featureLimits.databases, subusers: r.featureLimits.subusers, + subdomains: r.featureLimits.subdomains, }, allocation: { diff --git a/resources/scripts/api/routes/admin/servers/getServers.ts b/resources/scripts/api/routes/admin/servers/getServers.ts index 1d1ab128bd..2c152a4ddb 100644 --- a/resources/scripts/api/routes/admin/servers/getServers.ts +++ b/resources/scripts/api/routes/admin/servers/getServers.ts @@ -63,6 +63,7 @@ export interface Server { allocations: number; backups: number; subusers: number; + subdomains: number | null; }; ownerId: number; @@ -114,6 +115,7 @@ export const rawDataToServer = ({ attributes }: FractalResponseData): Server => allocations: attributes.feature_limits.allocations, backups: attributes.feature_limits.backups, subusers: attributes.feature_limits.subusers, + subdomains: attributes.feature_limits.subdomains ?? null, }, ownerId: attributes.owner_id, diff --git a/resources/scripts/api/routes/admin/servers/updateServer.ts b/resources/scripts/api/routes/admin/servers/updateServer.ts index 135bc2da93..0e7b97def2 100644 --- a/resources/scripts/api/routes/admin/servers/updateServer.ts +++ b/resources/scripts/api/routes/admin/servers/updateServer.ts @@ -21,6 +21,7 @@ export interface Values { backups: number; databases: number; subusers: number; + subdomains: number | null; }; renewalDate?: Date | null | undefined; @@ -56,6 +57,7 @@ export default (id: number, server: Partial, include: string[] = []): Pr backups: server.featureLimits?.backups, databases: server.featureLimits?.databases, subusers: server.featureLimits?.subusers, + subdomains: server.featureLimits?.subdomains, }, renewal_date: diff --git a/resources/scripts/api/routes/admin/servers/wingsRs.ts b/resources/scripts/api/routes/admin/servers/wingsRs.ts new file mode 100644 index 0000000000..6a04f23769 --- /dev/null +++ b/resources/scripts/api/routes/admin/servers/wingsRs.ts @@ -0,0 +1,68 @@ +import http from '@/api/http'; + +export interface AdminServerWingsStatus { + supercharged: boolean; + wings_type: string; + wings_version: string | null; +} + +export interface AdminServerSystemStats { + cpu: { used: number; threads: number; model: string }; + network: { receiving_rate: number; sending_rate: number }; + memory: { used: number; used_process: number; total: number }; + disk: { used: number; total: number; reading_rate: number; writing_rate: number }; +} + +export const getAdminServerWingsStatus = (serverId: number): Promise => { + return http.get(`/api/application/servers/${serverId}/wings-rs/status`).then(({ data }) => data); +}; + +export const getAdminServerWingsStats = (serverId: number): Promise => { + return http.get(`/api/application/servers/${serverId}/wings-rs/stats`).then(({ data }) => { + const stats = data?.stats ?? data; + + return { + cpu: { + used: Number(stats?.cpu?.used ?? 0), + threads: Number(stats?.cpu?.threads ?? 0), + model: stats?.cpu?.model ?? 'Unknown', + }, + network: { + receiving_rate: Number(stats?.network?.receiving_rate ?? 0), + sending_rate: Number(stats?.network?.sending_rate ?? 0), + }, + memory: { + used: Number(stats?.memory?.used ?? 0), + used_process: Number(stats?.memory?.used_process ?? 0), + total: Number(stats?.memory?.total ?? 0), + }, + disk: { + used: Number(stats?.disk?.used ?? 0), + total: Number(stats?.disk?.total ?? 0), + reading_rate: Number(stats?.disk?.reading_rate ?? 0), + writing_rate: Number(stats?.disk?.writing_rate ?? 0), + }, + }; + }); +}; + +export const getAdminServerInstallLogs = ( + serverId: number, + lines = 100, +): Promise<{ content: string[]; missing: boolean }> => { + return http + .get(`/api/application/servers/${serverId}/wings-rs/install-logs`, { params: { lines } }) + .then(({ data }) => { + const raw = data?.content; + + if (Array.isArray(raw)) { + return { content: raw, missing: Boolean(data?.missing) }; + } + + if (typeof raw === 'string') { + return { content: raw.length ? raw.split('\n') : [], missing: Boolean(data?.missing) }; + } + + return { content: [], missing: Boolean(data?.missing) }; + }); +}; diff --git a/resources/scripts/api/routes/auth/discord.ts b/resources/scripts/api/routes/auth/discord.ts index ef3ee44eaa..9bd4cae212 100644 --- a/resources/scripts/api/routes/auth/discord.ts +++ b/resources/scripts/api/routes/auth/discord.ts @@ -33,7 +33,9 @@ export const checkUsernameAvailability = (username: string): Promise => { +export const completeDiscordRegistration = ( + data: CompleteDiscordRegistrationData, +): Promise<{ userState: string | null }> => { return new Promise((resolve, reject) => { http.get('/sanctum/csrf-cookie') .then(() => diff --git a/resources/scripts/api/routes/auth/register.ts b/resources/scripts/api/routes/auth/register.ts index 692fba47b0..adaf840697 100644 --- a/resources/scripts/api/routes/auth/register.ts +++ b/resources/scripts/api/routes/auth/register.ts @@ -21,7 +21,13 @@ export const checkUsernameAvailability = (username: string): Promise): Promise => { +export default ({ + username, + email, + password, + password_confirmation, + ...rest +}: LoginData & Record): Promise => { return new Promise((resolve, reject) => { http.get('/sanctum/csrf-cookie') .then(() => diff --git a/resources/scripts/api/routes/server/billing.ts b/resources/scripts/api/routes/server/billing.ts index 00fae6d9d6..c6f5e3b625 100644 --- a/resources/scripts/api/routes/server/billing.ts +++ b/resources/scripts/api/routes/server/billing.ts @@ -29,6 +29,7 @@ export interface PlanChangeResponse { database: number; backup: number; allocation: number; + subdomain?: number | null; }; }; } @@ -82,7 +83,7 @@ export const getBillingCyclesForProduct = (productId: number): Promise => { return new Promise((resolve, reject) => { http.post(`/api/client/servers/${serverUuid}/billing/plans/${productId}/change`, { diff --git a/resources/scripts/api/routes/server/customDomains.ts b/resources/scripts/api/routes/server/customDomains.ts new file mode 100644 index 0000000000..2c68207488 --- /dev/null +++ b/resources/scripts/api/routes/server/customDomains.ts @@ -0,0 +1,75 @@ +import useSWR from 'swr'; +import http from '@/api/http'; +import { ServerContext } from '@/state/server'; + +export interface ServerCustomDomainRecord { + id: number; + domain_id: number; + domain: string; + subdomain: string; + full_domain: string; + port: number; + protocol: 'tcp' | 'udp' | 'both'; + service_tag: string | null; + record_type: 'srv' | 'cname'; + host_record_type: 'A' | 'CNAME' | null; + status: 'pending' | 'active' | 'failed'; + last_error: string | null; + last_synced_at: string | null; +} + +export interface AvailableServerCustomDomain { + id: number; + domain: string; + wildcard_enabled: boolean; + default_service_tag: string | null; + recommended_record_type: 'srv' | 'cname'; + srv_supported: boolean; + allow_record_type_selection: boolean; + forced_record_type: 'srv' | 'cname' | null; + dns_mode: 'minecraft' | 'rust' | 'generic'; + recommendation_notice: string; + connection_hint: string; +} + +export const getServerCustomDomains = () => { + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + + return useSWR( + ['server:custom-domains', uuid], + async () => { + const { data } = await http.get(`/api/client/servers/${uuid}/custom-domains`); + + return data.data || []; + }, + { revalidateOnFocus: false }, + ); +}; + +export const createServerCustomDomain = async ( + uuid: string, + payload: { + domain_id: number; + subdomain: string; + port: number; + protocol: 'tcp' | 'udp' | 'both'; + record_type?: 'srv' | 'cname'; + service_tag?: string; + }, +): Promise => { + await http.post(`/api/client/servers/${uuid}/custom-domains`, payload); +}; + +export const getServerCustomDomainOptions = async (uuid: string): Promise => { + const { data } = await http.get(`/api/client/servers/${uuid}/custom-domains/options`); + + return data.data || []; +}; + +export const deleteServerCustomDomain = async (uuid: string, id: number): Promise => { + await http.delete(`/api/client/servers/${uuid}/custom-domains/${id}`); +}; + +export const syncServerCustomDomains = async (uuid: string): Promise => { + await http.post(`/api/client/servers/${uuid}/custom-domains/sync`); +}; diff --git a/resources/scripts/api/routes/server/files.ts b/resources/scripts/api/routes/server/files.ts index 4ae9582dba..d7cb1aa457 100644 --- a/resources/scripts/api/routes/server/files.ts +++ b/resources/scripts/api/routes/server/files.ts @@ -43,11 +43,7 @@ const copyFile = (uuid: string, location: string): Promise => { }); }; -const getFileContents = ( - server: string, - file: string, - options?: { signal?: AbortSignal }, -): Promise => { +const getFileContents = (server: string, file: string, options?: { signal?: AbortSignal }): Promise => { return http .get(`/api/client/servers/${server}/files/contents`, { params: { file }, @@ -93,14 +89,11 @@ const saveFileContents = async ( ): Promise => { // Use the new endpoint with diff tracking when originalContent is provided if (originalContent !== undefined) { - await http.post( - `/api/client/servers/${uuid}/files/write-with-diff`, - { - file, - content, - original_content: originalContent, - }, - ); + await http.post(`/api/client/servers/${uuid}/files/write-with-diff`, { + file, + content, + original_content: originalContent, + }); } else { // Fallback to the old endpoint for backward compatibility await http.post(`/api/client/servers/${uuid}/files/write`, content, { diff --git a/resources/scripts/api/routes/server/index.ts b/resources/scripts/api/routes/server/index.ts index c4bc26d899..4d8e6f1c9d 100644 --- a/resources/scripts/api/routes/server/index.ts +++ b/resources/scripts/api/routes/server/index.ts @@ -8,6 +8,7 @@ export type ServerStatus = | 'reinstall_failed' | 'suspended' | 'restoring_backup' + | 'offline' | null; const getServer = (uuid: string): Promise<[Server, string[]]> => { diff --git a/resources/scripts/api/routes/server/modpacks.ts b/resources/scripts/api/routes/server/modpacks.ts index 65e091e25c..29e6309794 100644 --- a/resources/scripts/api/routes/server/modpacks.ts +++ b/resources/scripts/api/routes/server/modpacks.ts @@ -6,6 +6,8 @@ export interface ModpackSearchParams { sortOrder?: string; pageSize?: number; index?: number; + gameVersion?: string; + modLoaderType?: number; } export interface ModpackFileParams { diff --git a/resources/scripts/api/routes/server/mods.ts b/resources/scripts/api/routes/server/mods.ts index 63c451ef26..16ea32a171 100644 --- a/resources/scripts/api/routes/server/mods.ts +++ b/resources/scripts/api/routes/server/mods.ts @@ -234,11 +234,7 @@ export const getMod = ( }); }; -export const getModFiles = ( - uuid: string, - modId: number | string, - params: ModFileParams, -): Promise => { +export const getModFiles = (uuid: string, modId: number | string, params: ModFileParams): Promise => { return new Promise((resolve, reject) => { http.get(`/api/client/servers/${uuid}/mods/${modId}/files`, { params }) .then(({ data }) => resolve(data)) diff --git a/resources/scripts/api/routes/server/plugins.ts b/resources/scripts/api/routes/server/plugins.ts index 66c526e0bd..3ebeeeb552 100644 --- a/resources/scripts/api/routes/server/plugins.ts +++ b/resources/scripts/api/routes/server/plugins.ts @@ -35,11 +35,7 @@ const toInstalledAddon = (raw: any): InstalledAddon => ({ modifiedAt: raw?.modified_at ? new Date(raw.modified_at) : null, type: (raw?.type ?? 'mod') as InstalledAddonType, enabled: - typeof raw?.enabled === 'boolean' - ? raw.enabled - : typeof raw?.disabled === 'boolean' - ? !raw.disabled - : false, + typeof raw?.enabled === 'boolean' ? raw.enabled : typeof raw?.disabled === 'boolean' ? !raw.disabled : false, }); export interface InstalledAddonQuery { @@ -50,7 +46,10 @@ export interface InstalledAddonQuery { status?: InstalledStatusFilter; } -export const getInstalledAddons = (uuid: string, query: InstalledAddonQuery): Promise> => +export const getInstalledAddons = ( + uuid: string, + query: InstalledAddonQuery, +): Promise> => http .get(`/api/client/servers/${uuid}/plugins/installed`, { params: { diff --git a/resources/scripts/api/routes/server/wingsRs.ts b/resources/scripts/api/routes/server/wingsRs.ts new file mode 100644 index 0000000000..dab5399f0f --- /dev/null +++ b/resources/scripts/api/routes/server/wingsRs.ts @@ -0,0 +1,139 @@ +import http from '@/api/http'; + +export interface WingsRsStatus { + supercharged: boolean; + wings_type: string; + wings_version: string | null; + features: string[]; +} + +export interface FileFingerprint { + path: string; + algorithm: string; + hash: string; +} + +export interface SearchResult { + path: string; + name: string; + size: number; + modified: string; + is_file: boolean; + mime_type?: string; +} + +export interface CompressRequest { + root: string; + files: string[]; + format: 'tar' | 'tar_gz' | 'tar_xz' | 'tar_bz2' | 'tar_lz4' | 'tar_zstd' | 'zip' | 'seven_zip'; + name?: string; + foreground?: boolean; +} + +export interface CompressResult { + operation_id?: string; + file?: string; +} + +export interface ScriptRequest { + container_image?: string; + entrypoint?: string; + script: string; + environment?: Record; +} + +export type ArchiveFormat = 'tar' | 'tar_gz' | 'tar_xz' | 'tar_bz2' | 'tar_lz4' | 'tar_zstd' | 'zip' | 'seven_zip'; + +export const getWingsRsStatus = (uuid: string): Promise => { + return http.get(`/api/client/servers/${uuid}/wings-rs/status`).then(({ data }) => ({ + supercharged: Boolean(data?.supercharged), + wings_type: data?.wings_type ?? 'default', + wings_version: data?.wings_version ?? null, + features: Array.isArray(data?.features) ? data.features : [], + })); +}; + +export const getFingerprints = ( + uuid: string, + root: string, + files: string[], + algorithm?: string, +): Promise => { + return http + .post(`/api/client/servers/${uuid}/wings-rs/fingerprints`, { root, files, algorithm }) + .then(({ data }) => data); +}; + +export const searchFiles = ( + uuid: string, + params: { + root?: string; + pattern: string; + glob?: boolean; + regex?: boolean; + case_sensitive?: boolean; + }, +): Promise => { + return http.post(`/api/client/servers/${uuid}/wings-rs/search`, params).then(({ data }) => data); +}; + +export const compressAdvanced = (uuid: string, data: CompressRequest): Promise => { + return http + .post(`/api/client/servers/${uuid}/wings-rs/compress`, data, { + timeout: 10000, + timeoutErrorMessage: 'The compression is taking a while. It will complete in the background.', + }) + .then(({ data }) => data); +}; + +export const cancelOperation = (uuid: string, operationId: string): Promise => { + return http.delete(`/api/client/servers/${uuid}/wings-rs/operations/${operationId}`); +}; + +export const runScript = (uuid: string, data: ScriptRequest): Promise => { + return http.post(`/api/client/servers/${uuid}/wings-rs/script`, data); +}; + +export const abortInstall = (uuid: string): Promise => { + return http.post(`/api/client/servers/${uuid}/wings-rs/abort-install`); +}; + +export const getInstallLogs = (uuid: string, lines?: number): Promise => { + return http.get(`/api/client/servers/${uuid}/wings-rs/install-logs`, { params: { lines } }).then(({ data }) => { + if (Array.isArray(data)) { + return data; + } + + if (Array.isArray(data?.content)) { + return data.content; + } + + if (typeof data?.content === 'string') { + return data.content.split('\n'); + } + + if (typeof data === 'string') { + return data.split('\n'); + } + + return []; + }); +}; + +export interface SshInfo { + host: string; + port: number; + username: string; + command?: string; + container_supported: boolean; +} + +export const getSshInfo = (uuid: string): Promise => { + return http.get(`/api/client/servers/${uuid}/wings-rs/ssh`).then(({ data }) => ({ + host: data?.host ?? data?.ip ?? '', + port: Number(data?.port ?? 22), + username: data?.username ?? '', + command: data?.command, + container_supported: Boolean(data?.container_supported ?? data?.shell_available ?? false), + })); +}; diff --git a/resources/scripts/api/server/extensions/discordSrvHelper.ts b/resources/scripts/api/server/extensions/discordSrvHelper.ts new file mode 100644 index 0000000000..0131392e5c --- /dev/null +++ b/resources/scripts/api/server/extensions/discordSrvHelper.ts @@ -0,0 +1,64 @@ +import http from '@/api/http'; + +const base = (uuid: string) => `/api/client/servers/${uuid}/extensions/discordsrv_helper`; + +export interface DiscordSrvHelperStatus { + installed: boolean; + plugin_jar: string | null; + plugin_folder_present: boolean; + token_file_present: boolean; + config_present: boolean; +} + +export interface DiscordSrvHelperHistoryEntry { + id: number; + action: string; + created_at: string; + actor: { id: number; email: string } | null; +} + +export interface DiscordSrvHelperSubuserAccess { + uuid: string; + email: string; + username: string; + disabled: boolean; +} + +export const getDiscordSrvHelperStatus = async (uuid: string): Promise => { + const { data } = await http.get(`${base(uuid)}/status`); + return data; +}; + +export const installDiscordSrv = async (uuid: string, jarUrl?: string): Promise => { + await http.post(`${base(uuid)}/install`, jarUrl ? { jar_url: jarUrl } : {}); +}; + +export const setDiscordSrvToken = async (uuid: string, token: string): Promise => { + await http.post(`${base(uuid)}/token`, { token }); +}; + +export const setDiscordSrvGlobalChannel = async (uuid: string, channelId: string): Promise => { + await http.post(`${base(uuid)}/channel`, { channel_id: channelId }); +}; + +export const getDiscordSrvHistory = async (uuid: string): Promise => { + const { data } = await http.get(`${base(uuid)}/history`); + return data.data || []; +}; + +export const revertDiscordSrvHistory = async (uuid: string, snapshotId: number): Promise => { + await http.post(`${base(uuid)}/history/${snapshotId}/revert`); +}; + +export const getDiscordSrvSubusers = async (uuid: string): Promise => { + const { data } = await http.get(`${base(uuid)}/subusers`); + return data.data || []; +}; + +export const setDiscordSrvSubuserAccess = async ( + uuid: string, + subuserUuid: string, + enabled: boolean, +): Promise => { + await http.post(`${base(uuid)}/subusers/${subuserUuid}`, { enabled }); +}; diff --git a/resources/scripts/api/server/extensions/index.ts b/resources/scripts/api/server/extensions/index.ts new file mode 100644 index 0000000000..e8f170b6b9 --- /dev/null +++ b/resources/scripts/api/server/extensions/index.ts @@ -0,0 +1,20 @@ +import http from '@/api/http'; + +export interface ServerExtension { + id: string; + name: string; + description: string; + icon: string; + version: string; + route: string; +} + +export const getServerExtensions = async (uuid: string): Promise => { + const { data } = await http.get(`/api/client/servers/${uuid}/extensions`); + return data.data; +}; + +export const checkExtensionEnabled = async (uuid: string, extensionId: string): Promise => { + const { data } = await http.get(`/api/client/servers/${uuid}/extensions/${extensionId}`); + return data.enabled; +}; diff --git a/resources/scripts/api/server/extensions/playerManager.ts b/resources/scripts/api/server/extensions/playerManager.ts new file mode 100644 index 0000000000..02a27c8aa8 --- /dev/null +++ b/resources/scripts/api/server/extensions/playerManager.ts @@ -0,0 +1,260 @@ +import http from '@/api/http'; + +const extensionId = 'minecraft_player_manager'; + +const getBasePath = (uuid: string): string => `/api/client/servers/${uuid}/extensions/${extensionId}`; + +export interface OnlinePlayer { + name: string; + uuid?: string; +} + +export interface ServerStatus { + online: boolean; + players: { + online: number; + max: number; + list: OnlinePlayer[]; + }; + version: string; + motd: string; +} + +export interface PlayerEntry { + uuid: string; + name: string; + level?: number; + bypassesPlayerLimit?: boolean; + source?: string; + created?: string; + reason?: string; + expires?: string; +} + +export interface PlayerManagerStatus { + server: ServerStatus; + operators: PlayerEntry[]; + whitelist: PlayerEntry[]; + bannedPlayers: PlayerEntry[]; + bannedIps: { ip: string; reason: string; created: string; source: string; expires: string | null }[]; + whitelistEnabled: boolean; +} + +export const getPlayerManagerStatus = async (uuid: string): Promise => { + const { data } = await http.get(getBasePath(uuid)); + // Handle case where API returns nested data structure + if (data && data.data) { + return data.data; + } + return data; +}; + +export const setWhitelistEnabled = async (uuid: string, enabled: boolean): Promise => { + await http.post(`${getBasePath(uuid)}/whitelist`, { enabled }); +}; + +export const addToWhitelist = async (uuid: string, player: string): Promise => { + await http.put(`${getBasePath(uuid)}/whitelist/${player}`); +}; + +export const removeFromWhitelist = async (uuid: string, player: string): Promise => { + await http.delete(`${getBasePath(uuid)}/whitelist/${player}`); +}; + +export const opPlayer = async (uuid: string, player: string): Promise => { + await http.put(`${getBasePath(uuid)}/op/${player}`); +}; + +export const deopPlayer = async (uuid: string, player: string): Promise => { + await http.delete(`${getBasePath(uuid)}/op/${player}`); +}; + +export const banPlayer = async (uuid: string, player: string, reason: string): Promise => { + await http.put(`${getBasePath(uuid)}/ban/${player}`, { reason }); +}; + +export const unbanPlayer = async (uuid: string, player: string): Promise => { + await http.delete(`${getBasePath(uuid)}/ban/${player}`); +}; + +export const banIp = async (uuid: string, ip: string, reason: string): Promise => { + await http.put(`${getBasePath(uuid)}/ban-ip/${ip}`, { reason }); +}; + +export const unbanIp = async (uuid: string, ip: string): Promise => { + await http.delete(`${getBasePath(uuid)}/ban-ip/${ip}`); +}; + +export const kickPlayer = async (uuid: string, player: string, reason?: string): Promise => { + await http.post(`${getBasePath(uuid)}/kick/${player}`, { reason }); +}; + +export const whisperPlayer = async (uuid: string, player: string, message: string): Promise => { + await http.post(`${getBasePath(uuid)}/whisper/${player}`, { message }); +}; + +export const killPlayer = async (uuid: string, player: string): Promise => { + await http.post(`${getBasePath(uuid)}/kill/${player}`); +}; + +// v1.0.1 - Server Version +export interface ServerVersion { + raw: string; + major: number; + minor: number; + patch: number; + protocol: number; + supportsAttributes: boolean; +} + +export interface ServerVersionResponse { + success: boolean; + version?: ServerVersion; + error?: string; +} + +export const getServerVersion = async (uuid: string): Promise => { + const { data } = await http.get(`${getBasePath(uuid)}/version`); + return data.data || data; +}; + +// v1.0.1 - Player Data Types +export interface ItemEnchantment { + id: string; + name: string; + level: number; + levelRoman: string; +} + +export interface ItemDurability { + current: number; + max: number; + percentage: number; +} + +export interface InventoryItem { + id: string; + displayId: string; + name: string; + slot: number; + count: number; + damage: number; + enchantments: ItemEnchantment[]; + storedEnchantments: ItemEnchantment[]; + customName: string | null; + lore: string[]; + durability: ItemDurability | null; + contents: InventoryItem[]; +} + +export interface PlayerArmor { + helmet: InventoryItem | null; + chestplate: InventoryItem | null; + leggings: InventoryItem | null; + boots: InventoryItem | null; +} + +export interface PlayerLocation { + x: number; + y: number; + z: number; + yaw: number; + pitch: number; + dimension: string; + world: string; +} + +export interface PlayerStats { + health: number; + maxHealth: number; + food: number; + saturation: number; + xpLevel: number; + xpTotal: number; + xpProgress: number; + gamemode: string; + score: number; +} + +export interface PlayerDataResponse { + success: boolean; + player?: { + uuid: string; + name: string; + }; + inventory?: InventoryItem[]; + armor?: PlayerArmor; + offhand?: InventoryItem | null; + enderChest?: InventoryItem[]; + location?: PlayerLocation; + stats?: PlayerStats; + error?: string; + debug?: { + allSlots: { slot: number; id: string }[]; + nbtKeys?: string[]; + }; +} + +export const getPlayerData = async (uuid: string, player: string): Promise => { + const { data } = await http.get(`${getBasePath(uuid)}/player/${player}/data`); + return data.data || data; +}; + +// v1.0.1 - Attributes +export interface AttributeInfo { + id: string; + name: string; + default: number; + min: number; + max: number; + description: string; +} + +export interface AttributeCategory { + category: string; + attributes: AttributeInfo[]; +} + +export interface AttributesResponse { + success: boolean; + attributes?: AttributeCategory[]; + error?: string; +} + +export const getAttributes = async (uuid: string): Promise => { + const { data } = await http.get(`${getBasePath(uuid)}/attributes`); + return data.data || data; +}; + +export interface SetAttributeResponse { + success: boolean; + attribute?: string; + value?: number; + error?: string; +} + +export const setAttribute = async ( + uuid: string, + player: string, + attribute: string, + value: number, +): Promise => { + const { data } = await http.post(`${getBasePath(uuid)}/player/${player}/attribute/${attribute}`, { value }); + return data.data || data; +}; + +export interface ResetAttributeResponse { + success: boolean; + attribute?: string; + defaultValue?: number; + error?: string; +} + +export const resetAttribute = async ( + uuid: string, + player: string, + attribute: string, +): Promise => { + const { data } = await http.delete(`${getBasePath(uuid)}/player/${player}/attribute/${attribute}`); + return data.data || data; +}; diff --git a/resources/scripts/components/App.tsx b/resources/scripts/components/App.tsx index 55eb82b238..bb0d7c5597 100644 --- a/resources/scripts/components/App.tsx +++ b/resources/scripts/components/App.tsx @@ -64,7 +64,9 @@ function App() { state: PterodactylUser.state, useTotp: PterodactylUser.use_totp, emailVerified: Boolean(PterodactylUser.email_verified), - emailVerifiedAt: PterodactylUser.email_verified_at ? new Date(PterodactylUser.email_verified_at) : undefined, + emailVerifiedAt: PterodactylUser.email_verified_at + ? new Date(PterodactylUser.email_verified_at) + : undefined, createdAt: new Date(PterodactylUser.created_at), updatedAt: new Date(PterodactylUser.updated_at), discordLinked: Boolean(PterodactylUser.discord_linked), @@ -102,7 +104,9 @@ function App() { > diff --git a/resources/scripts/components/account/AccountApiContainer.tsx b/resources/scripts/components/account/AccountApiContainer.tsx index 6ad19f1fb7..e4e22064f5 100644 --- a/resources/scripts/components/account/AccountApiContainer.tsx +++ b/resources/scripts/components/account/AccountApiContainer.tsx @@ -5,7 +5,7 @@ import SpinnerOverlay from '@/elements/SpinnerOverlay'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import { getApiKeys, deleteApiKey } from '@/api/routes/account/api-keys'; -import { type ApiKey } from '@definitions/user'; +import { type ApiKey } from '@definitions/account'; import FlashMessageRender from '@/elements/FlashMessageRender'; import { format } from 'date-fns'; import PageContentBlock from '@/elements/PageContentBlock'; diff --git a/resources/scripts/components/account/AccountOverviewContainer.tsx b/resources/scripts/components/account/AccountOverviewContainer.tsx index 73c02fbd4f..e03b01872f 100644 --- a/resources/scripts/components/account/AccountOverviewContainer.tsx +++ b/resources/scripts/components/account/AccountOverviewContainer.tsx @@ -34,9 +34,15 @@ const Container = styled.div` export default () => { const { state } = useLocation(); const user = useStoreState(s => s.user.data!); - const emailEnabled = useStoreState( - s => Boolean(s.everest.data?.email?.enabled ?? s.everest.data?.email?.resend?.enabled ?? s.everest.data?.email?.resend), - ); + const emailEnabled = useStoreState(s => { + const email = s.everest.data?.email; + const resend = email?.resend; + return Boolean( + email?.enabled ?? + (typeof resend !== 'boolean' ? resend?.enabled : undefined) ?? + resend, + ); + }); const discordEnabled = useStoreState(s => Boolean(s.everest.data?.auth?.modules?.discord?.enabled)); return ( @@ -56,9 +62,7 @@ export default () => {
diff --git a/resources/scripts/components/account/AlertHistoryModal.tsx b/resources/scripts/components/account/AlertHistoryModal.tsx index fa9b4d023a..8b4c207061 100644 --- a/resources/scripts/components/account/AlertHistoryModal.tsx +++ b/resources/scripts/components/account/AlertHistoryModal.tsx @@ -63,19 +63,6 @@ export default ({ open, onClose }: Props) => { window.location.reload(); }; - const getTypeColor = (type: string) => { - switch (type) { - case 'success': - return 'text-green-400'; - case 'warning': - return 'text-yellow-400'; - case 'danger': - return 'text-red-400'; - default: - return 'text-blue-400'; - } - }; - const getTypeBadge = (type: string) => { switch (type) { case 'success': diff --git a/resources/scripts/components/account/CredentialsContainer.tsx b/resources/scripts/components/account/CredentialsContainer.tsx index fc5ba8a725..f8006a4be8 100644 --- a/resources/scripts/components/account/CredentialsContainer.tsx +++ b/resources/scripts/components/account/CredentialsContainer.tsx @@ -5,7 +5,7 @@ import SpinnerOverlay from '@/elements/SpinnerOverlay'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import { getApiKeys, deleteApiKey } from '@/api/routes/account/api-keys'; -import { type ApiKey } from '@definitions/user'; +import { type ApiKey } from '@definitions/account'; import FlashMessageRender from '@/elements/FlashMessageRender'; import { format } from 'date-fns'; import PageContentBlock from '@/elements/PageContentBlock'; diff --git a/resources/scripts/components/account/DashboardAlert.tsx b/resources/scripts/components/account/DashboardAlert.tsx index 938349d422..0293898b57 100644 --- a/resources/scripts/components/account/DashboardAlert.tsx +++ b/resources/scripts/components/account/DashboardAlert.tsx @@ -33,7 +33,7 @@ export default () => { const dismissAlert = (alert: ActiveAlert) => dismissAlertForUser(alert, user); // Filter out dismissed alerts and notification-only alerts, then group by position - const visibleAlerts = alerts.filter(a => !isAlertDismissed(a) && a.position !== 'notification'); + const visibleAlerts = alerts.filter(a => !isAlertDismissed(a) && (a.position as string) !== 'notification'); const topCenterAlerts = visibleAlerts.filter(a => a.position === 'top-center'); const slideOutAlerts = visibleAlerts.filter(a => a.position === 'slide-out'); const centerAlerts = visibleAlerts.filter(a => a.position === 'center'); diff --git a/resources/scripts/components/account/DashboardContainer.tsx b/resources/scripts/components/account/DashboardContainer.tsx index 9320b6710f..7878016c04 100644 --- a/resources/scripts/components/account/DashboardContainer.tsx +++ b/resources/scripts/components/account/DashboardContainer.tsx @@ -117,7 +117,9 @@ export default () => { you'd like to run.
-
- IP: {session.ipAddress || 'Unknown'} + IP:{' '} + {session.ipAddress || 'Unknown'} - Location: {session.location || 'Unknown'} + Location:{' '} + {session.location || 'Unknown'}
- First: {formatDate(session.createdAt)} + First:{' '} + {formatDate(session.createdAt)} - Last: {formatDate(session.lastActivityAt)} + Last:{' '} + {formatDate(session.lastActivityAt)}
@@ -193,7 +211,11 @@ export default () => { onClick={() => void handleRevoke(session.id)} disabled={session.isCurrent || !!session.revokedAt} > - {session.revokedAt ? 'Revoked' : session.isCurrent ? 'Current' : 'Log out'} + {session.revokedAt + ? 'Revoked' + : session.isCurrent + ? 'Current' + : 'Log out'}
diff --git a/resources/scripts/components/account/ServerRow.tsx b/resources/scripts/components/account/ServerRow.tsx index 4c1ba8349a..fdf43946a6 100644 --- a/resources/scripts/components/account/ServerRow.tsx +++ b/resources/scripts/components/account/ServerRow.tsx @@ -46,7 +46,11 @@ const UtilBox = ({ rounded?: string; server?: Server; }) => { - const stateLabel = server?.isTransferring ? 'Transferring' : server?.status === 'suspended' ? 'Suspended' : 'Offline'; + const stateLabel = server?.isTransferring + ? 'Transferring' + : server?.status === 'suspended' + ? 'Suspended' + : 'Offline'; return (
- + - {utilised > -1 ? `${utilised === Infinity ? 0 : utilised}%` : stateLabel} + {utilised > -1 ? `${utilised === Infinity ? 0 : utilised}%` : stateLabel}
diff --git a/resources/scripts/components/account/billing/ProductsContainer.tsx b/resources/scripts/components/account/billing/ProductsContainer.tsx index f18e2b2e2e..27f15cf85b 100644 --- a/resources/scripts/components/account/billing/ProductsContainer.tsx +++ b/resources/scripts/components/account/billing/ProductsContainer.tsx @@ -12,6 +12,7 @@ import { faDatabase, faEthernet, faExclamationTriangle, + faGlobe, faHdd, faMemory, faMicrochip, @@ -192,6 +193,18 @@ export default () => { } /> + + {product.limits.subdomain === null + ? 'Unlimited subdomains' + : `${product.limits.subdomain} subdomain${ + product.limits.subdomain === 1 ? '' : 's' + }`} + + } + />
{Number(product.price) > 0 && paidProductsBlocked ? ( diff --git a/resources/scripts/components/account/billing/order/BillingCycleBox.tsx b/resources/scripts/components/account/billing/order/BillingCycleBox.tsx index c3a6989274..7b13dcb918 100644 --- a/resources/scripts/components/account/billing/order/BillingCycleBox.tsx +++ b/resources/scripts/components/account/billing/order/BillingCycleBox.tsx @@ -36,7 +36,7 @@ export default ({ cycle, selected, setSelected }: Props) => {
setSelected(cycle.days)} className={classNames( - 'relative cursor-pointer rounded-lg border-2 p-4 transition-all hover:scale-[1.02]', + 'relative cursor-pointer rounded-lg border-2 p-4 transition-all', isSelected ? 'border-gray-600 hover:border-gray-500' : 'border-gray-700 hover:border-gray-600', )} style={ @@ -45,25 +45,28 @@ export default ({ cycle, selected, setSelected }: Props) => { : { backgroundColor: colors.secondary, borderColor: '#374151' } } > -
-
- -
-
-

- {cycle.days} {cycle.days === 1 ? 'Day' : 'Days'} -

- {cycle.isDefault && ( - - Default - - )} -
- {getDiscountLabel()} +
+ +
+
+

+ {cycle.days} {cycle.days === 1 ? 'Day' : 'Days'} +

+ {cycle.isDefault && ( + + Default + + )} +
+
+ + ${cycle.price.toFixed(2)} +
+ {getDiscountLabel()}
diff --git a/resources/scripts/components/account/billing/order/CheckoutPaymentContainer.tsx b/resources/scripts/components/account/billing/order/CheckoutPaymentContainer.tsx index 7fd1535ad9..f2a18d6dbb 100644 --- a/resources/scripts/components/account/billing/order/CheckoutPaymentContainer.tsx +++ b/resources/scripts/components/account/billing/order/CheckoutPaymentContainer.tsx @@ -106,7 +106,7 @@ export default () => { const initializeStripe = async () => { try { - const intentData = await getStripeIntent(product.id, couponId, checkoutState.selectedBillingDays); + const intentData = await getStripeIntent(product.id, couponId, checkoutState!.selectedBillingDays); setIntent({ id: intentData.id, secret: intentData.secret }); const stripePublicKey = await getStripeKey(product.id); @@ -118,7 +118,7 @@ export default () => { }; initializeStripe(); - }, [product?.id, couponId, couponData?.total, checkoutState.selectedBillingDays]); + }, [product?.id, couponId, couponData?.total, checkoutState?.selectedBillingDays]); if (!checkoutState?.productId) { return ( diff --git a/resources/scripts/components/account/billing/order/CheckoutStepper.tsx b/resources/scripts/components/account/billing/order/CheckoutStepper.tsx index 13c8605d1b..ca6d326232 100644 --- a/resources/scripts/components/account/billing/order/CheckoutStepper.tsx +++ b/resources/scripts/components/account/billing/order/CheckoutStepper.tsx @@ -21,7 +21,10 @@ export default ({ steps }: Props) => { {steps.map((step, stepIdx) => (
  • {step.status === 'complete' ? ( <> diff --git a/resources/scripts/components/account/billing/order/EggBox.tsx b/resources/scripts/components/account/billing/order/EggBox.tsx index f417de94e5..cb92c4aaed 100644 --- a/resources/scripts/components/account/billing/order/EggBox.tsx +++ b/resources/scripts/components/account/billing/order/EggBox.tsx @@ -26,7 +26,7 @@ export default ({ egg, selected, setSelected, onEggChange }: Props) => {
    ; } export default (data: Props) => { @@ -34,7 +39,12 @@ export default (data: Props) => { try { // Create Mollie payment with return URL - const payment = await createMolliePayment(Number(data.product.id), data.couponId, data.billingDays, returnUrl); + const payment = await createMolliePayment( + Number(data.product.id), + data.couponId, + data.billingDays, + returnUrl, + ); // Update payment with order details const variables = Array.from(data.vars, ([key, value]) => ({ key, value })); @@ -47,6 +57,7 @@ export default (data: Props) => { eggId: data.selectedEggId, billingDays: data.billingDays, name: data.serverName, + domainPayload: data.domainPayload, }); // Redirect to Mollie checkout diff --git a/resources/scripts/components/account/billing/order/NodeBox.tsx b/resources/scripts/components/account/billing/order/NodeBox.tsx index 4f13944f03..d589d16ff9 100644 --- a/resources/scripts/components/account/billing/order/NodeBox.tsx +++ b/resources/scripts/components/account/billing/order/NodeBox.tsx @@ -28,7 +28,7 @@ export default ({ node, selected, setSelected, basePrice, billingDays }: Props)
    setSelected(Number(node.id))} className={classNames( - 'relative cursor-pointer rounded-lg border-2 p-4 transition-all hover:scale-[1.02]', + 'relative cursor-pointer rounded-lg border-2 p-4 transition-all', isSelected ? 'border-gray-600 hover:border-gray-500' : 'border-gray-700 hover:border-gray-600', )} style={ diff --git a/resources/scripts/components/account/billing/order/OrderContainer.tsx b/resources/scripts/components/account/billing/order/OrderContainer.tsx index f71a97c97c..ad9de44cc4 100644 --- a/resources/scripts/components/account/billing/order/OrderContainer.tsx +++ b/resources/scripts/components/account/billing/order/OrderContainer.tsx @@ -15,7 +15,8 @@ import useFlash from '@/plugins/useFlash'; import { EggVariable } from '@definitions/server'; import { Button } from '@/elements/button'; import FlashMessageRender from '@/elements/FlashMessageRender'; -import { Product, type Node } from '@definitions/account/billing'; +import { Product, StripeIntent, type Node } from '@definitions/account/billing'; +import { Stripe } from '@stripe/stripe-js'; import { getProduct, getProductVariables, @@ -27,6 +28,11 @@ import { } from '@/api/routes/account/billing/products'; import AdminCheckbox from '@/elements/AdminCheckbox'; import { ValidateCouponResponse } from '@/api/routes/account/billing/coupons'; +import { AvailableCustomDomain, getAvailableCustomDomains } from '@/api/routes/account/billing/customDomains'; +import { processUnpaidOrder } from '@/api/routes/account/billing/orders/process'; +import { getStripeIntent, getStripeKey } from '@/api/routes/account/billing/orders/stripe'; +import { loadStripeOnce } from '@/lib/stripe'; +import PaymentMethodSelector from '@account/billing/order/PaymentMethodSelector'; import classNames from 'classnames'; const getResponseStatus = (reason: unknown): number | undefined => { @@ -58,10 +64,28 @@ export default () => { const [serverName, setServerName] = useState(''); const [serverNameTouched, setServerNameTouched] = useState(false); const [legalAgreed, setLegalAgreed] = useState(false); + const [intent, setIntent] = useState(null); + const [stripe, setStripe] = useState(null); const hasValidSelectedNode = Number.isInteger(selectedNode) && selectedNode > 0; const hasEditableVariables = eggs?.some(v => v.isEditable) ?? false; + const [_customDomainOptions, setCustomDomainOptions] = useState([]); + const [domainMappings, _setDomainMappings] = useState< + Array<{ + domain_id: number; + domain: string; + subdomain: string; + record_type: 'srv' | 'cname'; + }> + >([]); + const [selectedDomainId, setSelectedDomainId] = useState(0); + const [_mappingSubdomain, _setMappingSubdomain] = useState(''); + const [_mappingRecordType, setMappingRecordType] = useState<'srv' | 'cname'>('cname'); + + // Wizard step state + const [_currentStep, _setCurrentStep] = useState(1); + const { colors } = useStoreState(state => state.theme.data!); // Auto-generate server name @@ -85,6 +109,9 @@ export default () => { return selectedCycle ? selectedCycle.price : product?.price ?? 0; }; + const calculatedOrderTotal = couponData ? couponData.total : getCurrentPrice(); + const totalIsFree = calculatedOrderTotal === 0; + const pricingComplete = hasValidSelectedNode && !!selectedBillingDays; const softwareComplete = availableEggs.length > 0 && selectedEggId !== undefined; const configurationComplete = serverName.trim() !== ''; @@ -93,6 +120,47 @@ export default () => { const handleCouponApplied = (data: ValidateCouponResponse | null, status: 'applied' | 'removed' | 'invalid') => { if (status === 'invalid') return; setCouponData(data); + + // Only regenerate intent if the final total is not zero and using Stripe + if (product && product.price !== 0 && billing.processors?.stripe?.available) { + const finalTotal = data ? data.total : product.price; + + // If coupon makes it free, don't fetch intent + if (finalTotal === 0) { + setIntent(null); + } else { + // Regenerate intent with new amount for paid products + getStripeIntent(Number(params.id), data?.coupon.id) + .then(intentData => setIntent({ id: intentData.id, secret: intentData.secret })) + .catch((error: any) => console.error('Error updating payment intent:', error)); + } + } + }; + + const getDomainPayload = () => + domainMappings.map(mapping => ({ + domain_id: mapping.domain_id, + subdomain: mapping.subdomain, + record_type: mapping.record_type, + })); + + const createFree = () => { + if (product && serverName.trim()) { + const variables = Array.from(vars, ([key, value]) => ({ key, value })); + processUnpaidOrder( + product.id, + selectedNode, + undefined, + variables, + undefined, + couponData?.coupon.id, + selectedEggId, + serverName.trim(), + getDomainPayload(), + ) + .then(() => navigate('/')) + .catch((error: any) => clearAndAddHttpError({ key: 'account:billing:order', error })); + } }; useEffect(() => { @@ -139,17 +207,18 @@ export default () => { throw new Error(message, { cause: result.reason }); }); - if (removedMissingEggs) { - addFlash({ - key: 'account:billing:order', - type: 'warning', - message: 'Some server software options are no longer available and were removed from selection.', - }); - } + if (removedMissingEggs) { + addFlash({ + key: 'account:billing:order', + type: 'warning', + message: + 'Some server software options are no longer available and were removed from selection.', + }); + } setAvailableEggs(available); if (available.length > 0) { - setSelectedEggId(available[0].id); + setSelectedEggId(available[0]!.id); } else { // Clear selections and variables when no eggs remain. setSelectedEggId(undefined); @@ -159,9 +228,41 @@ export default () => { // Fetch nodes const nodesData = await getViableNodes(productData.id); setNodes(nodesData); - const firstNodeId = nodesData.length > 0 ? Number(nodesData[0].id) : 0; + const firstNodeId = Number(nodesData.at(0)?.id ?? 0); setSelectedNode(Number.isInteger(firstNodeId) && firstNodeId > 0 ? firstNodeId : 0); + setSelectedNode(Number(nodesData[0]?.id) ?? 0); + + const domainsData = await getAvailableCustomDomains(allowedEggs[0]); + setCustomDomainOptions(domainsData); + const firstDomain = domainsData[0]; + if (firstDomain) { + setSelectedDomainId(firstDomain.id); + setMappingRecordType(firstDomain.recommended_record_type); + } + if (productData.price !== 0) { + // Check which processors are available and fetch resources accordingly + const stripeAvailable = billing.processors?.stripe?.available ?? false; + + // Fetch Stripe resources if Stripe is available + if (stripeAvailable) { + try { + // Fetch payment intent + const intentData = await getStripeIntent(Number(params.id)); + setIntent({ id: intentData.id, secret: intentData.secret }); + + // Fetch Stripe public key and initialize Stripe + const stripePublicKey = await getStripeKey(Number(params.id)); + const stripeInstance = await loadStripeOnce(stripePublicKey.key); + setStripe(stripeInstance); + } catch (error) { + console.error('Error initializing Stripe:', error); + } + } + + // Mollie doesn't need pre-initialization like Stripe + // Payment is created when user clicks the button + } } catch (error: unknown) { console.error('Error fetching billing order data:', error); if (error instanceof Error && error.message) { @@ -186,6 +287,31 @@ export default () => { .catch(error => console.error(error)); }, [product, selectedEggId]); + useEffect(() => { + if (!selectedEggId) { + return; + } + + getAvailableCustomDomains(selectedEggId) + .then(domains => { + setCustomDomainOptions(domains); + + const currentlySelected = domains.find(option => option.id === selectedDomainId); + const nextSelected = currentlySelected ?? domains[0]; + + if (nextSelected) { + if (!currentlySelected) { + setSelectedDomainId(nextSelected.id); + } + + setMappingRecordType(nextSelected.recommended_record_type); + } else { + setSelectedDomainId(0); + } + }) + .catch(error => console.error(error)); + }, [selectedEggId]); + // Auto-generate server name when selections change useEffect(() => { if (!serverNameTouched && product && selectedNode && selectedEggId) { @@ -240,7 +366,9 @@ export default () => {

    {(!nodes || nodes.length < 1) && ( - No nodes are available for this product. Please contact support. + + No nodes are available for this product. Please contact support. + )}
    {nodes?.map(node => ( @@ -303,10 +431,7 @@ export default () => { Name your server and set any required configuration values for your selected software.

    -
    +

    Server Name

    { required maxLength={191} aria-invalid={serverNameTouched && !serverName.trim()} - aria-describedby={serverNameTouched && !serverName.trim() ? 'server-name-error' : undefined} + aria-describedby={ + serverNameTouched && !serverName.trim() ? 'server-name-error' : undefined + } className={classNames( 'w-full rounded-lg border-2 px-4 py-3 text-sm transition-all', 'text-gray-200 placeholder-gray-500', 'focus:outline-none focus:ring-2 focus:ring-primary/20', { 'border-gray-600': !serverNameTouched, - 'border-green-500 focus:border-green-500': serverNameTouched && serverName.trim(), + 'border-green-500 focus:border-green-500': + serverNameTouched && serverName.trim(), 'border-red-500 focus:border-red-500': serverNameTouched && !serverName.trim(), }, )} @@ -364,10 +492,7 @@ export default () => {

    -
    +
    {product.icon && ( @@ -376,7 +501,8 @@ export default () => {

    {product.name}

    - {selectedBillingDays} {selectedBillingDays === 1 ? 'day' : 'days'} billing cycle + {selectedBillingDays} {selectedBillingDays === 1 ? 'day' : 'days'} billing + cycle

    @@ -461,6 +587,30 @@ export default () => {
    + {totalIsFree ? ( + + ) : ( + + )} +
    Ensure all required fields are filled before continuing to payment. diff --git a/resources/scripts/components/account/billing/order/PayPalPaymentButton.tsx b/resources/scripts/components/account/billing/order/PayPalPaymentButton.tsx index cfd4298062..80757668ce 100644 --- a/resources/scripts/components/account/billing/order/PayPalPaymentButton.tsx +++ b/resources/scripts/components/account/billing/order/PayPalPaymentButton.tsx @@ -14,6 +14,11 @@ interface Props { billingDays: number; selectedEggId?: number; serverName: string; + domainPayload?: Array<{ + domain_id: number; + subdomain: string; + record_type?: 'srv' | 'cname'; + }>; } export default (data: Props) => { @@ -63,6 +68,7 @@ export default (data: Props) => { eggId: data.selectedEggId, billingDays: data.billingDays, name: data.serverName, + domainPayload: data.domainPayload, }); console.log('[PayPal] Order updated successfully'); diff --git a/resources/scripts/components/account/billing/order/PaymentButton.tsx b/resources/scripts/components/account/billing/order/PaymentButton.tsx index 95ad6bd9e7..6305537293 100644 --- a/resources/scripts/components/account/billing/order/PaymentButton.tsx +++ b/resources/scripts/components/account/billing/order/PaymentButton.tsx @@ -16,6 +16,11 @@ interface Props { billingDays: number; selectedEggId?: number; serverName: string; + domainPayload?: Array<{ + domain_id: number; + subdomain: string; + record_type?: 'srv' | 'cname'; + }>; } export default (data: Props) => { @@ -41,6 +46,7 @@ export default (data: Props) => { coupon_id: data.couponId, egg_id: data.selectedEggId, name: data.serverName, + domain_payload: data.domainPayload, billing_days: data.billingDays, }) .then(() => { diff --git a/resources/scripts/components/account/billing/order/PaymentMethodSelector.tsx b/resources/scripts/components/account/billing/order/PaymentMethodSelector.tsx index 098492257b..b5ba074a4c 100644 --- a/resources/scripts/components/account/billing/order/PaymentMethodSelector.tsx +++ b/resources/scripts/components/account/billing/order/PaymentMethodSelector.tsx @@ -22,6 +22,11 @@ interface Props { billingDays: number; selectedEggId?: number; serverName: string; + domainPayload?: Array<{ + domain_id: number; + subdomain: string; + record_type?: 'srv' | 'cname'; + }>; } type PaymentMethod = 'stripe' | 'mollie' | 'paypal'; @@ -32,15 +37,15 @@ export default (props: Props) => { const configuredProcessors: Array<{ method: PaymentMethod; available: boolean }> = [ { - method: 'stripe', + method: 'stripe' as const, available: billing.processors?.stripe?.available ?? false, }, { - method: 'mollie', + method: 'mollie' as const, available: billing.processors?.mollie?.available ?? false, }, { - method: 'paypal', + method: 'paypal' as const, available: billing.processors?.paypal?.available ?? false, }, ].filter(processor => { @@ -253,7 +258,6 @@ export default (props: Props) => { {/* Render the selected payment method */} {selectedMethod === 'stripe' && props.intent && props.stripe ? (
    - {/* @ts-expect-error this is fine, stripe library is just weird */} { billingDays={props.billingDays} selectedEggId={props.selectedEggId} serverName={props.serverName} + domainPayload={props.domainPayload} />
    @@ -277,6 +282,7 @@ export default (props: Props) => { billingDays={props.billingDays} selectedEggId={props.selectedEggId} serverName={props.serverName} + domainPayload={props.domainPayload} />
    ) : selectedMethod === 'paypal' ? ( @@ -289,6 +295,7 @@ export default (props: Props) => { billingDays={props.billingDays} selectedEggId={props.selectedEggId} serverName={props.serverName} + domainPayload={props.domainPayload} />
    ) : null} diff --git a/resources/scripts/components/account/billing/order/SubtotalCard.tsx b/resources/scripts/components/account/billing/order/SubtotalCard.tsx index 6243070157..920e022e3a 100644 --- a/resources/scripts/components/account/billing/order/SubtotalCard.tsx +++ b/resources/scripts/components/account/billing/order/SubtotalCard.tsx @@ -277,14 +277,14 @@ export default ({ aria-hidden={true} /> - {showCoupon && onCouponApplied && ( -
    - - + {showCoupon && onCouponApplied && ( +
    + + +
    + )}
    )}
    - )} -
    ); }; diff --git a/resources/scripts/components/account/billing/orders/OrdersContainer.tsx b/resources/scripts/components/account/billing/orders/OrdersContainer.tsx index e8d5a4651a..778679abbd 100644 --- a/resources/scripts/components/account/billing/orders/OrdersContainer.tsx +++ b/resources/scripts/components/account/billing/orders/OrdersContainer.tsx @@ -28,7 +28,7 @@ import OrderInspectorModal from '@/components/elements/OrderInspectorModal'; import { Order } from '@definitions/account/billing/models'; import tw from 'twin.macro'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faSearch, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { faTimes } from '@fortawesome/free-solid-svg-icons'; import Input from '@/elements/Input'; import InputSpinner from '@/elements/InputSpinner'; import debounce from 'debounce'; diff --git a/resources/scripts/components/account/donations/DonationHistoryContainer.tsx b/resources/scripts/components/account/donations/DonationHistoryContainer.tsx index 96bb0acb39..6aae287421 100644 --- a/resources/scripts/components/account/donations/DonationHistoryContainer.tsx +++ b/resources/scripts/components/account/donations/DonationHistoryContainer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import tw from 'twin.macro'; import PageContentBlock from '@/elements/PageContentBlock'; import FlashMessageRender from '@/elements/FlashMessageRender'; diff --git a/resources/scripts/components/account/donations/DonationPaymentForm.tsx b/resources/scripts/components/account/donations/DonationPaymentForm.tsx index b0e5c7ac7d..9ea29f03f3 100644 --- a/resources/scripts/components/account/donations/DonationPaymentForm.tsx +++ b/resources/scripts/components/account/donations/DonationPaymentForm.tsx @@ -1,4 +1,4 @@ -import React, { FormEvent, useState } from 'react'; +import { FormEvent, useState } from 'react'; import useFlash from '@/plugins/useFlash'; import { Button } from '@/elements/button'; import FlashMessageRender from '@/elements/FlashMessageRender'; @@ -9,7 +9,6 @@ import { useNavigate } from 'react-router-dom'; import tw from 'twin.macro'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faHeart, faLock } from '@fortawesome/free-solid-svg-icons'; -import { useStoreState } from '@/state/hooks'; interface Props { intentId: string; @@ -20,7 +19,6 @@ export default ({ intentId, amount }: Props) => { const stripe = useStripe(); const elements = useElements(); const navigate = useNavigate(); - const { colors } = useStoreState(state => state.theme.data!); const [loading, setLoading] = useState(false); const { clearFlashes, clearAndAddHttpError, addFlash } = useFlash(); diff --git a/resources/scripts/components/account/forms/DiscordLinkForm.tsx b/resources/scripts/components/account/forms/DiscordLinkForm.tsx index 92bf517143..23afbf2ed2 100644 --- a/resources/scripts/components/account/forms/DiscordLinkForm.tsx +++ b/resources/scripts/components/account/forms/DiscordLinkForm.tsx @@ -29,7 +29,11 @@ export default () => { }; const handleUnlink = () => { - if (!confirm('Are you sure you want to unlink your Discord account? You will no longer be able to log in via Discord SSO.')) { + if ( + !confirm( + 'Are you sure you want to unlink your Discord account? You will no longer be able to log in via Discord SSO.', + ) + ) { return; } @@ -39,7 +43,12 @@ export default () => { unlinkDiscordAccount() .then(() => { updateUserData({ discordLinked: false }); - addFlash({ key: 'account:discord', type: 'success', title: 'Success', message: 'Your Discord account has been unlinked.' }); + addFlash({ + key: 'account:discord', + type: 'success', + title: 'Success', + message: 'Your Discord account has been unlinked.', + }); setLoading(false); }) .catch(error => { diff --git a/resources/scripts/components/account/groups/ServerGroupDialog.tsx b/resources/scripts/components/account/groups/ServerGroupDialog.tsx index 1393e485f1..9dd34cf52a 100644 --- a/resources/scripts/components/account/groups/ServerGroupDialog.tsx +++ b/resources/scripts/components/account/groups/ServerGroupDialog.tsx @@ -63,7 +63,9 @@ export default ({ open, setOpen, groups, setGroups }: Props) => { setOpen({ open: 'none' })} title={'Add group to server'}> {groups ? ( -
    +
    {groups?.map(group => ( {group.name} @@ -98,7 +100,9 @@ export default ({ open, setOpen, groups, setGroups }: Props) => {
    {groups ? ( -
    +
    {groups?.map(group => (
    { return ( <> - + 1 && (
    - {!isFirstPage && pages[0] > 1 && ( + {!isFirstPage && pages[0]! > 1 && ( handlePageClick(1)} @@ -193,7 +193,7 @@ export default ({ modpacks, loading, pagination, onModpackClick, onPageChange }: {i} ))} - {!isLastPage && pages[pages.length - 1] < totalPages && ( + {!isLastPage && pages[pages.length - 1]! < totalPages && ( handlePageClick(totalPages)} diff --git a/resources/scripts/components/admin/AdminIndicators.tsx b/resources/scripts/components/admin/AdminIndicators.tsx index 3157143eb1..e2cdadcc83 100644 --- a/resources/scripts/components/admin/AdminIndicators.tsx +++ b/resources/scripts/components/admin/AdminIndicators.tsx @@ -21,13 +21,14 @@ const Indicator = ({ text, icon }: Props) => { }; export default () => { - const settings = useStoreState(state => state.settings.data!); const everest = useStoreState(state => state.everest.data!); return ( )} {data && data.items.length > 0 && ( - setCurrentPage(page)} - /> + setCurrentPage(page)} /> )} ); diff --git a/resources/scripts/components/admin/general/overview/OverviewContainer.tsx b/resources/scripts/components/admin/general/overview/OverviewContainer.tsx index 8e3d8e38e0..918ef1fde3 100644 --- a/resources/scripts/components/admin/general/overview/OverviewContainer.tsx +++ b/resources/scripts/components/admin/general/overview/OverviewContainer.tsx @@ -139,9 +139,7 @@ export default () => { icon={faUserPlus} link={'/admin/auth'} title={'Allow email registration'} - description={ - 'Enabling email registration allows users to signup via the login page.' - } + description={'Enabling email registration allows users to signup via the login page.'} /> )} {metricData && ( diff --git a/resources/scripts/components/admin/management/nodes/NodeLogsContainer.tsx b/resources/scripts/components/admin/management/nodes/NodeLogsContainer.tsx new file mode 100644 index 0000000000..85189ec16d --- /dev/null +++ b/resources/scripts/components/admin/management/nodes/NodeLogsContainer.tsx @@ -0,0 +1,148 @@ +import { useEffect, useState } from 'react'; +import tw from 'twin.macro'; +import AdminBox from '@/elements/AdminBox'; +import SpinnerOverlay from '@/elements/SpinnerOverlay'; +import { Context } from '@admin/management/nodes/NodeRouter'; +import { getSystemLogs, getSystemLogContents, LogFile } from '@/api/routes/admin/nodes/wingsRs'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faFileAlt, faArrowLeft, faSync } from '@fortawesome/free-solid-svg-icons'; +import useFlash from '@/plugins/useFlash'; +import { Button } from '@/elements/button'; + +const stripAnsi = (input: string): string => { + return input.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, ''); +}; + +export default () => { + const { clearFlashes, addError } = useFlash(); + const [loading, setLoading] = useState(true); + const [logFiles, setLogFiles] = useState([]); + const [selectedLog, setSelectedLog] = useState(null); + const [logContents, setLogContents] = useState([]); + const [logLoading, setLogLoading] = useState(false); + + const node = Context.useStoreState(state => state.node); + + if (!node) return null; + + useEffect(() => { + clearFlashes('node:logs'); + getSystemLogs(node.id) + .then(data => { + setLogFiles(data); + setLoading(false); + }) + .catch(error => { + console.error(error); + addError({ key: 'node:logs', message: 'Failed to load log files.' }); + setLoading(false); + }); + }, []); + + const openLog = (file: string) => { + setSelectedLog(file); + setLogLoading(true); + getSystemLogContents(node.id, file, 200) + .then(lines => { + setLogContents(lines); + setLogLoading(false); + }) + .catch(error => { + console.error(error); + addError({ key: 'node:logs', message: `Failed to load log: ${file}` }); + setLogLoading(false); + }); + }; + + const refreshLog = () => { + if (selectedLog) openLog(selectedLog); + }; + + const formatSize = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + }; + + if (selectedLog) { + return ( + + + +
    + } + css={tw`relative`} + > + +
    + {logContents.length === 0 ? ( +

    No log entries found.

    + ) : ( + logContents.map((line, i) => ( +
    + {i + 1} + {stripAnsi(line)} +
    + )) + )} +
    + + ); + } + + return ( + + + {logFiles.length === 0 ? ( +

    No log files available.

    + ) : ( +
    + {logFiles.map(file => ( +
    openLog(file.name)} + className={ + 'flex cursor-pointer items-center justify-between rounded bg-black/30 p-3 transition hover:bg-black/50' + } + > +
    + +
    +

    {file.name}

    +

    + {formatSize(file.size)} + {file.modified && ` · Modified ${new Date(file.modified).toLocaleString()}`} +

    +
    +
    + +
    + ))} +
    + )} +
    + ); +}; diff --git a/resources/scripts/components/admin/management/nodes/NodeRouter.tsx b/resources/scripts/components/admin/management/nodes/NodeRouter.tsx index 106396198d..e6a1ee4c7d 100644 --- a/resources/scripts/components/admin/management/nodes/NodeRouter.tsx +++ b/resources/scripts/components/admin/management/nodes/NodeRouter.tsx @@ -15,9 +15,10 @@ import NodeAboutContainer from '@admin/management/nodes/NodeAboutContainer'; import NodeConfigurationContainer from '@admin/management/nodes/NodeConfigurationContainer'; import NodeAllocationContainer from '@admin/management/nodes/NodeAllocationContainer'; import NodeServers from '@admin/management/nodes/NodeServers'; +import NodeWingsRsContainer from '@admin/management/nodes/NodeWingsRsContainer'; import type { ApplicationStore } from '@/state'; import NodeStatus from './NodeStatus'; -import { CodeIcon, OfficeBuildingIcon, ServerIcon, WifiIcon } from '@heroicons/react/outline'; +import { CodeIcon, LightningBoltIcon, OfficeBuildingIcon, ServerIcon, WifiIcon } from '@heroicons/react/outline'; import { CogIcon } from '@heroicons/react/solid'; interface ctx { @@ -105,6 +106,10 @@ const NodeRouter = () => { + + + + @@ -113,6 +118,7 @@ const NodeRouter = () => { } /> } /> } /> + } /> ); diff --git a/resources/scripts/components/admin/management/nodes/NodeStatsContainer.tsx b/resources/scripts/components/admin/management/nodes/NodeStatsContainer.tsx new file mode 100644 index 0000000000..7073c5aaca --- /dev/null +++ b/resources/scripts/components/admin/management/nodes/NodeStatsContainer.tsx @@ -0,0 +1,168 @@ +import { useEffect, useState, useRef } from 'react'; +import tw from 'twin.macro'; +import AdminBox from '@/elements/AdminBox'; +import SpinnerOverlay from '@/elements/SpinnerOverlay'; +import { Context } from '@admin/management/nodes/NodeRouter'; +import { getSystemStats, SystemStats } from '@/api/routes/admin/nodes/wingsRs'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faMicrochip, + faMemory, + faHdd, + faArrowUp, + faArrowDown, + faSync, + faBoltLightning, +} from '@fortawesome/free-solid-svg-icons'; +import type { IconDefinition } from '@fortawesome/free-solid-svg-icons'; +import useFlash from '@/plugins/useFlash'; + +const toNumber = (value: unknown, fallback = 0): number => { + const number = Number(value); + + return Number.isFinite(number) ? number : fallback; +}; + +const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +}; + +const formatRate = (bytesPerSec: number): string => { + return formatBytes(bytesPerSec) + '/s'; +}; + +const StatCard = ({ + icon, + title, + value, + subtitle, + large, +}: { + icon: IconDefinition; + title: string; + value: string; + subtitle?: string; + large?: boolean; +}) => ( +
    +
    +
    +
    +
    + +
    +
    +
    +

    {title}

    +

    {value}

    + {subtitle &&

    {subtitle}

    } +
    +
    +
    +
    +); + +export default () => { + const { clearFlashes, addError } = useFlash(); + const [loading, setLoading] = useState(true); + const [stats, setStats] = useState(null); + const [autoRefresh, setAutoRefresh] = useState(true); + const intervalRef = useRef(null); + + const node = Context.useStoreState(state => state.node); + + if (!node) return null; + + const fetchStats = () => { + clearFlashes('node:stats'); + getSystemStats(node.id) + .then(data => { + setStats(data); + setLoading(false); + }) + .catch(error => { + console.error(error); + addError({ key: 'node:stats', message: 'Failed to load system stats.' }); + setLoading(false); + }); + }; + + useEffect(() => { + fetchStats(); + }, []); + + useEffect(() => { + if (autoRefresh) { + intervalRef.current = setInterval(fetchStats, 5000); + } else if (intervalRef.current) { + clearInterval(intervalRef.current); + } + + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [autoRefresh]); + + return ( + setAutoRefresh(!autoRefresh)} + css={tw`ml-auto text-sm text-neutral-300 hover:text-neutral-100`} + > + + {autoRefresh ? 'Auto-refreshing' : 'Paused'} + + } + css={tw`relative`} + > + + {stats && ( +
    + + + + + +
    + )} +
    + ); +}; diff --git a/resources/scripts/components/admin/management/nodes/NodeWingsRsContainer.tsx b/resources/scripts/components/admin/management/nodes/NodeWingsRsContainer.tsx new file mode 100644 index 0000000000..d5cd65254d --- /dev/null +++ b/resources/scripts/components/admin/management/nodes/NodeWingsRsContainer.tsx @@ -0,0 +1,215 @@ +import { useEffect, useState } from 'react'; +import tw from 'twin.macro'; +import AdminBox from '@/elements/AdminBox'; +import SpinnerOverlay from '@/elements/SpinnerOverlay'; +import { Context } from '@admin/management/nodes/NodeRouter'; +import { + detectWingsRs, + getSystemOverview, + SystemOverview, + WingsRsDetectionResult, +} from '@/api/routes/admin/nodes/wingsRs'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faBoltLightning, faRocket, faCheck, faTimes } from '@fortawesome/free-solid-svg-icons'; +import useFlash from '@/plugins/useFlash'; +import { Button } from '@/elements/button'; +import NodeStatsContainer from '@admin/management/nodes/NodeStatsContainer'; +import NodeLogsContainer from '@admin/management/nodes/NodeLogsContainer'; + +const Code = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +export default () => { + const { clearFlashes, addError, addFlash } = useFlash(); + const [detecting, setDetecting] = useState(false); + const [overview, setOverview] = useState(null); + const [_overviewLoading, setOverviewLoading] = useState(true); + + const node = Context.useStoreState(state => state.node); + const setNode = Context.useStoreActions(actions => actions.setNode); + + if (!node) return null; + + const isSupercharged = node.wingsType === 'wings-rs'; + + useEffect(() => { + if (isSupercharged) { + clearFlashes('node:wingsrs'); + getSystemOverview(node.id) + .then(data => { + setOverview(data); + setOverviewLoading(false); + }) + .catch(() => { + setOverviewLoading(false); + }); + } else { + setOverviewLoading(false); + } + }, [isSupercharged]); + + const handleDetect = () => { + setDetecting(true); + clearFlashes('node:wingsrs'); + detectWingsRs(node.id) + .then((result: WingsRsDetectionResult) => { + if (result.detected) { + addFlash({ + key: 'node:wingsrs', + type: 'success', + message: `Wings-RS detected! Version: ${result.wings_version}`, + }); + // Update node in context + setNode({ + ...node, + wingsType: result.wings_type, + wingsVersion: result.wings_version, + wingsDetectedAt: new Date(), + }); + } else { + addFlash({ + key: 'node:wingsrs', + type: 'info', + message: 'This node is running standard Wings. Wings-RS features are not available.', + }); + } + setDetecting(false); + }) + .catch(error => { + console.error(error); + addError({ key: 'node:wingsrs', message: 'Failed to detect Wings type.' }); + setDetecting(false); + }); + }; + + return ( +
    + + + {detecting ? 'Detecting...' : 'Re-detect'} + + } + css={tw`relative`} + > + +
    +
    + + + {isSupercharged + ? 'This node is running Wings-RS (Supercharged)' + : 'This node is running standard Wings'} + +
    + + {isSupercharged && ( + + + + + + + + + + + {node.wingsDetectedAt && ( + + + + + )} + +
    Wings Type + {node.wingsType} +
    Version + {node.wingsVersion || 'Unknown'} +
    Detected At + {new Date(node.wingsDetectedAt).toLocaleString()} +
    + )} + + {isSupercharged && overview && ( + <> +
    + + + + + + + + + + + + + + + + + + + {overview.features.length > 0 && ( + + + + + )} + +
    Rust Version + {overview.rust_version || 'N/A'} +
    Build Date + {overview.build_date || 'N/A'} +
    Kernel + {overview.kernel} +
    Uptime + + {typeof overview.uptime === 'number' + ? `${Math.floor(overview.uptime / 3600)}h ${Math.floor( + (overview.uptime % 3600) / 60, + )}m` + : 'N/A'} + +
    Features +
    + {overview.features.map(feature => ( + + {feature} + + ))} +
    +
    + + )} + + {!isSupercharged && ( +

    + Click "Re-detect" to check if this node has been upgraded to Wings-RS. Wings-RS + enables supercharged features like real-time stats, log viewing, advanced file operations, + and more. +

    + )} +
    +
    + + {isSupercharged && } + {isSupercharged && } +
    + ); +}; diff --git a/resources/scripts/components/admin/management/nodes/allocations/CreateAllocationForm.tsx b/resources/scripts/components/admin/management/nodes/allocations/CreateAllocationForm.tsx index 119e0ceecc..ecf572aabc 100644 --- a/resources/scripts/components/admin/management/nodes/allocations/CreateAllocationForm.tsx +++ b/resources/scripts/components/admin/management/nodes/allocations/CreateAllocationForm.tsx @@ -45,7 +45,7 @@ function CreateAllocationForm({ nodeId }: { nodeId: number }) { return inputValue.match(/^([0-9a-f.:/]+)$/) !== null; }; - const submit = ({ ips, startPort, endPort, alias }: Values, { setSubmitting }: FormikHelpers) => { + const submit = ({ ips, startPort, endPort, alias }: Values, { setSubmitting }: FormikHelpers): void => { setSubmitting(false); ips.forEach(async ip => { @@ -55,7 +55,7 @@ function CreateAllocationForm({ nodeId }: { nodeId: number }) { }; return ( - onSubmit={submit} initialValues={{ ips: [] as string[], diff --git a/resources/scripts/components/admin/management/servers/NewServerContainer.tsx b/resources/scripts/components/admin/management/servers/NewServerContainer.tsx index 184b236e1a..1df3909ecd 100644 --- a/resources/scripts/components/admin/management/servers/NewServerContainer.tsx +++ b/resources/scripts/components/admin/management/servers/NewServerContainer.tsx @@ -128,21 +128,6 @@ function InternalForm() { } }; - const loadOptions = async (inputValue: string, callback: (options: Option[]) => void) => { - if (!node) { - callback([] as Option[]); - return; - } - - const allocations = await getAllocations(node.id, { search: inputValue, server_id: '0' }); - - callback( - allocations.map(a => { - return { value: a.id.toString(), label: a.getDisplayText() }; - }), - ); - }; - const getWizardSteps = (): Step[] => { return [ { @@ -698,6 +683,15 @@ function InternalForm() { type={'number'} description={'The total number of subusers that can be added to this server.'} /> +
    @@ -888,6 +882,7 @@ export default () => { backups: 0, databases: 0, subusers: 0, + subdomains: 1, }, allocation: { default: 0, diff --git a/resources/scripts/components/admin/management/servers/NodeSelect.tsx b/resources/scripts/components/admin/management/servers/NodeSelect.tsx index 60892ba488..4bf29ba177 100644 --- a/resources/scripts/components/admin/management/servers/NodeSelect.tsx +++ b/resources/scripts/components/admin/management/servers/NodeSelect.tsx @@ -42,9 +42,7 @@ export default ({ node, setNode }: { node: Node | null; setNode: (_: Node | null
    - {node && ( - Selected - )} + {node && Selected}
    {loading ? ( @@ -75,32 +73,32 @@ export default ({ node, setNode }: { node: Node | null; setNode: (_: Node | null onClick={() => onSelect(n)} aria-label={`Select node ${n.name} (${n.fqdn})`} > -
    -
    -

    {n.name}

    -

    {n.fqdn}

    +
    +
    +

    {n.name}

    +

    {n.fqdn}

    +
    + {isSelected && ( + + Selected + + )}
    - {isSelected && ( - - Selected +
    + + HTTP {n.ports.http.public} - )} -
    -
    - - HTTP {n.ports.http.public} - - - SFTP {n.ports.sftp.public} - -
    - + + SFTP {n.ports.sftp.public} + +
    + ); })}
    diff --git a/resources/scripts/components/admin/management/servers/ServerConfigurationContainer.tsx b/resources/scripts/components/admin/management/servers/ServerConfigurationContainer.tsx index af09ad7136..e0e1dfda4d 100644 --- a/resources/scripts/components/admin/management/servers/ServerConfigurationContainer.tsx +++ b/resources/scripts/components/admin/management/servers/ServerConfigurationContainer.tsx @@ -6,8 +6,9 @@ import { useEffect, useState } from 'react'; import { object } from 'yup'; import tw from 'twin.macro'; -import type { LoadedEgg } from '@/api/routes/admin/egg'; +import type { Egg } from '@/api/routes/admin/egg'; import { getEgg } from '@/api/routes/admin/egg'; +import type { WithRelationships } from '@/api/routes/admin'; import type { Server } from '@/api/routes/admin/server'; import { useServerFromRoute } from '@/api/routes/admin/server'; import type { Values } from '@/api/routes/admin/servers/updateServerStartup'; @@ -33,8 +34,8 @@ function ServerConfigurationForm({ server, }: { selectedEggId?: number; - egg?: LoadedEgg; - setEgg: (value: LoadedEgg | undefined) => void; + egg?: WithRelationships; + setEgg: (value: WithRelationships | undefined) => void; server: Server; }) { const { @@ -170,7 +171,7 @@ export default () => { const { clearFlashes, clearAndAddHttpError } = useStoreActions( (actions: Actions) => actions.flashes, ); - const [egg, setEgg] = useState(undefined); + const [egg, setEgg] = useState | undefined>(undefined); useEffect(() => { if (!server) { @@ -205,7 +206,7 @@ export default () => { name: values.name, externalId: values.externalId, ownerId: values.ownerId, - limits: server.limits, + limits: { ...server.limits, threads: server.limits.threads ?? '' }, featureLimits: server.featureLimits, allocationId: server.allocationId, addAllocations: [], diff --git a/resources/scripts/components/admin/management/servers/ServerOverviewContainer.tsx b/resources/scripts/components/admin/management/servers/ServerOverviewContainer.tsx index 635de106d4..ae256d16b3 100644 --- a/resources/scripts/components/admin/management/servers/ServerOverviewContainer.tsx +++ b/resources/scripts/components/admin/management/servers/ServerOverviewContainer.tsx @@ -10,7 +10,7 @@ import getNode from '@/api/routes/admin/nodes/getNode'; import { Node } from '@/api/routes/admin/nodes/getNodes'; import NodeStatus from '@admin/management/nodes/NodeStatus'; import { NavLink } from 'react-router-dom'; -import { useFlashKey } from '@/plugins/useFlash'; +import useFlash from '@/plugins/useFlash'; // Status badge colors const STATUS_COLORS = { @@ -31,7 +31,7 @@ export default () => { const [node, setNode] = useState(); const { data: server } = useServerFromRoute(); const { billing } = useStoreState(state => state.everest.data!); - const { addFlash } = useFlashKey('server'); + const { addFlash } = useFlash(); useEffect(() => { if (server) { diff --git a/resources/scripts/components/admin/management/servers/ServerResourcesContainer.tsx b/resources/scripts/components/admin/management/servers/ServerResourcesContainer.tsx index 10a0dadbf9..3bea5fdd60 100644 --- a/resources/scripts/components/admin/management/servers/ServerResourcesContainer.tsx +++ b/resources/scripts/components/admin/management/servers/ServerResourcesContainer.tsx @@ -55,6 +55,7 @@ export default () => { backups: server.featureLimits.backups, databases: server.featureLimits.databases, subusers: server.featureLimits.subusers, + subdomains: server.featureLimits.subdomains, }, allocationId: server.allocationId, addAllocations: [] as number[], diff --git a/resources/scripts/components/admin/management/servers/ServerRouter.tsx b/resources/scripts/components/admin/management/servers/ServerRouter.tsx index 46e8d20865..cb0b6712b3 100644 --- a/resources/scripts/components/admin/management/servers/ServerRouter.tsx +++ b/resources/scripts/components/admin/management/servers/ServerRouter.tsx @@ -13,6 +13,7 @@ import { CogIcon, CurrencyDollarIcon, DatabaseIcon, + LightningBoltIcon, ExclamationIcon, ExternalLinkIcon, InformationCircleIcon, @@ -25,6 +26,7 @@ import ServerOverviewContainer from './ServerOverviewContainer'; import ServerConfigurationContainer from './ServerConfigurationContainer'; import ServerResourcesContainer from './ServerResourcesContainer'; import ServerDangerZoneContainer from './ServerDangerZoneContainer'; +import ServerWingsRsContainer from './ServerWingsRsContainer'; import Pill from '@/elements/Pill'; export default () => { @@ -113,6 +115,11 @@ export default () => { name={'Danger Zone'} icon={ExclamationIcon} /> + { } /> } /> } /> + } /> ); diff --git a/resources/scripts/components/admin/management/servers/ServerSettingsContainer.tsx b/resources/scripts/components/admin/management/servers/ServerSettingsContainer.tsx index a4f6360499..17e061b326 100644 --- a/resources/scripts/components/admin/management/servers/ServerSettingsContainer.tsx +++ b/resources/scripts/components/admin/management/servers/ServerSettingsContainer.tsx @@ -71,6 +71,7 @@ export default () => { backups: server.featureLimits.backups, databases: server.featureLimits.databases, subusers: server.featureLimits.subusers, + subdomains: server.featureLimits.subdomains, }, allocationId: server.allocationId, addAllocations: [] as number[], diff --git a/resources/scripts/components/admin/management/servers/ServerStartupContainer.tsx b/resources/scripts/components/admin/management/servers/ServerStartupContainer.tsx index 96dc955e3b..d6dc2289a3 100644 --- a/resources/scripts/components/admin/management/servers/ServerStartupContainer.tsx +++ b/resources/scripts/components/admin/management/servers/ServerStartupContainer.tsx @@ -5,7 +5,7 @@ import { Form, Formik, useField, useFormikContext } from 'formik'; import { useEffect, useState } from 'react'; import { object } from 'yup'; -import type { Egg, EggVariable, LoadedEgg } from '@/api/routes/admin/egg'; +import type { Egg, EggVariable } from '@/api/routes/admin/egg'; import { getEgg } from '@/api/routes/admin/egg'; import type { Server } from '@/api/routes/admin/server'; import { useServerFromRoute } from '@/api/routes/admin/server'; @@ -156,8 +156,8 @@ function ServerStartupForm({ server, }: { selectedEggId?: number; - egg?: LoadedEgg; - setEgg: (value: LoadedEgg | undefined) => void; + egg?: WithRelationships; + setEgg: (value: WithRelationships | undefined) => void; server: Server; }) { const { @@ -219,7 +219,7 @@ export default () => { const { clearFlashes, clearAndAddHttpError } = useStoreActions( (actions: Actions) => actions.flashes, ); - const [egg, setEgg] = useState(undefined); + const [egg, setEgg] = useState | undefined>(undefined); useEffect(() => { if (!server) { diff --git a/resources/scripts/components/admin/management/servers/ServerWingsRsContainer.tsx b/resources/scripts/components/admin/management/servers/ServerWingsRsContainer.tsx new file mode 100644 index 0000000000..7644434aa2 --- /dev/null +++ b/resources/scripts/components/admin/management/servers/ServerWingsRsContainer.tsx @@ -0,0 +1,113 @@ +import { useEffect, useState } from 'react'; +import tw from 'twin.macro'; +import { useServerFromRoute } from '@/api/routes/admin/server'; +import AdminBox from '@/elements/AdminBox'; +import SpinnerOverlay from '@/elements/SpinnerOverlay'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faBoltLightning, faFileAlt, faSync } from '@fortawesome/free-solid-svg-icons'; +import { getAdminServerWingsStatus, getAdminServerInstallLogs } from '@/api/routes/admin/servers/wingsRs'; +import useFlash from '@/plugins/useFlash'; + +export default () => { + const { data: server } = useServerFromRoute(); + const { addError } = useFlash(); + + const [loading, setLoading] = useState(true); + const [status, setStatus] = useState<{ + supercharged: boolean; + wings_type: string; + wings_version: string | null; + } | null>(null); + const [logs, setLogs] = useState([]); + const [logsMissing, setLogsMissing] = useState(false); + const [logsLoading, setLogsLoading] = useState(false); + + const load = async () => { + if (!server) return; + + try { + setLoading(true); + const statusData = await getAdminServerWingsStatus(server.id); + + setStatus(statusData); + } catch (error) { + console.error(error); + addError({ key: 'server', message: 'Failed to load Wings-RS server details.' }); + } finally { + setLoading(false); + } + }; + + const loadLogs = async () => { + if (!server) return; + + try { + setLogsLoading(true); + const response = await getAdminServerInstallLogs(server.id, 100); + setLogs(response.content); + setLogsMissing(response.missing); + } catch (error) { + console.error(error); + addError({ key: 'server', message: 'Failed to load install logs.' }); + } finally { + setLogsLoading(false); + } + }; + + useEffect(() => { + load(); + }, [server?.id]); + + if (!server) return null; + + if (status && !status.supercharged) { + return ( + +

    This server's node is not running Wings-RS.

    +
    + ); + } + + return ( +
    + + +
    +
    + Type: {status?.wings_type ?? 'unknown'} +
    +
    + Version: {status?.wings_version ?? 'unknown'} +
    +
    +
    + + + + Refresh + + } + css={tw`relative`} + > + + {logsMissing ? ( +

    No installation log file exists yet for this server.

    + ) : logs.length === 0 ? ( +

    Click refresh to load installation logs.

    + ) : ( +
    + {logs.map((line, index) => ( +
    + {line} +
    + ))} +
    + )} +
    +
    + ); +}; diff --git a/resources/scripts/components/admin/management/servers/billing/EditServerBillingDialog.tsx b/resources/scripts/components/admin/management/servers/billing/EditServerBillingDialog.tsx index 3158a57880..68741bc0d4 100644 --- a/resources/scripts/components/admin/management/servers/billing/EditServerBillingDialog.tsx +++ b/resources/scripts/components/admin/management/servers/billing/EditServerBillingDialog.tsx @@ -54,7 +54,7 @@ export default ({ server }: { server: Server }) => { // We need to find which category this product belongs to // For now, if there's only one category, select it if (cats.length === 1) { - setSelectedCategoryId(cats[0].id); + setSelectedCategoryId(cats[0]!.id); } } }) @@ -130,13 +130,13 @@ export default ({ server }: { server: Server }) => { setSelectedBillingDays(server.billingDays); } else { // Select default cycle - const defaultCycle = cycles.find(c => c.is_default); - setSelectedBillingDays(defaultCycle ? defaultCycle.days : cycles[0].days); + const defaultCycle = cycles.find(c => c.isDefault); + setSelectedBillingDays(defaultCycle ? defaultCycle.days : cycles[0]!.days); } } else { // Select default cycle - const defaultCycle = cycles.find(c => c.is_default); - setSelectedBillingDays(defaultCycle ? defaultCycle.days : cycles[0].days); + const defaultCycle = cycles.find(c => c.isDefault); + setSelectedBillingDays(defaultCycle ? defaultCycle.days : cycles[0]!.days); } } }) @@ -379,14 +379,14 @@ export default ({ server }: { server: Server }) => { {billingCycles.map(cycle => ( ))} @@ -403,18 +403,18 @@ export default ({ server }: { server: Server }) => {

    Price: $ {selectedCycle.price?.toFixed(2) || '0.00'} - {selectedCycle.discount_percent !== 0 && ( + {selectedCycle.discountPercent !== 0 && ( 0 + selectedCycle.discountPercent > 0 ? 'text-green-400' : 'text-red-400' } > {' '} - ({selectedCycle.discount_percent > 0 ? '' : '+'} - {Math.abs(selectedCycle.discount_percent)}%{' '} - {selectedCycle.discount_percent > 0 + ({selectedCycle.discountPercent > 0 ? '' : '+'} + {Math.abs(selectedCycle.discountPercent)}%{' '} + {selectedCycle.discountPercent > 0 ? 'discount' : 'premium'} ) diff --git a/resources/scripts/components/admin/management/servers/billing/EditServerBillingModal.tsx b/resources/scripts/components/admin/management/servers/billing/EditServerBillingModal.tsx index 901680b690..22a852cbc7 100644 --- a/resources/scripts/components/admin/management/servers/billing/EditServerBillingModal.tsx +++ b/resources/scripts/components/admin/management/servers/billing/EditServerBillingModal.tsx @@ -225,7 +225,7 @@ const PlanSelectionStep = ({

    {cycle.days} {cycle.days === 1 ? 'Day' : 'Days'}
    - {cycle.is_default && ( + {cycle.isDefault && ( Default @@ -234,15 +234,15 @@ const PlanSelectionStep = ({
    ${cycle.price?.toFixed(2) || '0.00'}
    - {cycle.discount_percent !== 0 && ( + {cycle.discountPercent !== 0 && (
    0 ? 'text-green-400' : 'text-red-400' + cycle.discountPercent > 0 ? 'text-green-400' : 'text-red-400' }`} > - {cycle.discount_percent > 0 ? '' : '+'} - {Math.abs(cycle.discount_percent)}%{' '} - {cycle.discount_percent > 0 ? 'discount' : 'premium'} + {cycle.discountPercent > 0 ? '' : '+'} + {Math.abs(cycle.discountPercent)}%{' '} + {cycle.discountPercent > 0 ? 'discount' : 'premium'}
    )} @@ -268,18 +268,18 @@ const PlanSelectionStep = ({ ${selectedCycle.price?.toFixed(2) || '0.00'}
    - {selectedCycle.discount_percent !== 0 && ( + {selectedCycle.discountPercent !== 0 && (
    Discount: 0 + selectedCycle.discountPercent > 0 ? 'text-green-400' : 'text-red-400' }`} > - {selectedCycle.discount_percent > 0 ? '' : '+'} - {Math.abs(selectedCycle.discount_percent)}% + {selectedCycle.discountPercent > 0 ? '' : '+'} + {Math.abs(selectedCycle.discountPercent)}%
    )} @@ -364,16 +364,16 @@ export default ({ server }: { server: Server }) => { if (server.billingDays) { const hasCycle = cycles.some(c => c.days === server.billingDays); if (hasCycle) { - setForm(prev => ({ ...prev, billingDays: server.billingDays })); + setForm(prev => ({ ...prev, billingDays: server.billingDays ?? null })); return; } } // Select default cycle or first available - const defaultCycle = cycles.find(c => c.is_default); + const defaultCycle = cycles.find(c => c.isDefault); setForm(prev => ({ ...prev, - billingDays: defaultCycle ? defaultCycle.days : cycles[0].days, + billingDays: defaultCycle ? defaultCycle.days : cycles[0]!.days, })); }; @@ -400,14 +400,14 @@ export default ({ server }: { server: Server }) => { setForm(prev => ({ ...prev, categoryId: category.id })); } else if (product.categoryUuid) { // Fallback: find category by UUID in the loaded categories - const matchingCategory = cats.find(c => c.uuid === product.categoryUuid); + const matchingCategory = cats.find(c => c.id === product.categoryUuid); if (matchingCategory) { setForm(prev => ({ ...prev, categoryId: matchingCategory.id })); } } } else if (server.billingProductId && cats.length === 1) { // Fallback: if there's only one category, use it - setForm(prev => ({ ...prev, categoryId: cats[0].id })); + setForm(prev => ({ ...prev, categoryId: cats[0]!.id })); } }) .catch(err => { @@ -458,7 +458,7 @@ export default ({ server }: { server: Server }) => { if (server.billingProductId) { const hasProduct = productList.some((p: Product) => p.id === server.billingProductId); if (hasProduct) { - setForm(prev => ({ ...prev, productId: server.billingProductId })); + setForm(prev => ({ ...prev, productId: server.billingProductId ?? null })); } else { setForm(prev => ({ ...prev, productId: null })); } @@ -608,8 +608,8 @@ export default ({ server }: { server: Server }) => { setOpen(false)} - title={pages[page].title} - description={pages[page].description} + title={pages[page]!.title} + description={pages[page]!.description} size="lg" > @@ -628,8 +628,8 @@ export default ({ server }: { server: Server }) => { {page > 0 && !form.billable && (
    - Billing is disabled. Click "Finish" to save these changes and disable automatic billing for - this server. + Billing is disabled. Click "Finish" to save these changes and disable automatic + billing for this server.
    )} diff --git a/resources/scripts/components/admin/management/servers/billing/ServerBillingContainer.tsx b/resources/scripts/components/admin/management/servers/billing/ServerBillingContainer.tsx index cf398e9bc6..bd6f94b8c1 100644 --- a/resources/scripts/components/admin/management/servers/billing/ServerBillingContainer.tsx +++ b/resources/scripts/components/admin/management/servers/billing/ServerBillingContainer.tsx @@ -24,9 +24,6 @@ export default () => { const { data: server } = useServerFromRoute(); const billing = useStoreState(state => state.everest.data!.billing); - // Get configurable renewal settings - const renewalDays = billing.renewal?.days || 30; - if (!server) return null; const product = server.relationships.product; diff --git a/resources/scripts/components/admin/management/servers/manage/TransferServerBox.tsx b/resources/scripts/components/admin/management/servers/manage/TransferServerBox.tsx index cec58fecb1..23875ca197 100644 --- a/resources/scripts/components/admin/management/servers/manage/TransferServerBox.tsx +++ b/resources/scripts/components/admin/management/servers/manage/TransferServerBox.tsx @@ -31,7 +31,7 @@ export default () => { const availableNodes = fetchedNodes.filter(node => node.id !== server.nodeId); setNodes(availableNodes); }) - .catch(error => { + .catch(_error => { addFlash({ key: 'server:manage', type: 'error', @@ -53,13 +53,13 @@ export default () => { setAllocations(availableAllocations); // Auto-select first allocation if available if (availableAllocations.length > 0) { - setSelectedAllocationId(availableAllocations[0].id); + setSelectedAllocationId(availableAllocations[0]!.id); } else { setSelectedAllocationId(null); } setLoading(false); }) - .catch(error => { + .catch(_error => { addFlash({ key: 'server:manage', type: 'error', @@ -101,7 +101,7 @@ export default () => { setNodes([]); setAllocations([]); }) - .catch(error => { + .catch(_error => { addFlash({ key: 'server:manage', type: 'error', diff --git a/resources/scripts/components/admin/management/servers/settings/FeatureLimitsBox.tsx b/resources/scripts/components/admin/management/servers/settings/FeatureLimitsBox.tsx index c2b66954f2..fed7d688b6 100644 --- a/resources/scripts/components/admin/management/servers/settings/FeatureLimitsBox.tsx +++ b/resources/scripts/components/admin/management/servers/settings/FeatureLimitsBox.tsx @@ -39,6 +39,13 @@ export default () => { type={'number'} description={'The total number of subusers that can be added to this server.'} /> +
    ); diff --git a/resources/scripts/components/admin/management/servers/settings/NetworkingBox.tsx b/resources/scripts/components/admin/management/servers/settings/NetworkingBox.tsx index 8fba652ae1..74bffd2b03 100644 --- a/resources/scripts/components/admin/management/servers/settings/NetworkingBox.tsx +++ b/resources/scripts/components/admin/management/servers/settings/NetworkingBox.tsx @@ -20,8 +20,8 @@ export default () => { const { setFieldValue } = useFormikContext(); const { clearFlashes, clearAndAddHttpError } = useStoreActions(actions => actions.flashes); const [availableAllocations, setAvailableAllocations] = useState([]); - const [selectedAvailableIds, setSelectedAvailableIds] = useState([]); - const [selectedCurrentIds, setSelectedCurrentIds] = useState([]); + const [selectedAvailableId, setSelectedAvailableId] = useState(null); + const [selectedCurrentId, setSelectedCurrentId] = useState(null); const [loading, setLoading] = useState(false); const [loadingAvailable, setLoadingAvailable] = useState(false); const [modalOpen, setModalOpen] = useState(false); @@ -97,15 +97,14 @@ export default () => { const canAddMore = allocationLimit === 0 || currentAllocations.length < allocationLimit; const handleAddAllocation = async () => { - if (selectedAvailableIds.length === 0) return; + if (!selectedAvailableId) return; // Check allocation limit before adding - const newTotal = currentAllocations.length + selectedAvailableIds.length; - if (allocationLimit > 0 && newTotal > allocationLimit) { + if (!canAddMore) { clearAndAddHttpError({ key: 'server:networking', error: { - message: `Cannot add ${selectedAvailableIds.length} allocation(s). Would exceed limit of ${allocationLimit}.`, + message: `Allocation limit of ${allocationLimit} reached. Remove allocations or increase the limit.`, }, }); return; @@ -133,14 +132,15 @@ export default () => { backups: server.featureLimits.backups, databases: server.featureLimits.databases, subusers: server.featureLimits.subusers, + subdomains: server.featureLimits.subdomains, }, allocationId: server.allocationId, - addAllocations: selectedAvailableIds, + addAllocations: [selectedAvailableId], removeAllocations: [], }); await mutate(); - setSelectedAvailableIds([]); + setSelectedAvailableId(null); } catch (error) { console.error('Failed to add allocation:', error); clearAndAddHttpError({ key: 'server:networking', error }); @@ -150,11 +150,11 @@ export default () => { }; const handleRemoveAllocation = async () => { - if (selectedCurrentIds.length === 0) return; + if (!selectedCurrentId) return; // Can't remove the primary allocation if there are no other allocations - const isPrimarySelected = selectedCurrentIds.includes(server.allocationId); - const remainingCount = currentAllocations.length - selectedCurrentIds.length; + const isPrimarySelected = selectedCurrentId === server.allocationId; + const remainingCount = currentAllocations.length - 1; if (isPrimarySelected && remainingCount === 0) { clearAndAddHttpError({ @@ -173,8 +173,8 @@ export default () => { try { // If removing primary, set a new primary first let newPrimaryId = server.allocationId; - if (isPrimarySelected) { - const remaining = currentAllocations.find(a => !selectedCurrentIds.includes(a.id)); + if (selectedCurrentId === server.allocationId) { + const remaining = currentAllocations.find(a => a.id !== selectedCurrentId); if (remaining) { newPrimaryId = remaining.id; } @@ -198,14 +198,15 @@ export default () => { backups: server.featureLimits.backups, databases: server.featureLimits.databases, subusers: server.featureLimits.subusers, + subdomains: server.featureLimits.subdomains, }, allocationId: newPrimaryId, addAllocations: [], - removeAllocations: selectedCurrentIds, + removeAllocations: [selectedCurrentId], }); await mutate(); - setSelectedCurrentIds([]); + setSelectedCurrentId(null); } catch (error) { console.error('Failed to remove allocation:', error); clearAndAddHttpError({ key: 'server:networking', error }); @@ -215,7 +216,7 @@ export default () => { }; const handleSetPrimary = async () => { - if (selectedCurrentIds.length !== 1 || selectedCurrentIds[0] === server.allocationId) return; + if (!selectedCurrentId || selectedCurrentId === server.allocationId) return; setLoading(true); clearFlashes('server:networking'); @@ -239,8 +240,9 @@ export default () => { backups: server.featureLimits.backups, databases: server.featureLimits.databases, subusers: server.featureLimits.subusers, + subdomains: server.featureLimits.subdomains, }, - allocationId: selectedCurrentIds[0], + allocationId: selectedCurrentId, addAllocations: [], removeAllocations: [], }); @@ -303,24 +305,19 @@ export default () => { description="Add, remove, or set primary allocations for this server" size="xl" > - +
    {/* Current Allocations */}
    - +
    @@ -350,30 +347,21 @@ export default () => {
    - setSelectedCurrentIds(prev => - prev.includes(allocation.id) - ? prev.filter(id => id !== allocation.id) - : [...prev, allocation.id], + setSelectedCurrentId(prev => + prev === allocation.id ? null : allocation.id, ) } css={tw`flex items-center justify-between p-3 cursor-pointer transition-colors hover:bg-gray-700`} style={{ - backgroundColor: selectedCurrentIds.includes(allocation.id) - ? '#374151' - : undefined, + backgroundColor: + selectedCurrentId === allocation.id ? '#374151' : undefined, }} >
    - setSelectedCurrentIds(prev => - prev.includes(allocation.id) - ? prev.filter(id => id !== allocation.id) - : [...prev, allocation.id], - ) - } + type="radio" + checked={selectedCurrentId === allocation.id} + onChange={() => setSelectedCurrentId(allocation.id)} css={tw`cursor-pointer`} onClick={e => e.stopPropagation()} /> @@ -399,18 +387,15 @@ export default () => { {/* Available Allocations */}
    - +
    @@ -431,29 +416,20 @@ export default () => {
    - setSelectedAvailableIds(prev => - prev.includes(allocation.id) - ? prev.filter(id => id !== allocation.id) - : [...prev, allocation.id], + setSelectedAvailableId(prev => + prev === allocation.id ? null : allocation.id, ) } css={tw`flex items-center gap-3 p-3 cursor-pointer transition-colors hover:bg-gray-700`} style={{ - backgroundColor: selectedAvailableIds.includes(allocation.id) - ? '#374151' - : undefined, + backgroundColor: + selectedAvailableId === allocation.id ? '#374151' : undefined, }} > - setSelectedAvailableIds(prev => - prev.includes(allocation.id) - ? prev.filter(id => id !== allocation.id) - : [...prev, allocation.id], - ) - } + type="radio" + checked={selectedAvailableId === allocation.id} + onChange={() => setSelectedAvailableId(allocation.id)} css={tw`cursor-pointer`} onClick={e => e.stopPropagation()} /> @@ -475,11 +451,10 @@ export default () => { {/* Info Message */}

    - 💡 How to use: Select multiple allocations using checkboxes from either - list. Click "Add" to add selected available allocations immediately, or - "Remove" to remove selected current allocations. Select a single allocation and - click "Set Primary" to make it the primary allocation. Changes are saved - automatically. + 💡 How to use: Select an allocation from either list. Click "Add" + to add the selected available allocation, or "Remove" to remove the selected + current allocation. Select a current allocation and click "Set Primary" to make it + the primary allocation. Changes are saved automatically.

    diff --git a/resources/scripts/components/admin/management/users/view/VerifyEmailBox.tsx b/resources/scripts/components/admin/management/users/view/VerifyEmailBox.tsx index 781071683e..b0a289b6aa 100644 --- a/resources/scripts/components/admin/management/users/view/VerifyEmailBox.tsx +++ b/resources/scripts/components/admin/management/users/view/VerifyEmailBox.tsx @@ -55,7 +55,11 @@ export default () => {
    {isVerified ? ( - setVisible(true)}> + setVisible(true)} + > Unverify Email ) : ( diff --git a/resources/scripts/components/admin/modules/ai/SettingsContainer.tsx b/resources/scripts/components/admin/modules/ai/SettingsContainer.tsx index 46c868f632..e77d3772e7 100644 --- a/resources/scripts/components/admin/modules/ai/SettingsContainer.tsx +++ b/resources/scripts/components/admin/modules/ai/SettingsContainer.tsx @@ -1,6 +1,6 @@ -import Field from '@/elements/Field'; +import Field, { TextareaField } from '@/elements/Field'; import Label from '@/elements/Label'; -import { Form, Formik } from 'formik'; +import { Field as FormikField, Form, Formik } from 'formik'; import AdminBox from '@/elements/AdminBox'; import { useStoreState } from '@/state/hooks'; import { @@ -16,7 +16,6 @@ import { import { AISettings, updateSettings } from '@/api/routes/admin/ai/settings'; import useFlash from '@/plugins/useFlash'; import { Button } from '@/elements/button'; -import SelectField from '@/elements/SelectField'; import { useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -108,10 +107,10 @@ export default () => {
    - + - +

    {values.mode === 'ollama' ? 'Ollama mode allows HTTP for local connections and does not require an API key.' @@ -203,8 +202,7 @@ export default () => {

    - { link: alert.link || '', link_text: alert.link_text || '', priority: alert.priority, - start_at: alert.start_at ? alert.start_at.split('T')[0] : '', - end_at: alert.end_at ? alert.end_at.split('T')[0] : '', + start_at: alert.start_at ? (alert.start_at.split('T')[0] ?? '') : '', + end_at: alert.end_at ? (alert.end_at.split('T')[0] ?? '') : '', }); if (alert.users) { @@ -136,7 +136,7 @@ export default () => { end_at: values.end_at || undefined, }; - const promise = id ? updateAlert(parseInt(id), data) : createAlert(data); + const promise = id ? updateAlert(parseInt(id), data) : createAlert(data as CreateAlertData); promise .then(() => { diff --git a/resources/scripts/components/admin/modules/auth/AuthRouter.tsx b/resources/scripts/components/admin/modules/auth/AuthRouter.tsx index dc01a8e76c..bcef703c5d 100644 --- a/resources/scripts/components/admin/modules/auth/AuthRouter.tsx +++ b/resources/scripts/components/admin/modules/auth/AuthRouter.tsx @@ -15,18 +15,18 @@ export default () => { return ( - + {jguardEnabled && ( <> - - + + )} @@ -36,7 +36,7 @@ export default () => { {jguardEnabled && ( <> @@ -46,7 +46,7 @@ export default () => { } /> diff --git a/resources/scripts/components/admin/modules/auth/jguard/JGuardPending.tsx b/resources/scripts/components/admin/modules/auth/jguard/JGuardPending.tsx index 8f0760b7ed..053ca94192 100644 --- a/resources/scripts/components/admin/modules/auth/jguard/JGuardPending.tsx +++ b/resources/scripts/components/admin/modules/auth/jguard/JGuardPending.tsx @@ -27,11 +27,7 @@ const TimeRemaining = ({ expiresAt }: { expiresAt: string | null }) => { return Activating shortly…; } - return ( - - {formatDistanceToNow(date, { addSuffix: true })} - - ); + return {formatDistanceToNow(date, { addSuffix: true })}; }; export default () => { @@ -118,7 +114,11 @@ export default () => {
    - + @@ -135,7 +135,9 @@ export default () => { - + diff --git a/resources/scripts/components/admin/modules/auth/jguard/JGuardSettings.tsx b/resources/scripts/components/admin/modules/auth/jguard/JGuardSettings.tsx index 4d02e6ae39..768b1b8a42 100644 --- a/resources/scripts/components/admin/modules/auth/jguard/JGuardSettings.tsx +++ b/resources/scripts/components/admin/modules/auth/jguard/JGuardSettings.tsx @@ -8,7 +8,7 @@ import { useStoreActions, useStoreState } from '@/state/hooks'; import useStatus from '@/plugins/useStatus'; import { faShieldHalved } from '@fortawesome/free-solid-svg-icons'; import { toggleModule } from '@/api/routes/admin/auth/module'; -import { updateJGuardSettings } from '@/api/routes/admin/auth/jguard'; +import { updateJGuardSettings, type JGuardSettingsValues } from '@/api/routes/admin/auth/jguard'; import { Alert } from '@/elements/alert'; import { Dialog } from '@/elements/dialog'; @@ -21,11 +21,13 @@ export default () => { const [confirmDisable, setConfirmDisable] = useState(false); // Local controlled state — initialized from page-load store, updated immediately on change. - const [approvalMode, setApprovalMode] = useState<'manual' | 'delayed'>(jguard.approval_mode === 'immediate' ? 'manual' : jguard.approval_mode as 'manual' | 'delayed'); + const [approvalMode, setApprovalMode] = useState<'manual' | 'delayed'>( + jguard.approval_mode === 'immediate' ? 'manual' : (jguard.approval_mode as 'manual' | 'delayed'), + ); const [delay, setDelay] = useState(jguard.delay); const [pendingMessage, setPendingMessage] = useState(jguard.pending_message ?? ''); - const saveSetting = (values: { approval_mode?: string; delay?: number; pending_message?: string }) => { + const saveSetting = (values: JGuardSettingsValues) => { clearFlashes('auth:jguard:settings'); setStatus('loading'); updateJGuardSettings(values) @@ -172,8 +174,8 @@ export default () => { {approvalMode === 'manual' && ( - Manual approval mode is active. New registrations will be held until you approve them from - the Pending Accounts tab. + Manual approval mode is active. New registrations will be held until you approve them from the{' '} + Pending Accounts tab. )} diff --git a/resources/scripts/components/admin/modules/auth/modules/JGuard.tsx b/resources/scripts/components/admin/modules/auth/modules/JGuard.tsx index 245d24611e..a8b3004d44 100644 --- a/resources/scripts/components/admin/modules/auth/modules/JGuard.tsx +++ b/resources/scripts/components/admin/modules/auth/modules/JGuard.tsx @@ -12,10 +12,7 @@ export default () => { jGuard is enabled. Configure it and manage pending accounts under the{' '} - + jGuard Settings {' '} tab. @@ -24,4 +21,3 @@ export default () => { ); }; - diff --git a/resources/scripts/components/admin/modules/billing/BillingDropdown.tsx b/resources/scripts/components/admin/modules/billing/BillingDropdown.tsx index 7c4fcaa27b..0f0d4cfe75 100644 --- a/resources/scripts/components/admin/modules/billing/BillingDropdown.tsx +++ b/resources/scripts/components/admin/modules/billing/BillingDropdown.tsx @@ -11,7 +11,7 @@ import { createPortal } from 'react-dom'; interface DropdownItemProps { to: string; name: string; - icon?: ComponentType; + icon?: ComponentType<{ className?: string }>; children?: ReactNode; } @@ -51,7 +51,7 @@ const BillingDropdownItem = ({ interface BillingDropdownProps { items: DropdownItemProps[]; - icon?: ComponentType; + icon?: ComponentType<{ className?: string }>; } export const BillingDropdown = ({ items, icon: IconComponent }: BillingDropdownProps) => { diff --git a/resources/scripts/components/admin/modules/billing/BillingRulesContainer.tsx b/resources/scripts/components/admin/modules/billing/BillingRulesContainer.tsx index a0fa8d911a..7aefaab1e7 100644 --- a/resources/scripts/components/admin/modules/billing/BillingRulesContainer.tsx +++ b/resources/scripts/components/admin/modules/billing/BillingRulesContainer.tsx @@ -89,7 +89,12 @@ export const normalizeBillingDaysValue = (raw: string | number): { value: number return { value: clamped.value, clamped: clamped.clamped }; }; -const formatBillingLength = (maxDays: number, isLast: boolean, defaultBillingDays: number, clamped?: ClampTag): string => { +const formatBillingLength = ( + maxDays: number, + isLast: boolean, + defaultBillingDays: number, + clamped?: ClampTag, +): string => { const suffix = clamped ? ` (${clamped})` : ''; if (maxDays === defaultBillingDays) return `${maxDays} days (base)${suffix}`; if (isLast) return `${maxDays}+ days${suffix}`; @@ -107,30 +112,30 @@ export default () => { // Parse multiplier steps from settings or use defaults const parseSteps = (stepsString: string | undefined): MultiplierStep[] => { - if (!stepsString) { - return [ - { id: nanoid(), maxDays: 10, maxDaysInput: '10', multiplier: 1.3, multiplierInput: '1.30' }, - { id: nanoid(), maxDays: 20, maxDaysInput: '20', multiplier: 1.2, multiplierInput: '1.20' }, - { id: nanoid(), maxDays: 29, maxDaysInput: '29', multiplier: 1.1, multiplierInput: '1.10' }, - { id: nanoid(), maxDays: 30, maxDaysInput: '30', multiplier: 1.0, multiplierInput: '1.00' }, - { id: nanoid(), maxDays: 59, maxDaysInput: '59', multiplier: 0.95, multiplierInput: '0.95' }, - { id: nanoid(), maxDays: 89, maxDaysInput: '89', multiplier: 0.9, multiplierInput: '0.90' }, - { id: nanoid(), maxDays: 999, maxDaysInput: '999', multiplier: 0.85, multiplierInput: '0.85' }, - ]; - } - try { - const parsed = JSON.parse(stepsString) as StoredMultiplierStep[]; - // Add IDs to existing steps if they don't have them - return parsed.map(step => ({ - ...step, - id: nanoid(), - maxDaysInput: step.maxDays.toString(), - multiplierInput: step.multiplier.toFixed(2), - })); - } catch { - return []; - } - }; + if (!stepsString) { + return [ + { id: nanoid(), maxDays: 10, maxDaysInput: '10', multiplier: 1.3, multiplierInput: '1.30' }, + { id: nanoid(), maxDays: 20, maxDaysInput: '20', multiplier: 1.2, multiplierInput: '1.20' }, + { id: nanoid(), maxDays: 29, maxDaysInput: '29', multiplier: 1.1, multiplierInput: '1.10' }, + { id: nanoid(), maxDays: 30, maxDaysInput: '30', multiplier: 1.0, multiplierInput: '1.00' }, + { id: nanoid(), maxDays: 59, maxDaysInput: '59', multiplier: 0.95, multiplierInput: '0.95' }, + { id: nanoid(), maxDays: 89, maxDaysInput: '89', multiplier: 0.9, multiplierInput: '0.90' }, + { id: nanoid(), maxDays: 999, maxDaysInput: '999', multiplier: 0.85, multiplierInput: '0.85' }, + ]; + } + try { + const parsed = JSON.parse(stepsString) as StoredMultiplierStep[]; + // Add IDs to existing steps if they don't have them + return parsed.map(step => ({ + ...step, + id: nanoid(), + maxDaysInput: step.maxDays.toString(), + multiplierInput: step.multiplier.toFixed(2), + })); + } catch { + return []; + } + }; const [multiplierSteps, setMultiplierSteps] = useState( parseSteps(settings.renewal?.multiplier_steps as string | undefined), @@ -218,63 +223,65 @@ export default () => { throw new Error('At least one multiplier step is required'); } - // Sort steps by maxDays for consistency - const sortedSteps = [...multiplierSteps].sort((a, b) => a.maxDays - b.maxDays); - - const validatedSteps = sortedSteps.map((step, idx) => { - let normalizedMultiplier: { value: number; clamped?: ClampTag }; - let normalizedMaxDays: { value: number; clamped?: ClampTag }; - try { - normalizedMultiplier = normalizeMultiplierValue(step.multiplierInput ?? step.multiplier?.toString() ?? ''); - } catch (err) { - const message = err instanceof Error ? err.message : 'Invalid price adjustment.'; - throw new Error(`Step ${idx + 1} (${step.maxDays} days): ${message}`); - } - - try { - normalizedMaxDays = normalizeBillingDaysValue(step.maxDaysInput ?? step.maxDays?.toString() ?? ''); - } catch (err) { - const message = err instanceof Error ? err.message : 'Invalid billing length.'; - throw new Error(`Step ${idx + 1}: ${message}`); - } - - return { - ...step, - maxDays: normalizedMaxDays.value, - maxDaysInput: normalizedMaxDays.value.toString(), - multiplier: normalizedMultiplier.value, - multiplierInput: normalizedMultiplier.value.toFixed(2), - }; - }); - - // Remove IDs before saving (backend doesn't need them) - const stepsToSave = validatedSteps.map(step => ({ - maxDays: step.maxDays, - multiplier: step.multiplier, - })); + // Sort steps by maxDays for consistency + const sortedSteps = [...multiplierSteps].sort((a, b) => a.maxDays - b.maxDays); + + const validatedSteps = sortedSteps.map((step, idx) => { + let normalizedMultiplier: { value: number; clamped?: ClampTag }; + let normalizedMaxDays: { value: number; clamped?: ClampTag }; + try { + normalizedMultiplier = normalizeMultiplierValue( + step.multiplierInput ?? step.multiplier?.toString() ?? '', + ); + } catch (err) { + const message = err instanceof Error ? err.message : 'Invalid price adjustment.'; + throw new Error(`Step ${idx + 1} (${step.maxDays} days): ${message}`); + } + + try { + normalizedMaxDays = normalizeBillingDaysValue(step.maxDaysInput ?? step.maxDays?.toString() ?? ''); + } catch (err) { + const message = err instanceof Error ? err.message : 'Invalid billing length.'; + throw new Error(`Step ${idx + 1}: ${message}`); + } + + return { + ...step, + maxDays: normalizedMaxDays.value, + maxDaysInput: normalizedMaxDays.value.toString(), + multiplier: normalizedMultiplier.value, + multiplierInput: normalizedMultiplier.value.toFixed(2), + }; + }); + + // Remove IDs before saving (backend doesn't need them) + const stepsToSave = validatedSteps.map(step => ({ + maxDays: step.maxDays, + multiplier: step.multiplier, + })); // Save both settings await updateSettings('renewal:default_billing_days', defaultBillingDays); await updateSettings('renewal:multiplier_steps', JSON.stringify(stepsToSave)); - // Update state with all new values - updateEverest({ - billing: { - ...settings, - renewal: { - ...settings.renewal, - default_billing_days: defaultBillingDays, - multiplier_steps: JSON.stringify(stepsToSave), - }, - }, - }); - - setMultiplierSteps(validatedSteps); - - addFlash({ - key: 'admin:billing', - type: 'success', - message: 'Billing rules updated successfully.', + // Update state with all new values + updateEverest({ + billing: { + ...settings, + renewal: { + ...settings.renewal, + default_billing_days: defaultBillingDays, + multiplier_steps: JSON.stringify(stepsToSave), + }, + }, + }); + + setMultiplierSteps(validatedSteps); + + addFlash({ + key: 'admin:billing', + type: 'success', + message: 'Billing rules updated successfully.', }); } catch (error) { console.error(error); @@ -451,12 +458,12 @@ export default () => {
    Username Email Mode {entry.approval_mode} {formatDate(entry.created_at)} + {formatDate(entry.created_at)} +
    - {formatBillingLength( - displayMaxDays.effective, - isLast, - defaultBillingDays, - displayMaxDays.clampTag, - )} + {formatBillingLength( + displayMaxDays.effective, + isLast, + defaultBillingDays, + displayMaxDays.clampTag, + )} { tw`min-w-[120px] font-medium`, Math.abs(displayMultiplier.effective - 1.0) < EPSILON && tw`text-blue-400`, - displayMultiplier.effective >= 1.0 + EPSILON && tw`text-red-400`, - displayMultiplier.effective < 1.0 - EPSILON && tw`text-green-400`, + displayMultiplier.effective >= 1.0 + EPSILON && + tw`text-red-400`, + displayMultiplier.effective < 1.0 - EPSILON && + tw`text-green-400`, ]} > {formatPriceAdjustment( @@ -490,20 +499,22 @@ export default () => { )} updateMultiplierInput(step.id, e.target.value)} - disabled={loading} - css={tw`w-24`} - /> -
    + type={'number'} + step={0.01} + min={MIN_MULTIPLIER} + max={MAX_MULTIPLIER} + value={ + step.multiplierInput ?? + step.multiplier?.toFixed(2) ?? + '1.00' + } + onChange={e => + updateMultiplierInput(step.id, e.target.value) + } + disabled={loading} + css={tw`w-24`} + /> +
    {donations && donations.last_page > 1 && ( - - {({ isLoading }) => ( -
    {isLoading && }
    + + {() => ( +
    )} )} diff --git a/resources/scripts/components/admin/modules/billing/exceptions/BillingExceptionsTable.tsx b/resources/scripts/components/admin/modules/billing/exceptions/BillingExceptionsTable.tsx index 7c901c0524..320ce53c3f 100644 --- a/resources/scripts/components/admin/modules/billing/exceptions/BillingExceptionsTable.tsx +++ b/resources/scripts/components/admin/modules/billing/exceptions/BillingExceptionsTable.tsx @@ -29,13 +29,13 @@ function getColor(type: BillingExceptionType): PillStatus { case 'deployment': return 'warn'; case 'payment': - return 'error'; + return 'danger'; case 'storefront': return 'info'; case 'webhook': return 'warn'; case 'refund': - return 'error'; + return 'danger'; case 'validation': return 'warn'; default: diff --git a/resources/scripts/components/admin/modules/billing/guides/SetupMollie.tsx b/resources/scripts/components/admin/modules/billing/guides/SetupMollie.tsx index f794dbcaec..cdefe27f16 100644 --- a/resources/scripts/components/admin/modules/billing/guides/SetupMollie.tsx +++ b/resources/scripts/components/admin/modules/billing/guides/SetupMollie.tsx @@ -5,7 +5,6 @@ import Field from '@/elements/Field'; import { Button } from '@/elements/button'; import { updateSettings } from '@/api/routes/admin/billing'; import { useStoreActions, useStoreState } from '@/state/hooks'; -import { BillingSetupDialog } from '@admin/modules/billing/SettingsContainer'; interface Values { apiKey: string; @@ -15,7 +14,7 @@ interface Props { extOpen?: boolean; } -export default ({ extOpen }: Props) => { +export default ({}: Props) => { const settings = useStoreState(s => s.everest.data!.billing); const updateEverest = useStoreActions(s => s.everest.updateEverest); diff --git a/resources/scripts/components/admin/modules/billing/guides/SetupMollieKeys.tsx b/resources/scripts/components/admin/modules/billing/guides/SetupMollieKeys.tsx index fbd163acac..8b4fb576ff 100644 --- a/resources/scripts/components/admin/modules/billing/guides/SetupMollieKeys.tsx +++ b/resources/scripts/components/admin/modules/billing/guides/SetupMollieKeys.tsx @@ -1,5 +1,4 @@ import Input from '@/elements/Input'; -import { useStoreState } from '@/state/hooks'; import { Dialog } from '@/elements/dialog'; import { faExclamationTriangle, faCheckCircle, faInfoCircle } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -14,7 +13,6 @@ interface MollieKeys { export default ({ open, onClose }: { open: boolean; onClose: () => void }) => { const [data, setData] = useState({}); - const existingMollie = useStoreState(s => s.everest.data!.billing.mollie); const submit = async () => { if (!data.apiKey) return; diff --git a/resources/scripts/components/admin/modules/billing/guides/SetupPayPalKeys.tsx b/resources/scripts/components/admin/modules/billing/guides/SetupPayPalKeys.tsx index 5b36a4e8dc..09c6702936 100644 --- a/resources/scripts/components/admin/modules/billing/guides/SetupPayPalKeys.tsx +++ b/resources/scripts/components/admin/modules/billing/guides/SetupPayPalKeys.tsx @@ -1,6 +1,5 @@ import Input from '@/elements/Input'; import Select from '@/elements/Select'; -import { useStoreState } from '@/state/hooks'; import { Dialog } from '@/elements/dialog'; import { faExclamationTriangle, faCheckCircle, faInfoCircle } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -17,7 +16,6 @@ interface PayPalKeys { export default ({ open, onClose }: { open: boolean; onClose: () => void }) => { const [data, setData] = useState({ mode: 'sandbox' }); - const existingPayPal = useStoreState(s => s.everest.data!.billing.paypal_standalone); const submit = async () => { if (!data.clientId || !data.clientSecret || !data.mode) return; diff --git a/resources/scripts/components/admin/modules/billing/guides/SetupStripeKeys.tsx b/resources/scripts/components/admin/modules/billing/guides/SetupStripeKeys.tsx index a66e80a555..599e8701fb 100644 --- a/resources/scripts/components/admin/modules/billing/guides/SetupStripeKeys.tsx +++ b/resources/scripts/components/admin/modules/billing/guides/SetupStripeKeys.tsx @@ -1,10 +1,9 @@ import Input from '@/elements/Input'; -import { useStoreState } from '@/state/hooks'; import { Dialog } from '@/elements/dialog'; -import { faExclamationTriangle, faCheckCircle, faInfoCircle } from '@fortawesome/free-solid-svg-icons'; +import { faExclamationTriangle, faCheckCircle } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import Tooltip from '@/elements/tooltip/Tooltip'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { Button } from '@/elements/button'; import { updateSettings } from '@/api/routes/admin/billing'; @@ -15,7 +14,6 @@ interface StripeKeys { export default ({ open, onClose }: { open: boolean; onClose: () => void }) => { const [data, setData] = useState({}); - const existingKeys = useStoreState(s => s.everest.data!.billing.keys); const submit = async () => { if (!data.publishable || !data.secret) return; diff --git a/resources/scripts/components/admin/modules/billing/integrations/IntegrationsContainer.tsx b/resources/scripts/components/admin/modules/billing/integrations/IntegrationsContainer.tsx index 39eeece808..30ff91c4a4 100644 --- a/resources/scripts/components/admin/modules/billing/integrations/IntegrationsContainer.tsx +++ b/resources/scripts/components/admin/modules/billing/integrations/IntegrationsContainer.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import AdminBox from '@/elements/AdminBox'; import { Button } from '@/elements/button'; -import { useStoreState, useStoreActions } from '@/state/hooks'; +import { useStoreState } from '@/state/hooks'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faCheckCircle, faTimesCircle, faPuzzlePiece, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; import { createIntegrationRegistry } from './registry'; @@ -11,7 +11,6 @@ import FlashMessageRender from '@/elements/FlashMessageRender'; export default () => { const settings = useStoreState(s => s.everest.data!.billing); const theme = useStoreState(s => s.theme.data!); - const _updateEverest = useStoreActions(s => s.everest.updateEverest); const [loading, setLoading] = useState(null); const integrations = createIntegrationRegistry(settings); diff --git a/resources/scripts/components/admin/modules/billing/overview/BillingHealthSummary.tsx b/resources/scripts/components/admin/modules/billing/overview/BillingHealthSummary.tsx index d17d884454..fdfc74aad5 100644 --- a/resources/scripts/components/admin/modules/billing/overview/BillingHealthSummary.tsx +++ b/resources/scripts/components/admin/modules/billing/overview/BillingHealthSummary.tsx @@ -1,6 +1,5 @@ import { useStoreState } from '@/state/hooks'; import ContentBox from '@/elements/ContentBox'; -import { differenceInDays, parseISO } from 'date-fns'; import { BillingAnalytics } from '@definitions/admin'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { @@ -19,8 +18,7 @@ interface BillingHealthSummaryProps { history: number; } -export default ({ data, history }: BillingHealthSummaryProps) => { - const now = new Date(); +export default ({ data }: BillingHealthSummaryProps) => { const settings = useStoreState(s => s.everest.data!.billing); const [copiedUUID, setCopiedUUID] = useState(null); diff --git a/resources/scripts/components/admin/modules/billing/products/BillingCyclesManager.tsx b/resources/scripts/components/admin/modules/billing/products/BillingCyclesManager.tsx index b4688159b7..ad39be12e8 100644 --- a/resources/scripts/components/admin/modules/billing/products/BillingCyclesManager.tsx +++ b/resources/scripts/components/admin/modules/billing/products/BillingCyclesManager.tsx @@ -93,7 +93,7 @@ const BillingCyclesManager = ({ cycles, basePrice, onChange }: BillingCyclesMana const handleToggleCycle = (index: number) => { const updated = [...cycles]; - updated[index] = { ...updated[index], isEnabled: !updated[index].isEnabled }; + updated[index] = { ...updated[index]!, isEnabled: !updated[index]!.isEnabled }; onChange(updated); }; diff --git a/resources/scripts/components/admin/modules/billing/products/CategoryForm.tsx b/resources/scripts/components/admin/modules/billing/products/CategoryForm.tsx index 48104ff5c6..45467395f7 100644 --- a/resources/scripts/components/admin/modules/billing/products/CategoryForm.tsx +++ b/resources/scripts/components/admin/modules/billing/products/CategoryForm.tsx @@ -5,7 +5,6 @@ import { Form, Formik, useFormikContext } from 'formik'; import { useNavigate, useParams } from 'react-router-dom'; import Field, { FieldRow } from '@/elements/Field'; import tw from 'twin.macro'; -import AdminContentBlock from '@/elements/AdminContentBlock'; import { Button } from '@/elements/button'; import type { ApplicationStore } from '@/state'; import AdminBox from '@/elements/AdminBox'; @@ -224,7 +223,7 @@ export default ({ category }: { category?: Category }) => { const formContent = ( let nextSelected = initialSelection; if (nextSelected.length === 0 && _eggs.length > 0) { - nextSelected = [_eggs[0].id]; + nextSelected = [_eggs[0]!.id!]; } setSelected(nextSelected); if (nextSelected.length > 0) { - setEggIdValue(nextSelected[0]); + setEggIdValue(nextSelected[0]!); setEggIdTouched(true); setAllowedEggsValue(nextSelected); setAllowedEggsTouched(true); @@ -62,7 +62,7 @@ export default ({ nestId, selectedEggIds = [], onEggSelectionChange }: Props) => let filtered = selectedEggIds.filter(id => validEggIds.has(id)); if (filtered.length === 0 && eggs.length > 0) { - filtered = [eggs[0].id]; + filtered = [eggs[0]!.id!]; } const isSameSelection = @@ -70,7 +70,7 @@ export default ({ nestId, selectedEggIds = [], onEggSelectionChange }: Props) => if (filtered.length > 0 && !isSameSelection) { setSelected(filtered); - setEggIdValue(filtered[0]); + setEggIdValue(filtered[0]!); setEggIdTouched(true); setAllowedEggsValue(filtered); setAllowedEggsTouched(true); @@ -100,7 +100,7 @@ export default ({ nestId, selectedEggIds = [], onEggSelectionChange }: Props) => setSelected(newSelected); // Update form values - setEggIdValue(newSelected[0]); // Primary egg is the first one + setEggIdValue(newSelected[0]!); // Primary egg is the first one setEggIdTouched(true); setAllowedEggsValue(newSelected); setAllowedEggsTouched(true); diff --git a/resources/scripts/components/admin/modules/billing/products/ProductForm.tsx b/resources/scripts/components/admin/modules/billing/products/ProductForm.tsx index 0eb01ceef4..ce2a1b40a8 100644 --- a/resources/scripts/components/admin/modules/billing/products/ProductForm.tsx +++ b/resources/scripts/components/admin/modules/billing/products/ProductForm.tsx @@ -5,7 +5,6 @@ import { Form, Formik } from 'formik'; import { Link, useNavigate, useParams } from 'react-router-dom'; import Field, { FieldRow } from '@/elements/Field'; import tw from 'twin.macro'; -import AdminContentBlock from '@/elements/AdminContentBlock'; import { Button } from '@/elements/button'; import type { ApplicationStore } from '@/state'; import AdminBox from '@/elements/AdminBox'; @@ -184,6 +183,7 @@ export default ({ product }: { product?: Product }) => { backup: product?.limits.backup ?? 0, database: product?.limits.database ?? 0, allocation: product?.limits.allocation ?? 1, + subdomain: product?.limits.subdomain ?? 1, }, }} validationSchema={object().shape({ @@ -203,6 +203,7 @@ export default ({ product }: { product?: Product }) => { backup: number().required().min(0), database: number().required().min(0), allocation: number().required().min(1), + subdomain: number().nullable().min(0), }), })} > @@ -345,6 +346,13 @@ export default ({ product }: { product?: Product }) => { label={'Allocation (Port) Limit'} description={'The amount of ports this product can have.'} /> + diff --git a/resources/scripts/components/admin/modules/customDomains/CustomDomainsRouter.tsx b/resources/scripts/components/admin/modules/customDomains/CustomDomainsRouter.tsx new file mode 100644 index 0000000000..cc17ea3ccf --- /dev/null +++ b/resources/scripts/components/admin/modules/customDomains/CustomDomainsRouter.tsx @@ -0,0 +1,40 @@ +import { Route, Routes } from 'react-router-dom'; +import { NotFound } from '@/elements/ScreenBlock'; +import AdminContentBlock from '@/elements/AdminContentBlock'; +import FlashMessageRender from '@/elements/FlashMessageRender'; +import { SubNavigation, SubNavigationLink } from '@admin/SubNavigation'; +import { CogIcon, GlobeAltIcon } from '@heroicons/react/outline'; +import DomainsContainer from './domains/DomainsContainer'; +import SettingsContainer from './settings/SettingsContainer'; + +export default () => { + return ( + +
    +
    +

    Custom Domains

    +

    + Manage domain inventory and Cloudflare credentials for automated DNS provisioning. +

    +
    +
    + + + + + + + + + + + + + + } /> + } /> + } /> + +
    + ); +}; diff --git a/resources/scripts/components/admin/modules/customDomains/domains/DomainsContainer.tsx b/resources/scripts/components/admin/modules/customDomains/domains/DomainsContainer.tsx new file mode 100644 index 0000000000..8d36cc043a --- /dev/null +++ b/resources/scripts/components/admin/modules/customDomains/domains/DomainsContainer.tsx @@ -0,0 +1,515 @@ +import { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCheck } from '@fortawesome/free-solid-svg-icons'; +import useFlash from '@/plugins/useFlash'; +import { useStoreState } from '@/state/hooks'; +import { Button } from '@/elements/button'; +import Input from '@/elements/Input'; +import SpinnerOverlay from '@/elements/SpinnerOverlay'; +import { + AdminCustomDomain, + CustomDomainApiKey, + createCustomDomain, + deleteCustomDomain, + getCustomDomainApiKeys, + getCustomDomainTargetOptions, + getCustomDomains, + updateCustomDomain, +} from '@/api/routes/admin/customDomains'; + +export default () => { + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { colors } = useStoreState(state => state.theme.data!); + + const [loading, setLoading] = useState(false); + const [domains, setDomains] = useState([]); + const [domain, setDomain] = useState(''); + const [zoneId, setZoneId] = useState(''); + const [apiKeyId, setApiKeyId] = useState(0); + const [serviceTag, setServiceTag] = useState(''); + const [eggServiceTags, setEggServiceTags] = useState>({}); + const [selectedEggForTagId, setSelectedEggForTagId] = useState(0); + const [selectedEggTagInput, setSelectedEggTagInput] = useState(''); + const [allowedNestIds, setAllowedNestIds] = useState([]); + const [allowedEggIds, setAllowedEggIds] = useState([]); + const [apiKeys, setApiKeys] = useState([]); + const [nests, setNests] = useState>([]); + const [eggs, setEggs] = useState< + Array<{ id: number; name: string; nest_id: number; nest_name: string; default_service_tag: string | null }> + >([]); + + const loadDomains = async () => { + const rows = await getCustomDomains(); + setDomains(rows); + }; + + const loadOptions = async () => { + const [keys, options] = await Promise.all([getCustomDomainApiKeys(), getCustomDomainTargetOptions()]); + setApiKeys(keys); + setNests(options.nests.map(nest => ({ id: nest.id, name: nest.name }))); + setEggs( + options.eggs.map(egg => ({ + id: egg.id, + name: egg.name, + nest_id: egg.nest_id, + nest_name: egg.nest_name || `Nest #${egg.nest_id}`, + default_service_tag: egg.default_service_tag, + })), + ); + + if (keys[0] && !apiKeyId) { + setApiKeyId(keys[0].id); + } + }; + + const toggleNest = (id: number) => { + setAllowedNestIds(current => (current.includes(id) ? current.filter(item => item !== id) : current.concat(id))); + }; + + const toggleEgg = (id: number) => { + setAllowedEggIds(current => (current.includes(id) ? current.filter(item => item !== id) : current.concat(id))); + }; + + const filteredEggs = eggs.filter(egg => allowedNestIds.length === 0 || allowedNestIds.includes(egg.nest_id)); + + const selectedEggForTag = eggs.find(egg => egg.id === selectedEggForTagId); + + useEffect(() => { + clearFlashes('admin:custom-domains'); + setLoading(true); + + Promise.all([loadDomains(), loadOptions()]) + .catch(error => clearAndAddHttpError({ key: 'admin:custom-domains', error })) + .finally(() => setLoading(false)); + }, []); + + const onCreate = async () => { + if (!domain.trim()) { + return; + } + + clearFlashes('admin:custom-domains'); + setLoading(true); + + try { + await createCustomDomain({ + domain: domain.trim().toLowerCase(), + cloudflare_zone_id: zoneId.trim() || null, + api_key_id: apiKeyId || null, + allowed_nest_ids: allowedNestIds, + allowed_egg_ids: allowedEggIds, + service_tag: serviceTag.trim() || null, + egg_service_tags: eggServiceTags, + enabled: true, + }); + + setDomain(''); + setZoneId(''); + setServiceTag(''); + setEggServiceTags({}); + setSelectedEggForTagId(0); + setSelectedEggTagInput(''); + setAllowedNestIds([]); + setAllowedEggIds([]); + + await loadDomains(); + } catch (error) { + clearAndAddHttpError({ key: 'admin:custom-domains', error }); + } finally { + setLoading(false); + } + }; + + const onToggleEnabled = async (row: AdminCustomDomain) => { + clearFlashes('admin:custom-domains'); + setLoading(true); + + try { + await updateCustomDomain(row.id, { enabled: !row.enabled }); + await loadDomains(); + } catch (error) { + clearAndAddHttpError({ key: 'admin:custom-domains', error }); + } finally { + setLoading(false); + } + }; + + const onDelete = async (id: number) => { + clearFlashes('admin:custom-domains'); + setLoading(true); + + try { + await deleteCustomDomain(id); + await loadDomains(); + } catch (error) { + clearAndAddHttpError({ key: 'admin:custom-domains', error }); + } finally { + setLoading(false); + } + }; + + return ( + <> + + +
    +
    + setDomain(e.currentTarget.value)} + placeholder={'example.com'} + /> +
    +
    + setZoneId(e.currentTarget.value)} + placeholder={'Cloudflare zone ID (optional)'} + /> +
    +
    + +
    +
    + +
    +
    + +
    +
    + setServiceTag(e.currentTarget.value.toLowerCase())} + placeholder={'Default service tag (e.g. _minecraft._)'} + /> +
    +
    + SRV is reliable for Minecraft-family eggs. Rust and most other eggs should use CNAME and connect + with :port. +
    +
    + Leave nests/eggs unselected to allow all. SRV tags only affect Minecraft-family servers. +
    +
    + +
    +
    +
    Per-Egg Service Tag Override
    +
    +
    + Select an egg to override its service tag. The field auto-fills with the egg default tag when + available. +
    +
    + + + setSelectedEggTagInput(e.currentTarget.value.toLowerCase())} + placeholder={selectedEggForTag?.default_service_tag || 'e.g. _minecraft._'} + disabled={!selectedEggForTagId} + /> + +
    + + +
    +
    +
    + {selectedEggForTag?.default_service_tag + ? `Detected default for this egg: ${selectedEggForTag.default_service_tag}` + : 'No SRV default detected for this egg. Recommended: CNAME + :port for non-Minecraft games.'} +
    +
    + {Object.keys(eggServiceTags).length < 1 ? ( +
    No per-egg overrides configured.
    + ) : ( + Object.entries(eggServiceTags).map(([eggId, tag]) => { + const egg = eggs.find(item => String(item.id) === eggId); + + return ( +
    + {egg?.name || `Egg #${eggId}`} → {tag} +
    + ); + }) + )} +
    +
    + +
    +
    +
    +

    Allowed Nests

    +
    + + | + +
    +
    +
    + {nests.map(nest => ( + + ))} +
    +
    + +
    +
    +

    + Allowed Eggs + {allowedNestIds.length > 0 && ( + + ({filteredEggs.length} in selected nests) + + )} +

    +
    + + | + +
    +
    + {filteredEggs.length < 1 ? ( +
    + {allowedNestIds.length > 0 ? 'No eggs found in selected nests.' : 'No eggs available.'} +
    + ) : ( +
    + {filteredEggs.map(egg => ( + + ))} +
    + )} +
    +
    + +
    + {domains.length < 1 && ( +
    + No custom domains configured yet. +
    + )} + + {domains.map(row => ( +
    +
    +
    {row.domain}
    +
    + Zone: {row.cloudflare_zone_id || 'Auto-resolve'} • API key: {row.api_key_name || 'none'} +
    +
    + Service tag: {row.service_tag || 'auto (no explicit default)'} • Nests:{' '} + {row.allowed_nest_ids?.length || 0} • Eggs: {row.allowed_egg_ids?.length || 0} +
    +
    + Egg overrides: {Object.keys(row.egg_service_tags || {}).length} +
    +
    + +
    + + +
    +
    + ))} +
    + + ); +}; diff --git a/resources/scripts/components/admin/modules/customDomains/settings/SettingsContainer.tsx b/resources/scripts/components/admin/modules/customDomains/settings/SettingsContainer.tsx new file mode 100644 index 0000000000..e6ffa86261 --- /dev/null +++ b/resources/scripts/components/admin/modules/customDomains/settings/SettingsContainer.tsx @@ -0,0 +1,258 @@ +import { useEffect, useState } from 'react'; +import Input from '@/elements/Input'; +import { Button } from '@/elements/button'; +import SpinnerOverlay from '@/elements/SpinnerOverlay'; +import useFlash from '@/plugins/useFlash'; +import { useStoreState } from '@/state/hooks'; +import { + createCustomDomainApiKey, + deleteCustomDomainApiKey, + getCustomDomainSettings, + getCustomDomainApiKeys, + updateCustomDomainSettings, + updateCustomDomainApiKey, + type CustomDomainApiKey, +} from '@/api/routes/admin/customDomains'; + +export default () => { + const { clearFlashes, clearAndAddHttpError, addFlash } = useFlash(); + const { colors } = useStoreState(state => state.theme.data!); + + const [loading, setLoading] = useState(false); + const [apiKeys, setApiKeys] = useState([]); + const [name, setName] = useState(''); + const [token, setToken] = useState(''); + const [rateLimitCreatePerMinute, setRateLimitCreatePerMinute] = useState(10); + const [rateLimitSyncPerMinute, setRateLimitSyncPerMinute] = useState(5); + const [rateLimitBillingOptionsPerMinute, setRateLimitBillingOptionsPerMinute] = useState(20); + + const loadApiKeys = async () => { + const rows = await getCustomDomainApiKeys(); + setApiKeys(rows); + }; + + const loadSettings = async () => { + const data = await getCustomDomainSettings(); + setRateLimitCreatePerMinute(Number(data.rate_limit_create_per_minute || 10)); + setRateLimitSyncPerMinute(Number(data.rate_limit_sync_per_minute || 5)); + setRateLimitBillingOptionsPerMinute(Number(data.rate_limit_billing_options_per_minute || 20)); + }; + + useEffect(() => { + clearFlashes('admin:custom-domains'); + setLoading(true); + + Promise.all([loadApiKeys(), loadSettings()]) + .catch(error => clearAndAddHttpError({ key: 'admin:custom-domains', error })) + .finally(() => setLoading(false)); + }, []); + + const onSaveSecurityAndLimits = async () => { + clearFlashes('admin:custom-domains'); + setLoading(true); + + try { + await updateCustomDomainSettings({ + allow_wildcard: false, + max_wildcards_per_user: 1, + rate_limit_create_per_minute: rateLimitCreatePerMinute, + rate_limit_sync_per_minute: rateLimitSyncPerMinute, + rate_limit_billing_options_per_minute: rateLimitBillingOptionsPerMinute, + }); + + addFlash({ + key: 'admin:custom-domains', + type: 'success', + message: 'Security and rate limit settings saved.', + }); + } catch (error) { + clearAndAddHttpError({ key: 'admin:custom-domains', error }); + } finally { + setLoading(false); + } + }; + + const onCreate = async () => { + clearFlashes('admin:custom-domains'); + setLoading(true); + + try { + await createCustomDomainApiKey({ name: name.trim(), token: token.trim(), enabled: true }); + setName(''); + setToken(''); + await loadApiKeys(); + + addFlash({ + key: 'admin:custom-domains', + type: 'success', + message: 'Cloudflare API key saved.', + }); + } catch (error) { + clearAndAddHttpError({ key: 'admin:custom-domains', error }); + } finally { + setLoading(false); + } + }; + + const onToggle = async (row: CustomDomainApiKey) => { + clearFlashes('admin:custom-domains'); + setLoading(true); + + try { + await updateCustomDomainApiKey(row.id, { enabled: !row.enabled }); + await loadApiKeys(); + } catch (error) { + clearAndAddHttpError({ key: 'admin:custom-domains', error }); + } finally { + setLoading(false); + } + }; + + const onDelete = async (id: number) => { + clearFlashes('admin:custom-domains'); + setLoading(true); + + try { + await deleteCustomDomainApiKey(id); + await loadApiKeys(); + } catch (error) { + clearAndAddHttpError({ key: 'admin:custom-domains', error }); + } finally { + setLoading(false); + } + }; + + return ( + <> + + +
    +
    +

    Cloudflare API Keys

    +
    +

    + Add multiple Cloudflare API keys with human-readable names. Keys are encrypted at rest. +

    + +
    + setName(e.currentTarget.value)} + placeholder={'Key name (e.g. Main CF Account)'} + autoComplete={'off'} + /> + setToken(e.currentTarget.value)} + placeholder={'Cloudflare API token'} + autoComplete={'off'} + /> + +
    + +
    + {apiKeys.length < 1 && ( +
    + No API keys configured yet. +
    + )} + + {apiKeys.map(row => ( +
    +
    +
    {row.name}
    +
    {row.enabled ? 'Enabled' : 'Disabled'}
    +
    + +
    + + +
    +
    + ))} +
    +
    + +
    +
    +

    Security & Rate Limits

    +
    +

    + Wildcards are fully disabled. Configure API rate limits for custom domain endpoints below. +

    +
    + Rate limits are scoped per authenticated user UUID (fallback to client IP if unauthenticated), not + per server and not a single global bucket for all users. +
    + +
    +
    + + setRateLimitCreatePerMinute(Number(e.currentTarget.value || 1))} + /> +
    + +
    + + setRateLimitSyncPerMinute(Number(e.currentTarget.value || 1))} + /> +
    + +
    + + setRateLimitBillingOptionsPerMinute(Number(e.currentTarget.value || 1))} + /> +
    +
    + +
    + +
    +
    + + ); +}; diff --git a/resources/scripts/components/admin/modules/email/EmailActivityLog.tsx b/resources/scripts/components/admin/modules/email/EmailActivityLog.tsx index d723b63f6f..4148d9dbdb 100644 --- a/resources/scripts/components/admin/modules/email/EmailActivityLog.tsx +++ b/resources/scripts/components/admin/modules/email/EmailActivityLog.tsx @@ -56,7 +56,15 @@ export default () => { const { colors } = useStoreState(state => state.theme.data!); // Filter states - const [filters, setFilters] = useState({ + const [filters, setFilters] = useState<{ + status: string; + template_key: string; + recipient: string; + only_failures: boolean; + date_from: string; + date_to: string; + page: number; + }>({ status: searchParams.get('status') || '', template_key: searchParams.get('template_key') || '', recipient: searchParams.get('recipient') || '', @@ -74,7 +82,7 @@ export default () => { const loadData = async () => { setLoading(true); try { - const data = await getEmailLogs(filters); + const data = await getEmailLogs(filters as import('@/api/routes/admin/email').EmailLogFilters); setLogs(data); } catch (error: any) { addFlash({ @@ -157,8 +165,8 @@ export default () => { const from = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); const newFilters = { ...filters, - date_from: from.toISOString().split('T')[0], - date_to: now.toISOString().split('T')[0], + date_from: from.toISOString().split('T')[0] ?? '', + date_to: now.toISOString().split('T')[0] ?? '', page: 1, }; setFilters(newFilters); diff --git a/resources/scripts/components/admin/modules/email/EmailRouter.tsx b/resources/scripts/components/admin/modules/email/EmailRouter.tsx index a6f1a1b1bd..6d602fa76c 100644 --- a/resources/scripts/components/admin/modules/email/EmailRouter.tsx +++ b/resources/scripts/components/admin/modules/email/EmailRouter.tsx @@ -13,28 +13,27 @@ export default () => { return ( - - - - - - + + + + + + - } /> @@ -42,7 +41,7 @@ export default () => { } /> @@ -50,7 +49,7 @@ export default () => { } /> @@ -58,7 +57,7 @@ export default () => { } /> @@ -66,7 +65,7 @@ export default () => { } /> diff --git a/resources/scripts/components/admin/modules/email/NotificationSettings.tsx b/resources/scripts/components/admin/modules/email/NotificationSettings.tsx index a8711e6a67..61e3b82cfd 100644 --- a/resources/scripts/components/admin/modules/email/NotificationSettings.tsx +++ b/resources/scripts/components/admin/modules/email/NotificationSettings.tsx @@ -3,7 +3,6 @@ import { getNotificationSettings, updateNotificationSetting, type EmailNotificationSetting, - type NotificationSettingsResponse, } from '@/api/routes/admin/email'; import useFlash from '@/plugins/useFlash'; import { Button } from '@/elements/button'; @@ -16,7 +15,7 @@ export default () => { const [categories, setCategories] = useState>({}); const [toggling, setToggling] = useState>({}); const { clearFlashes, addFlash } = useFlash(); - const { secondary } = useStoreState((state) => state.theme.data!.colors); + const { secondary } = useStoreState(state => state.theme.data!.colors); useEffect(() => { loadSettings(); @@ -47,9 +46,9 @@ export default () => { // Update local state const updatedCategories = { ...categories }; - const category = updatedCategories[setting.category]; - const index = category.findIndex((s) => s.id === setting.id); - category[index].enabled = !setting.enabled; + const category = updatedCategories[setting.category]!; + const index = category.findIndex(s => s.id === setting.id); + category[index]!.enabled = !setting.enabled; setCategories(updatedCategories); addFlash({ @@ -70,8 +69,8 @@ export default () => { if (loading) { return ( -
    - +
    +
    ); } @@ -83,37 +82,37 @@ export default () => { }; return ( -
    +
    {/* Category Groups */} {Object.entries(categories).map(([categoryKey, settings]) => ( -
    -

    +
    +

    {categoryTitles[categoryKey] || categoryKey}

    -
    - {settings.map((setting) => { +
    + {settings.map(setting => { const ItemToggle = setting.enabled ? Button.Success : Button.Danger; return (
    -
    -
    +
    +
    {setting.rate_limit_exempt && ( - + Rate Limit Exempt )}
    {setting.description && ( -

    {setting.description}

    +

    {setting.description}

    )} -

    +

    {setting.template_key}

    @@ -129,7 +128,7 @@ export default () => { }`} > {toggling[setting.template_key] ? ( - + ) : setting.enabled ? ( 'Enabled' ) : ( @@ -144,7 +143,7 @@ export default () => { ))} {Object.keys(categories).length === 0 && ( -
    No email notification types configured
    +
    No email notification types configured
    )}
    ); diff --git a/resources/scripts/components/admin/modules/email/ResendSettings.tsx b/resources/scripts/components/admin/modules/email/ResendSettings.tsx index 7a6506ef17..37a1aed176 100644 --- a/resources/scripts/components/admin/modules/email/ResendSettings.tsx +++ b/resources/scripts/components/admin/modules/email/ResendSettings.tsx @@ -53,7 +53,7 @@ export default () => { const resolveEmailResponseStatus = (response: EmailResponse): EmailStatus => response.status || (response.success ? 'sent' : 'failed'); - const getFlashType = (status: EmailStatus): 'success' | 'warning' | 'danger' => { + const getFlashType = (status: EmailStatus): 'success' | 'warning' | 'error' => { const tone = getEmailStatusPresentation(status).tone; if (tone === 'success') { @@ -64,7 +64,7 @@ export default () => { return 'warning'; } - return 'danger'; + return 'error'; }; const [activeTab, setActiveTab] = useState('overview'); @@ -126,7 +126,7 @@ export default () => { setResendUsage(data.resend_usage); setCustomMonthlyLimit( data.resend_plan.custom_monthly_limit !== null && - data.resend_plan.custom_monthly_limit !== undefined + data.resend_plan.custom_monthly_limit !== undefined ? String(data.resend_plan.custom_monthly_limit) : '', ); @@ -206,11 +206,7 @@ export default () => { return settings?.resend_plan; } - return ( - resendPlanOptions.find(plan => plan.key === resendPlan) || - resendPlanOptions[0] || - settings?.resend_plan - ); + return resendPlanOptions.find(plan => plan.key === resendPlan) || resendPlanOptions[0] || settings?.resend_plan; }, [resendPlanOptions, resendPlan, settings]); const activeUsage = useMemo(() => resendUsage, [resendUsage]); @@ -237,7 +233,7 @@ export default () => { (customMonthlyLimit || '') !== (initialPlan.custom_monthly_limit !== null && initialPlan.custom_monthly_limit !== undefined ? String(initialPlan.custom_monthly_limit) - : '') ) || + : '')) || (initialPlan && (customDailyLimit || '') !== (initialPlan.custom_daily_limit !== null && initialPlan.custom_daily_limit !== undefined @@ -304,7 +300,7 @@ export default () => { if (!settings) return; setSaving(true); clearFlashes('email:settings'); - setStatus('processing'); + setStatus('loading'); const payload: EmailSettingsUpdate = { enabled, @@ -352,14 +348,16 @@ export default () => { setResendPlanOptions(updated.resend_plans || []); setResendUsage(updated.resend_usage); setCustomMonthlyLimit( - updated.resend_plan.custom_monthly_limit !== null && updated.resend_plan.custom_monthly_limit !== undefined + updated.resend_plan.custom_monthly_limit !== null && + updated.resend_plan.custom_monthly_limit !== undefined ? String(updated.resend_plan.custom_monthly_limit) - : '' + : '', ); setCustomDailyLimit( - updated.resend_plan.custom_daily_limit !== null && updated.resend_plan.custom_daily_limit !== undefined + updated.resend_plan.custom_daily_limit !== null && + updated.resend_plan.custom_daily_limit !== undefined ? String(updated.resend_plan.custom_daily_limit) - : '' + : '', ); setResendApiKeyInput(''); setSmtpPasswordInput(''); @@ -398,7 +396,7 @@ export default () => { setEnabled(newEnabled); setSavingEnabled(true); clearFlashes('email:settings'); - setStatus('processing'); + setStatus('loading'); updateSettings({ enabled: newEnabled }) .then(updated => { @@ -425,7 +423,7 @@ export default () => { clearFlashes('email:settings:resend'); setClearingApiKey(true); - setStatus('processing'); + setStatus('loading'); updateSettings({ api_key: '', clear_api_key: true }) .then(updated => { @@ -451,7 +449,7 @@ export default () => { clearFlashes('email:settings:smtp'); setClearingSmtpPassword(true); - setStatus('processing'); + setStatus('loading'); updateSettings({ smtp_password: '', clear_smtp_password: true }) .then(updated => { @@ -477,7 +475,7 @@ export default () => { clearFlashes('email:settings:smtp'); setResettingSmtp(true); - setStatus('processing'); + setStatus('loading'); const payload: EmailSettingsUpdate = { smtp_host: '', @@ -565,7 +563,7 @@ export default () => { if (!testRecipient) { addFlash({ key: 'email:settings:test', - type: 'danger', + type: 'error', message: 'Enter a recipient email first.', }); return; @@ -977,7 +975,10 @@ export default () => { ? activeUsage?.daily_limit ?? activePlan?.daily_limit ?? null : null } - applies={Boolean(activePlan?.enforce_daily && (activePlan?.daily_limit !== null || activeUsage?.daily_limit !== null))} + applies={Boolean( + activePlan?.enforce_daily && + (activePlan?.daily_limit !== null || activeUsage?.daily_limit !== null), + )} /> { ? activeUsage?.monthly_limit ?? activePlan?.monthly_limit ?? null : null } - applies={Boolean(activePlan?.enforce_monthly && (activePlan?.monthly_limit !== null || activeUsage?.monthly_limit !== null))} + applies={Boolean( + activePlan?.enforce_monthly && + (activePlan?.monthly_limit !== null || + activeUsage?.monthly_limit !== null), + )} />

    - Source: {activeUsage?.source === 'provider' ? 'Provider reported' : 'Internal fallback'} - {activeUsage?.synced_at ? ` • Updated ${new Date(activeUsage.synced_at).toLocaleString()}` : ''} + Source:{' '} + {activeUsage?.source === 'provider' ? 'Provider reported' : 'Internal fallback'} + {activeUsage?.synced_at + ? ` • Updated ${new Date(activeUsage.synced_at).toLocaleString()}` + : ''}

    {settings.resend_rate_limit && (

    - Rate limit — limit: {settings.resend_rate_limit.limit ?? 'n/a'}, remaining: {settings.resend_rate_limit.remaining ?? 'n/a'}, reset: {settings.resend_rate_limit.reset ?? 'n/a'}, retry-after: {settings.resend_rate_limit.retry_after ?? 'n/a'} + Rate limit — limit: {settings.resend_rate_limit.limit ?? 'n/a'}, remaining:{' '} + {settings.resend_rate_limit.remaining ?? 'n/a'}, reset:{' '} + {settings.resend_rate_limit.reset ?? 'n/a'}, retry-after:{' '} + {settings.resend_rate_limit.retry_after ?? 'n/a'}

    )}

    diff --git a/resources/scripts/components/admin/modules/email/TemplateViewer.tsx b/resources/scripts/components/admin/modules/email/TemplateViewer.tsx index 5c822045f1..fab37c8a4e 100644 --- a/resources/scripts/components/admin/modules/email/TemplateViewer.tsx +++ b/resources/scripts/components/admin/modules/email/TemplateViewer.tsx @@ -18,7 +18,6 @@ import { faEnvelopeOpenText, faRedo, faSave, - faTimes, faChevronDown, faChevronUp, faCheck, @@ -87,9 +86,14 @@ const ViewToggleBtn = styled.button<{ $active: boolean }>` ${tw`flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium transition-colors`} background-color: ${({ $active }) => ($active ? 'rgba(255,255,255,0.14)' : 'transparent')}; color: ${({ $active }) => ($active ? '#ffffff' : '#9ca3af')}; - border-right: 1px solid rgba(255,255,255,0.08); - &:last-child { border-right: 0; } - &:hover { background-color: rgba(255,255,255,0.1); color: #ffffff; } + border-right: 1px solid rgba(255, 255, 255, 0.08); + &:last-child { + border-right: 0; + } + &:hover { + background-color: rgba(255, 255, 255, 0.1); + color: #ffffff; + } `; const ActionButton = styled.button<{ $variant?: 'primary' | 'danger' }>` @@ -101,7 +105,10 @@ const ActionButton = styled.button<{ $variant?: 'primary' | 'danger' }>` background-color: ${({ $variant }) => $variant === 'primary' ? '#1d4ed8' : $variant === 'danger' ? '#b91c1c' : 'rgba(255,255,255,0.14)'}; } - &:disabled { opacity: 0.45; cursor: not-allowed; } + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } `; const SaveFeedback = styled.span<{ $ok: boolean }>` @@ -161,7 +168,9 @@ const VarsDivider = styled.div` const VarsPanelHeader = styled.button` ${tw`w-full flex items-center justify-between px-3 py-2 text-xs font-semibold uppercase tracking-wider transition-colors`} color: #6b7280; - &:hover { color: #9ca3af; } + &:hover { + color: #9ca3af; + } `; const VarsList = styled.div` @@ -224,7 +233,7 @@ export default () => { // Editor source state — loaded automatically when a template is selected const [sourceContent, setSourceContent] = useState(null); const [sourceLoading, setSourceLoading] = useState(false); - const [savedContent, setSavedContent] = useState(null); + const [, setSavedContent] = useState(null); const [saving, setSaving] = useState(false); const [saveStatus, setSaveStatus] = useState<{ ok: boolean; msg: string } | null>(null); const [isCustomized, setIsCustomized] = useState(false); @@ -234,7 +243,7 @@ export default () => { const [varsOpen, setVarsOpen] = useState(false); const { clearFlashes, addFlash } = useFlash(); - const { colors } = useStoreState((state) => state.theme.data!); + const { colors } = useStoreState(state => state.theme.data!); // Ref to pull current editor text on demand const fetchEditorContent = useRef Promise)>(null); @@ -246,7 +255,7 @@ export default () => { .then(({ templates: list }) => { setTemplates(list); if (list.length > 0) { - loadTemplate(list[0]); + loadTemplate(list[0]!); } }) .catch(() => @@ -267,9 +276,13 @@ export default () => { setPreviewHtml(null); setPreviewLoading(true); previewEmailTemplate(tpl.key) - .then((rendered) => setPreviewHtml(rendered)) + .then(rendered => setPreviewHtml(rendered)) .catch(() => - addFlash({ key: 'email:templates', type: 'error', message: `Failed to render preview for "${tpl.label}".` }), + addFlash({ + key: 'email:templates', + type: 'error', + message: `Failed to render preview for "${tpl.label}".`, + }), ) .finally(() => setPreviewLoading(false)); @@ -282,7 +295,11 @@ export default () => { setIsCustomized(is_customized); }) .catch(() => - addFlash({ key: 'email:templates', type: 'error', message: `Failed to load source for "${tpl.label}".` }), + addFlash({ + key: 'email:templates', + type: 'error', + message: `Failed to load source for "${tpl.label}".`, + }), ) .finally(() => setSourceLoading(false)); }; @@ -292,18 +309,11 @@ export default () => { setPreviewHtml(null); setPreviewLoading(true); previewEmailTemplate(selected.key) - .then((rendered) => setPreviewHtml(rendered)) - .catch(() => - addFlash({ key: 'email:templates', type: 'error', message: 'Failed to refresh preview.' }), - ) + .then(rendered => setPreviewHtml(rendered)) + .catch(() => addFlash({ key: 'email:templates', type: 'error', message: 'Failed to refresh preview.' })) .finally(() => setPreviewLoading(false)); }; - const discardChanges = () => { - setSourceContent(savedContent); - setSaveStatus(null); - }; - const handleSave = useCallback(async () => { if (!selected || !fetchEditorContent.current) return; const content = await fetchEditorContent.current(); @@ -316,14 +326,12 @@ export default () => { setSourceContent(content); setIsCustomized(customized); // Update the template list entry so the sidebar badge refreshes - setTemplates((prev) => - prev.map((t) => (t.key === selected.key ? { ...t, is_customized: customized } : t)), - ); + setTemplates(prev => prev.map(t => (t.key === selected.key ? { ...t, is_customized: customized } : t))); // Refresh preview to reflect saved changes setPreviewHtml(null); setPreviewLoading(true); previewEmailTemplate(selected.key) - .then((rendered) => setPreviewHtml(rendered)) + .then(rendered => setPreviewHtml(rendered)) .finally(() => setPreviewLoading(false)); }) .catch(() => setSaveStatus({ ok: false, msg: 'Save failed — check file permissions.' })) @@ -337,9 +345,7 @@ export default () => { revertEmailTemplate(selected.key) .then(() => { setIsCustomized(false); - setTemplates((prev) => - prev.map((t) => (t.key === selected.key ? { ...t, is_customized: false } : t)), - ); + setTemplates(prev => prev.map(t => (t.key === selected.key ? { ...t, is_customized: false } : t))); // Reload the default source and refresh preview setSourceLoading(true); getEmailTemplateSource(selected.key) @@ -349,14 +355,18 @@ export default () => { setIsCustomized(is_customized); }) .catch(() => - addFlash({ key: 'email:templates', type: 'error', message: 'Failed to reload template source after revert.' }), + addFlash({ + key: 'email:templates', + type: 'error', + message: 'Failed to reload template source after revert.', + }), ) .finally(() => setSourceLoading(false)); setPreviewHtml(null); setPreviewLoading(true); previewEmailTemplate(selected.key) - .then((rendered) => setPreviewHtml(rendered)) + .then(rendered => setPreviewHtml(rendered)) .finally(() => setPreviewLoading(false)); setSaveStatus({ ok: true, msg: 'Reverted to default.' }); @@ -367,7 +377,11 @@ export default () => { const switchViewMode = (mode: ViewMode) => { setViewMode(mode); - try { localStorage.setItem(VIEW_MODE_KEY, mode); } catch { /* ignore */ } + try { + localStorage.setItem(VIEW_MODE_KEY, mode); + } catch { + /* ignore */ + } }; // ── Derived ─────────────────────────────────────────────────────────────── @@ -381,7 +395,7 @@ export default () => { if (loading) { return (

    - +
    ); } @@ -398,15 +412,27 @@ export default () => { {/* View mode toggles */} - switchViewMode('split')} title='Split view'> + switchViewMode('split')} + title="Split view" + > Split - switchViewMode('editor')} title='Editor only'> + switchViewMode('editor')} + title="Editor only" + > Editor - switchViewMode('preview')} title='Preview only'> + switchViewMode('preview')} + title="Preview only" + > Preview @@ -417,11 +443,7 @@ export default () => { {selected.label} ({selected.key}) - {isCustomized ? ( - custom - ) : ( - default - )} + {isCustomized ? custom : default} )} @@ -438,19 +460,19 @@ export default () => { {/* Revert to default */} {selected && isCustomized && ( - {reverting ? : } + {reverting ? : } Revert to Default )} {/* Refresh preview */} {selected && viewMode !== 'editor' && ( - + Refresh @@ -458,8 +480,13 @@ export default () => { {/* Save */} {selected && sourceContent !== null && ( - - {saving ? : } + + {saving ? : } Save )} @@ -474,7 +501,7 @@ export default () => { {Object.entries(grouped).map(([category, items]) => (
    {category} - {items.map((tpl) => ( + {items.map(tpl => ( { {/* Variable docs in sidebar */} {variables.length > 0 && ( - setVarsOpen((v) => !v)}> + setVarsOpen(v => !v)}> Variables ({variables.length}) {varsOpen && ( - {variables.map((v) => ( + {variables.map(v => (
    {v.name} {v.required && ( - req + + req + )}
    -
    {v.description}
    +
    + {v.description} +
    {v.example && ( -
    +
    e.g. {v.example}
    )} @@ -529,14 +566,16 @@ export default () => { {sourceLoading ? ( - + ) : sourceContent !== null ? (
    { fetchEditorContent.current = cb; }} + fetchContent={cb => { + fetchEditorContent.current = cb; + }} onContentSaved={handleSave} style={{ minHeight: '560px' }} /> @@ -557,17 +596,17 @@ export default () => { {previewLoading ? ( - + ) : previewHtml !== null ? ( ) : ( - + Select a template to preview it here. )} diff --git a/resources/scripts/components/admin/modules/email/VerificationRestrictions.tsx b/resources/scripts/components/admin/modules/email/VerificationRestrictions.tsx index a0ed115faf..471c40f3c9 100644 --- a/resources/scripts/components/admin/modules/email/VerificationRestrictions.tsx +++ b/resources/scripts/components/admin/modules/email/VerificationRestrictions.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { getVerificationRules, updateVerificationRules } from '@/api/routes/admin/email'; import { DEFAULT_EMAIL_VERIFICATION_RULES, @@ -20,12 +20,17 @@ const VerificationRestrictions = () => { const { clearAndAddHttpError, clearFlashes, addFlash } = useFlash(); const { background } = useStoreState(state => state.theme.data!.colors); const [rules, setRules] = useState(() => cloneRules(DEFAULT_EMAIL_VERIFICATION_RULES)); - const [initialRules, setInitialRules] = useState(() => cloneRules(DEFAULT_EMAIL_VERIFICATION_RULES)); + const [initialRules, setInitialRules] = useState(() => + cloneRules(DEFAULT_EMAIL_VERIFICATION_RULES), + ); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const hasChanges = useMemo(() => { - return JSON.stringify(normalizeVerificationRules(initialRules)) !== JSON.stringify(normalizeVerificationRules(rules)); + return ( + JSON.stringify(normalizeVerificationRules(initialRules)) !== + JSON.stringify(normalizeVerificationRules(rules)) + ); }, [initialRules, rules]); useEffect(() => { diff --git a/resources/scripts/components/admin/modules/email/status.ts b/resources/scripts/components/admin/modules/email/status.ts index ad440f55a3..612304e986 100644 --- a/resources/scripts/components/admin/modules/email/status.ts +++ b/resources/scripts/components/admin/modules/email/status.ts @@ -44,7 +44,7 @@ const EMAIL_STATUS_PRESENTATIONS: Record = { export const getEmailStatusPresentation = (status?: string | null): EmailStatusPresentation => { if (!status) { - return EMAIL_STATUS_PRESENTATIONS.failed; + return EMAIL_STATUS_PRESENTATIONS.failed!; } return ( diff --git a/resources/scripts/components/admin/modules/extensions/CatalogCard.tsx b/resources/scripts/components/admin/modules/extensions/CatalogCard.tsx new file mode 100644 index 0000000000..b5c6a5c94a --- /dev/null +++ b/resources/scripts/components/admin/modules/extensions/CatalogCard.tsx @@ -0,0 +1,771 @@ +import { useEffect, useMemo, useState } from 'react'; +import classNames from 'classnames'; +import { useStoreState } from '@/state/hooks'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faBell, + faBolt, + faChartLine, + faCloud, + faCogs, + faCube, + faDatabase, + faFile, + faFolder, + faGamepad, + faGlobe, + faKey, + faLock, + faPuzzlePiece, + faRobot, + faScroll, + faServer, + faShieldHalved, + faTerminal, + faToggleOff, + faToggleOn, + faUsers, + faWrench, + faLink, + faDownload, + faTrash, + faCog, + faTriangleExclamation, + faCheck, + faArrowsRotate, +} from '@fortawesome/free-solid-svg-icons'; +import { faDiscord } from '@fortawesome/free-brands-svg-icons'; +import { Button } from '@/elements/button'; +import Modal from '@/elements/Modal'; +import Input, { Textarea } from '@/elements/Input'; +import Select from '@/elements/Select'; +import Spinner from '@/elements/Spinner'; +import useFlash from '@/plugins/useFlash'; +import { + EggOption, + ExtensionData, + ExtensionSettingField, + NestOption, + getNestsAndEggs, + installExtension, + toggleExtension, + uninstallExtension, + updateExtension, + upgradeExtension, +} from '@/api/routes/admin/extensions'; + +interface PackageActionState { + extensionId: string; + extensionName: string; + type: 'install' | 'uninstall' | 'update'; +} + +interface Props { + extension: ExtensionData; + currentPanelVersion?: string; + activePackageAction: PackageActionState | null; + isOperationRunning?: boolean; + isSelected?: boolean; + onToggleSelect?: () => void; + onRefresh: () => void; + onPackageActionStart: (action: PackageActionState) => void; + onPackageActionEnd: (extensionId: string) => void; +} + +const iconMap: Record = { + puzzle: faPuzzlePiece, + users: faUsers, + gamepad: faGamepad, + cube: faCube, + server: faServer, + discord: faDiscord, + link: faLink, + wrench: faWrench, + shield: faShieldHalved, + terminal: faTerminal, + globe: faGlobe, + database: faDatabase, + chart: faChartLine, + bell: faBell, + robot: faRobot, + cloud: faCloud, + folder: faFolder, + file: faFile, + key: faKey, + bolt: faBolt, + cogs: faCogs, + lock: faLock, + scroll: faScroll, +}; + +export default ({ + extension, + currentPanelVersion, + activePackageAction, + isOperationRunning = false, + isSelected = false, + onToggleSelect, + onRefresh, + onPackageActionStart, + onPackageActionEnd, +}: Props) => { + const { colors } = useStoreState(state => state.theme.data!); + const primary = colors.primary; + const { addFlash, clearFlashes, clearAndAddHttpError } = useFlash(); + + const [loading, setLoading] = useState(false); + const [configOpen, setConfigOpen] = useState(false); + const [nestsAndEggs, setNestsAndEggs] = useState<{ nests: NestOption[]; eggs: EggOption[] } | null>(null); + const [selectedNests, setSelectedNests] = useState(extension.allowedNests || []); + const [selectedEggs, setSelectedEggs] = useState(extension.allowedEggs || []); + const [settings, setSettings] = useState>(extension.settings || {}); + + const icon = iconMap[extension.icon] || faPuzzlePiece; + const manageable = Boolean(extension.installed) || extension.status === 'core'; + const compatiblePanelVersions = useMemo( + () => + (extension.compatiblePanelVersions ?? []).filter((version): version is string => version.trim().length > 0), + [extension.compatiblePanelVersions], + ); + const hasPanelRestrictions = compatiblePanelVersions.length > 0; + const canEvaluatePanelCompatibility = Boolean(currentPanelVersion); + const currentPanelSupported = + !hasPanelRestrictions || !currentPanelVersion || compatiblePanelVersions.includes(currentPanelVersion); + const installBlockedByPanelVersion = + Boolean(extension.installable && extension.source?.repositoryId) && + hasPanelRestrictions && + canEvaluatePanelCompatibility && + !currentPanelSupported; + const compatibilityHeading = !hasPanelRestrictions + ? 'Compatible with any panel version' + : installBlockedByPanelVersion + ? 'Unsupported on this panel version' + : canEvaluatePanelCompatibility + ? 'Supports the current panel version' + : 'Declared supported panel versions'; + const compatibilityMessage = !hasPanelRestrictions + ? 'This package does not declare panel version restrictions.' + : installBlockedByPanelVersion + ? extension.installable + ? `This package cannot be installed on panel ${currentPanelVersion}. Install is blocked until the panel version matches one of the supported releases below.` + : `This installed package does not list panel ${currentPanelVersion} as supported. Update the panel or package if you see compatibility issues.` + : canEvaluatePanelCompatibility + ? `Panel ${currentPanelVersion} is included in this package's supported version list.` + : 'This package declares support for the panel versions listed below.'; + const alpha = (color: string, opacity: string) => `${color}${opacity}`; + const cardStyle = { backgroundColor: colors.secondary, borderColor: colors.headers }; + const surfaceStyle = { backgroundColor: colors.background, borderColor: colors.headers }; + const accentSurfaceStyle = { backgroundColor: alpha(primary, '10'), borderColor: primary }; + const accentPillStyle = { + backgroundColor: alpha(primary, '16'), + borderColor: alpha(primary, '55'), + color: primary, + }; + const neutralPillStyle = { backgroundColor: colors.background, borderColor: colors.headers }; + const statusPillStyle = extension.status === 'core' ? accentPillStyle : neutralPillStyle; + const enabledPillStyle = extension.enabled ? accentPillStyle : neutralPillStyle; + const updatePillStyle = accentPillStyle; + const compatibilityBoxStyle = installBlockedByPanelVersion + ? { ...accentSurfaceStyle, borderStyle: 'dashed' } + : surfaceStyle; + const anotherPackageActionInProgress = + isOperationRunning || (activePackageAction !== null && activePackageAction.extensionId !== extension.id); + let packageActionNotice: string | null = null; + if (anotherPackageActionInProgress) { + packageActionNotice = + activePackageAction !== null + ? `Wait for ${activePackageAction.extensionName} to finish ${ + activePackageAction.type === 'install' ? 'installing' : 'uninstalling' + } before starting another extension install or uninstall.` + : 'An extension operation is already running. Wait for it to finish before starting another install, update, or uninstall.'; + } + + useEffect(() => { + if (!configOpen || nestsAndEggs) { + return; + } + + getNestsAndEggs() + .then(data => setNestsAndEggs(data)) + .catch(error => clearAndAddHttpError({ key: 'admin:extensions', error })); + }, [configOpen, nestsAndEggs]); + + useEffect(() => { + if (!configOpen) { + return; + } + + setSelectedNests(extension.allowedNests || []); + setSelectedEggs(extension.allowedEggs || []); + setSettings(extension.settings || {}); + }, [configOpen, extension.allowedEggs, extension.allowedNests, extension.settings]); + + const filteredEggs = useMemo( + () => nestsAndEggs?.eggs.filter(egg => selectedNests.length === 0 || selectedNests.includes(egg.nestId)) ?? [], + [nestsAndEggs, selectedNests], + ); + + const updateSetting = (key: string, value: unknown) => { + setSettings(current => ({ ...current, [key]: value })); + }; + + const handleToggle = () => { + setLoading(true); + clearFlashes('admin:extensions'); + + toggleExtension(extension.id) + .then(() => { + addFlash({ + key: 'admin:extensions', + type: 'success', + message: `${extension.name} has been ${ + extension.enabled ? 'disabled' : 'enabled' + } for eligible servers.`, + }); + onRefresh(); + }) + .catch(error => clearAndAddHttpError({ key: 'admin:extensions', error })) + .finally(() => setLoading(false)); + }; + + const handleInstall = () => { + if (packageActionNotice) { + clearFlashes('admin:extensions'); + addFlash({ + key: 'admin:extensions', + type: 'warning', + message: packageActionNotice, + }); + + return; + } + + if (!extension.source?.repositoryId) { + return; + } + + const confirmed = window.confirm( + extension.source.official + ? `Install ${extension.name} from ${extension.source.label}?` + : `Install ${extension.name} from ${extension.source.label}? Third-party repositories can execute arbitrary PHP and frontend code inside M12Labs.`, + ); + + if (!confirmed) { + return; + } + + setLoading(true); + clearFlashes('admin:extensions'); + onPackageActionStart({ extensionId: extension.id, extensionName: extension.name, type: 'install' }); + + installExtension(extension.id, extension.source.repositoryId) + .then(() => { + addFlash({ + key: 'admin:extensions', + type: 'success', + message: `${extension.name} was installed and M12Labs was rebuilt.`, + }); + onRefresh(); + }) + .catch(error => { + clearAndAddHttpError({ key: 'admin:extensions', error }); + }) + .finally(() => { + setLoading(false); + onPackageActionEnd(extension.id); + }); + }; + + const handleUninstall = () => { + if (packageActionNotice) { + clearFlashes('admin:extensions'); + addFlash({ + key: 'admin:extensions', + type: 'warning', + message: packageActionNotice, + }); + + return; + } + + if (!extension.canUninstall) { + return; + } + + const confirmed = window.confirm( + `Uninstall ${extension.name}? This restores the files it added to M12Labs and rebuilds the panel.`, + ); + + if (!confirmed) { + return; + } + + setLoading(true); + clearFlashes('admin:extensions'); + onPackageActionStart({ extensionId: extension.id, extensionName: extension.name, type: 'uninstall' }); + + uninstallExtension(extension.id) + .then(() => { + addFlash({ + key: 'admin:extensions', + type: 'success', + message: `${extension.name} was uninstalled and M12Labs was rebuilt.`, + }); + onRefresh(); + }) + .catch(error => { + clearAndAddHttpError({ key: 'admin:extensions', error }); + }) + .finally(() => { + setLoading(false); + onPackageActionEnd(extension.id); + }); + }; + + const handleUpdate = () => { + if (packageActionNotice) { + clearFlashes('admin:extensions'); + addFlash({ + key: 'admin:extensions', + type: 'warning', + message: packageActionNotice, + }); + + return; + } + + if (!extension.updateAvailable || !extension.source?.repositoryId) { + return; + } + + const confirmed = window.confirm( + `Update ${extension.name} from v${extension.version} to v${ + extension.latestVersion ?? 'latest' + }? The panel will be rebuilt after the update.`, + ); + + if (!confirmed) { + return; + } + + setLoading(true); + clearFlashes('admin:extensions'); + onPackageActionStart({ extensionId: extension.id, extensionName: extension.name, type: 'update' }); + + upgradeExtension(extension.id, extension.source.repositoryId) + .then(() => { + addFlash({ + key: 'admin:extensions', + type: 'success', + message: `${extension.name} was updated to v${ + extension.latestVersion ?? 'latest' + } and M12Labs was rebuilt.`, + }); + onRefresh(); + }) + .catch(error => { + clearAndAddHttpError({ key: 'admin:extensions', error }); + }) + .finally(() => { + setLoading(false); + onPackageActionEnd(extension.id); + }); + }; + + const handleSaveConfig = () => { + setLoading(true); + clearFlashes('admin:extensions'); + + updateExtension(extension.id, selectedNests, selectedEggs, settings) + .then(() => { + addFlash({ + key: 'admin:extensions', + type: 'success', + message: `${extension.name} configuration has been updated.`, + }); + setConfigOpen(false); + onRefresh(); + }) + .catch(error => clearAndAddHttpError({ key: 'admin:extensions', error })) + .finally(() => setLoading(false)); + }; + + const renderSettingField = (field: ExtensionSettingField) => { + const value = settings[field.key]; + + if (field.type === 'boolean') { + return ( + + ); + } + + return ( +
    + + {field.help &&

    {field.help}

    } +
    + {field.type === 'textarea' ? ( +