Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 54 additions & 9 deletions cli/src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,14 @@ export async function installCommand(extension: string, options: InstallOptions)
const root = getPterodactylRoot();
const spin = spinner("Preparing installation...");

let manifest: Manifest | null = null;
let backupPath: string | null = null;
let extDir: string | null = null;
let packagePath: string | null = null;
let destDir: string | null = null;

try {
// ── 1. Resolve source ──────────────────────────────────────────────────
let extDir: string;
let packagePath: string | null = null;

if (extension.endsWith(".rsx")) {
// Install from .rsx package file
Expand Down Expand Up @@ -62,7 +66,6 @@ export async function installCommand(extension: string, options: InstallOptions)
// ── 2. Load & validate manifest ────────────────────────────────────────
spin.text = "Validating extension...";
const manifestPath = path.join(extDir, "reshaped.yml");
let manifest: Manifest;

try {
manifest = loadManifest(manifestPath);
Expand Down Expand Up @@ -96,7 +99,6 @@ export async function installCommand(extension: string, options: InstallOptions)
spin.succeed(`Validated: ${chalk.cyan.bold(name)} v${version}`);

// ── 4. Create backup if reinstalling ───────────────────────────────────
let backupPath: string | null = null;
if (installed.has(id)) {
backupPath = await createBackup(id, root);
log.info(` Created backup at ${chalk.gray(backupPath)}`);
Expand All @@ -106,7 +108,7 @@ export async function installCommand(extension: string, options: InstallOptions)

// ── 5. Copy extension files ────────────────────────────────────────────
progress.tick("Copying extension files");
const destDir = getExtensionPath(id);
destDir = getExtensionPath(id);
file.ensureDir(destDir);
fs.copySync(extDir, destDir);

Expand Down Expand Up @@ -199,20 +201,35 @@ export async function installCommand(extension: string, options: InstallOptions)
log.info(` ${chalk.gray(`Registered ${hookCount} UI hook(s)`)}`);
}

// Clean up temp dir if extracted from package
if (packagePath && extDir !== destDir) {
fs.removeSync(extDir);
}
} catch (err: unknown) {
spin.fail("Installation failed");
const msg = err instanceof Error ? err.message : String(err);
log.error(msg);

// Rollback
const id = manifest?.meta?.id;
if (id) {
const rollbackSpin = spinner("Rolling back changes...");
try {
const installed = getInstalledExtensions(root);
await rollback(id, root, backupPath, installed.has(id));
rollbackSpin.succeed("Rollback completed successfully");
} catch (rollbackErr) {
rollbackSpin.fail("Rollback failed — manual cleanup may be required");
log.error(rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr));
}
}

if (process.env.RESHAPED_DEBUG && err instanceof Error) {
console.error(err.stack);
}

process.exit(1);
} finally {
// Clean up temp dir if extracted from package
if (packagePath && extDir && extDir !== destDir && file.isDir(extDir)) {
fs.removeSync(extDir);
}
}
}

Expand All @@ -236,6 +253,34 @@ async function createBackup(id: string, root: string): Promise<string> {
return backupPath;
}

async function rollback(id: string, root: string, backupPath: string | null, isUpdate: boolean) {
const extDir = getExtensionPath(id);
const phpDest = path.join(root, `app/ReshapedExtensions/${id}`);
const hooksDir = path.join(root, "resources/scripts/reshaped/extensions", id);

// 1. Remove partially installed files (only if new install or we have backup to restore)
if (!isUpdate || backupPath) {
if (file.isDir(extDir)) fs.removeSync(extDir);
if (file.isDir(phpDest)) fs.removeSync(phpDest);
if (file.isDir(hooksDir)) fs.removeSync(hooksDir);
}

// 2. Restore from backup if exists
if (backupPath && file.exists(backupPath)) {
const destParent = path.dirname(extDir);
fs.ensureDirSync(destParent);
shell(`tar -xzf "${backupPath}" -C "${destParent}"`, { silent: true });
log.info(` Restored from backup: ${path.basename(backupPath)}`);
}

// 3. Clear cache to be safe
try {
shell("php artisan config:clear && php artisan view:clear", { silent: true, cwd: root });
} catch {
/* Ignore */
}
}

async function installPhpComponents(manifest: Manifest, root: string) {
const destDir = getExtensionPath(manifest.meta.id);

Expand Down
2 changes: 1 addition & 1 deletion framework/.phpunit.result.cache
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"version":2,"defects":[],"times":{"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_setting_returns_default_when_not_set":0.044,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_can_store_and_retrieve_string_setting":0.003,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_can_store_and_retrieve_boolean_setting":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_can_store_and_retrieve_integer_setting":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_can_store_and_retrieve_float_setting":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_can_store_and_retrieve_json_setting":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_get_all_settings_returns_all_keys":0.002,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_forget_setting_removes_it":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_settings_are_scoped_per_extension":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_server_data_defaults_to_null":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_can_store_and_retrieve_server_data":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_server_data_is_scoped_per_server":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_forget_all_server_data_clears_server_entries":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_user_data_defaults_to_null":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_can_store_and_retrieve_user_data":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_cache_stores_and_retrieves_value":0.002,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_remember_calls_callback_when_missing":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_remember_uses_cache_on_subsequent_calls":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_forget_cache_removes_entry":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_can_send_user_notification":0.002,"Reshaped\\Framework\\Tests\\Unit\\PermissionServiceTest::test_admin_always_has_permission":0.073,"Reshaped\\Framework\\Tests\\Unit\\PermissionServiceTest::test_deny_default_blocks_regular_user":0.072,"Reshaped\\Framework\\Tests\\Unit\\PermissionServiceTest::test_grant_overrides_deny_default":0.072,"Reshaped\\Framework\\Tests\\Unit\\PermissionServiceTest::test_deny_overrides_allow_default":0.072,"Reshaped\\Framework\\Tests\\Unit\\PermissionServiceTest::test_reset_reverts_to_default":0.072,"Reshaped\\Framework\\Tests\\Unit\\PermissionServiceTest::test_sync_from_manifest_creates_permission_rows":0.001,"Reshaped\\Framework\\Tests\\Unit\\PermissionServiceTest::test_unknown_permission_defaults_to_deny":0.072,"Reshaped\\Framework\\Tests\\Feature\\ExtensionLifecycleTest::test_can_register_an_extension":0.001,"Reshaped\\Framework\\Tests\\Feature\\ExtensionLifecycleTest::test_registered_extension_appears_in_active_list":0.001,"Reshaped\\Framework\\Tests\\Feature\\ExtensionLifecycleTest::test_duplicate_registration_updates_version":0.003,"Reshaped\\Framework\\Tests\\Feature\\ExtensionLifecycleTest::test_can_deactivate_an_extension":0.001,"Reshaped\\Framework\\Tests\\Feature\\ExtensionLifecycleTest::test_can_unregister_an_extension":0.001,"Reshaped\\Framework\\Tests\\Feature\\ExtensionLifecycleTest::test_is_active_returns_false_for_unknown_extension":0,"Reshaped\\Framework\\Tests\\Feature\\ExtensionLifecycleTest::test_is_active_returns_true_for_active_extension":0.001}}
{"version":2,"defects":[],"times":{"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_setting_returns_default_when_not_set":0.019,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_can_store_and_retrieve_string_setting":0.002,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_can_store_and_retrieve_boolean_setting":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_can_store_and_retrieve_integer_setting":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_can_store_and_retrieve_float_setting":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_can_store_and_retrieve_json_setting":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_get_all_settings_returns_all_keys":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_forget_setting_removes_it":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_settings_are_scoped_per_extension":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_server_data_defaults_to_null":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_can_store_and_retrieve_server_data":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_server_data_is_scoped_per_server":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_forget_all_server_data_clears_server_entries":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_user_data_defaults_to_null":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_can_store_and_retrieve_user_data":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_cache_stores_and_retrieves_value":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_remember_calls_callback_when_missing":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_remember_uses_cache_on_subsequent_calls":0,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_forget_cache_removes_entry":0.001,"Reshaped\\Framework\\Tests\\Unit\\ExtensionLibraryTest::test_can_send_user_notification":0.001,"Reshaped\\Framework\\Tests\\Unit\\PermissionServiceTest::test_admin_always_has_permission":0.072,"Reshaped\\Framework\\Tests\\Unit\\PermissionServiceTest::test_deny_default_blocks_regular_user":0.071,"Reshaped\\Framework\\Tests\\Unit\\PermissionServiceTest::test_grant_overrides_deny_default":0.072,"Reshaped\\Framework\\Tests\\Unit\\PermissionServiceTest::test_deny_overrides_allow_default":0.072,"Reshaped\\Framework\\Tests\\Unit\\PermissionServiceTest::test_reset_reverts_to_default":0.072,"Reshaped\\Framework\\Tests\\Unit\\PermissionServiceTest::test_sync_from_manifest_creates_permission_rows":0.001,"Reshaped\\Framework\\Tests\\Unit\\PermissionServiceTest::test_unknown_permission_defaults_to_deny":0.071,"Reshaped\\Framework\\Tests\\Feature\\ExtensionLifecycleTest::test_can_register_an_extension":0.001,"Reshaped\\Framework\\Tests\\Feature\\ExtensionLifecycleTest::test_registered_extension_appears_in_active_list":0.001,"Reshaped\\Framework\\Tests\\Feature\\ExtensionLifecycleTest::test_duplicate_registration_updates_version":0.002,"Reshaped\\Framework\\Tests\\Feature\\ExtensionLifecycleTest::test_can_deactivate_an_extension":0.001,"Reshaped\\Framework\\Tests\\Feature\\ExtensionLifecycleTest::test_can_unregister_an_extension":0.001,"Reshaped\\Framework\\Tests\\Feature\\ExtensionLifecycleTest::test_is_active_returns_false_for_unknown_extension":0,"Reshaped\\Framework\\Tests\\Feature\\ExtensionLifecycleTest::test_is_active_returns_true_for_active_extension":0.001}}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ public function saveSettings(Request $request, string $id): RedirectResponse

$manifest = json_decode($extension->manifest, true);
$settings = $manifest['settings'] ?? [];
$oldSettings = $this->library->getAllSettings($id);
$newSettings = [];

foreach ($settings as $setting) {
$key = $setting['key'];
Expand All @@ -81,8 +83,11 @@ public function saveSettings(Request $request, string $id): RedirectResponse
};

$this->library->setSetting($id, $key, $value);
$newSettings[$key] = $value;
}

$this->library->audit($id, 'settings.updated', $oldSettings, $newSettings);

Log::info("[Reshaped] Settings saved for extension: {$id}", [
'admin' => auth()->id(),
]);
Expand Down
28 changes: 28 additions & 0 deletions framework/app/ReshapedFramework/Libraries/ExtensionLibrary.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Reshaped\Framework\Services\AuditLogger;
use Reshaped\Framework\Services\PermissionService;
use Symfony\Component\Yaml\Yaml;

class ExtensionLibrary
Expand Down Expand Up @@ -398,6 +400,32 @@ public function log(mixed $extension, mixed $message = null, string $level = 'in
Log::{$level}("[Reshaped:{$extension}] {$message}", $context);
}

public function audit(mixed $extension, ?string $action = null, ?array $before = null, ?array $after = null, ?int $serverId = null): void
{
if ($this->namespace && (is_null($action) || !is_string($extension))) {
$serverId = $after;
$after = $before;
$before = $action;
$action = (string) $extension;
$extension = $this->namespace;
}

app(AuditLogger::class)->log($extension, $action, auth()->id(), $serverId, $before, $after);
}

// ─── Permissions ──────────────────────────────────────────────────────────

public function can(mixed $extension, mixed $permission = null, ?object $user = null): bool
{
if ($this->namespace && (is_null($permission) || !is_string($permission))) {
$user = $permission;
$permission = $extension;
$extension = $this->namespace;
}

return app(PermissionService::class)->check($extension, $permission, $user ?: auth()->user());
}

// ─── Events ───────────────────────────────────────────────────────────────

public function fire(mixed $extension, mixed $event = null, mixed $payload = null): void
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Reshaped\Framework\Console\Commands\RebuildCommand;
use Reshaped\Framework\Http\Middleware\CheckExtensionPermission;
use Reshaped\Framework\Libraries\ExtensionLibrary;
use Reshaped\Framework\Services\AuditLogger;
use Reshaped\Framework\Services\ExtensionManager;
use Reshaped\Framework\Services\HookRegistry;
use Reshaped\Framework\Services\PermissionService;
Expand Down Expand Up @@ -45,6 +46,11 @@ public function register(): void
return new PermissionService;
});

// Audit logger
$this->app->singleton(AuditLogger::class, function ($app) {
return new AuditLogger;
});

// Alias for convenient access
$this->app->alias(ExtensionLibrary::class, 'reshaped');
$this->app->alias(ExtensionLibrary::class, 'reshaped.library');
Expand Down
57 changes: 57 additions & 0 deletions framework/app/ReshapedFramework/Services/AuditLogger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

namespace Reshaped\Framework\Services;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Request;

/**
* AuditLogger
*
* Handles recording extension-specific actions into the audit log.
* Provides a database-backed history of administrative and user actions.
*/
class AuditLogger
{
/**
* Record an action in the audit log.
*
* @param string $extension Extension identifier
* @param string $action Action name (e.g. "settings.updated")
* @param int|null $userId The user performing the action
* @param int|null $serverId Optional server ID associated with the action
* @param array|null $before Data before the change
* @param array|null $after Data after the change
*/
public function log(
string $extension,
string $action,
?int $userId = null,
?int $serverId = null,
?array $before = null,
?array $after = null
): void {
DB::table('reshaped_audit_log')->insert([
'extension' => $extension,
'user_id' => $userId ?: auth()->id(),
'server_id' => $serverId,
'action' => $action,
'before' => $before ? json_encode($before) : null,
'after' => $after ? json_encode($after) : null,
'ip' => Request::ip(),
'created_at' => now(),
]);
}

/**
* Retrieve audit logs for a specific extension.
*/
public function getLogs(string $extension, int $limit = 50): \Illuminate\Support\Collection
{
return DB::table('reshaped_audit_log')
->where('extension', $extension)
->orderByDesc('created_at')
->limit($limit)
->get();
}
}