Merge branch 'release/1.10.50'

This commit is contained in:
Andy Miller
2025-11-14 15:41:36 +00:00
45 changed files with 65804 additions and 71212 deletions

View File

@@ -1,3 +1,16 @@
# v1.10.50
## 11/14/2025
1. [](#new)
* Support for 'safe-upgrade' installation
* Support for safe-upgrade restore functionality in Tools
1. [](#improved)
* Improved session expiration/logout handling
* Various minor CSS fixes
1. [](#bugfix)
* Fix for deeply nested sortable fields (at last!)
* Restore admin session timeout modal by returning 401 for timed-out AJAX requests
# v1.10.49.1
## 09/03/2025

View File

@@ -32,6 +32,7 @@ use Grav\Plugin\Admin\Popularity;
use Grav\Plugin\Admin\Router;
use Grav\Plugin\Admin\Themes;
use Grav\Plugin\Admin\AdminController;
use Grav\Plugin\Admin\SafeUpgradeManager;
use Grav\Plugin\Admin\Twig\AdminTwigExtension;
use Grav\Plugin\Admin\WhiteLabel;
use Grav\Plugin\Form\Form;
@@ -383,6 +384,29 @@ class AdminPlugin extends Plugin
'reports' => [['admin.super'], 'PLUGIN_ADMIN.REPORTS'],
'direct-install' => [['admin.super'], 'PLUGIN_ADMIN.DIRECT_INSTALL'],
]);
try {
$manifestFiles = glob(GRAV_ROOT . '/user/data/upgrades/*.json') ?: [];
if (!$manifestFiles) {
$manager = new SafeUpgradeManager(Grav::instance());
$manifestFiles = $manager->hasSnapshots() ? [true] : [];
}
$tools = $event['tools'];
Grav::instance()['log']->debug('[Admin] Tools before restore grav: ' . implode(',', array_keys($tools)));
if ($manifestFiles) {
$tools['restore-grav'] = [['admin.super'], 'PLUGIN_ADMIN.RESTORE_GRAV'];
Grav::instance()['log']->debug('[Admin] Restore Grav tool enabled');
}
$event['tools'] = $tools;
Grav::instance()['log']->debug('[Admin] Tools after register: ' . implode(',', array_keys($tools)));
} catch (\Throwable $e) {
// ignore availability errors, snapshots tool will simply stay hidden
Grav::instance()['log']->warning('[Admin] Restore Grav detection failed: ' . $e->getMessage());
}
}
/**

View File

@@ -23,6 +23,7 @@ pages:
show_modular: true
session:
timeout: 1800
keep_alive: true
edit_mode: normal
frontend_preview_target: inline
show_github_msg: true

View File

@@ -1,7 +1,7 @@
name: Admin Panel
slug: admin
type: plugin
version: 1.10.49.1
version: 1.10.50
description: Adds an advanced administration panel to manage your site
icon: empire
author:
@@ -224,6 +224,18 @@ form:
type: number
min: 1
session.keep_alive:
type: toggle
label: Keep Alive Ping
help: "Periodically pings to keep your admin session alive. Turn OFF to allow the session to expire while idle (useful for testing timeouts)."
highlight: 1
default: 1
options:
1: PLUGIN_ADMIN.ENABLED
0: PLUGIN_ADMIN.DISABLED
validate:
type: bool
hide_page_types:
type: select
size: large

View File

@@ -341,6 +341,31 @@ class Admin
return array_unique($perms);
}
/**
* @return array<int, array{id:string, source_version:?string, target_version:?string, created_at:int, created_at_iso:?string, backup_path:?string, package_path:?string}>
*/
public function safeUpgradeSnapshots(): array
{
try {
$manager = new SafeUpgradeManager();
return $manager->listSnapshots();
} catch (\Throwable $e) {
return [];
}
}
public function safeUpgradeHasSnapshots(): bool
{
try {
$manager = new SafeUpgradeManager();
return $manager->hasSnapshots();
} catch (\Throwable $e) {
return false;
}
}
/**
* Return the languages available in the site
*

View File

@@ -96,6 +96,8 @@ class AdminBaseController
// Make sure that user is logged into admin.
if (!$this->admin->authorize()) {
$this->respondUnauthorizedIfAjax();
return false;
}
@@ -236,6 +238,31 @@ class AdminBaseController
$this->close($response);
}
/**
* Return a JSON 401 response when an unauthenticated request was clearly triggered via AJAX.
*
* @return void
*/
protected function respondUnauthorizedIfAjax(): void
{
$uri = $this->grav['uri'] ?? null;
$extension = $uri ? $uri->extension() : null;
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
$requestedWith = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? '';
$acceptsJson = is_string($accept) && (stripos($accept, 'application/json') !== false || stripos($accept, 'text/json') !== false);
$isAjax = ($extension === 'json') || $acceptsJson || (is_string($requestedWith) && strtolower($requestedWith) === 'xmlhttprequest');
if (!$isAjax) {
return;
}
$this->sendJsonResponse([
'status' => 'unauthenticated',
'message' => Admin::translate('PLUGIN_ADMIN.SESSION_EXPIRED_DESC')
], 401);
}
/**
* @param ResponseInterface $response
* @return never-return

View File

@@ -54,6 +54,9 @@ use Twig\Loader\FilesystemLoader;
*/
class AdminController extends AdminBaseController
{
/** @var SafeUpgradeManager|null */
protected $safeUpgradeManager;
/**
* @param Grav|null $grav
* @param string|null $view
@@ -750,6 +753,18 @@ class AdminController extends AdminBaseController
// INSTALL & UPGRADE
/**
* @return SafeUpgradeManager
*/
protected function getSafeUpgradeManager()
{
if (null === $this->safeUpgradeManager) {
$this->safeUpgradeManager = new SafeUpgradeManager();
}
return $this->safeUpgradeManager;
}
/**
* Handles updating Grav
*
@@ -791,6 +806,273 @@ class AdminController extends AdminBaseController
$this->sendJsonResponse($json_response);
}
/**
* Safe upgrade preflight endpoint.
*
* Route: GET /update.json/task:safeUpgradePreflight (AJAX call)
*
* @return bool
*/
public function taskSafeUpgradePreflight()
{
if (!$this->authorizeTask('install grav', ['admin.super'])) {
$this->admin->json_response = [
'status' => 'error',
'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK')
];
return false;
}
$post = $this->getPost($_POST ?? []);
$force = !empty($post['force']);
$result = $this->getSafeUpgradeManager()->preflight($force);
$status = $result['status'] ?? 'ready';
$response = [
'status' => $status === 'error' ? 'error' : 'success',
'data' => $result,
];
if (!empty($result['message'])) {
$response['message'] = $result['message'];
}
$this->sendJsonResponse($response);
return true;
}
/**
* Start safe upgrade process.
*
* Route: POST /update.json/task:safeUpgradeStart (AJAX call)
*
* @return bool
*/
public function taskSafeUpgradeStart()
{
if (!$this->authorizeTask('install grav', ['admin.super'])) {
$this->admin->json_response = [
'status' => 'error',
'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK')
];
return false;
}
$post = $this->getPost($_POST ?? []);
$options = [
'force' => !empty($post['force']),
'timeout' => isset($post['timeout']) ? (int)$post['timeout'] : 30,
'overwrite' => !empty($post['overwrite']),
'decisions' => isset($post['decisions']) && is_array($post['decisions']) ? $post['decisions'] : [],
];
$manager = $this->getSafeUpgradeManager();
$result = $manager->queue($options);
$status = $result['status'] ?? 'error';
if ($status === 'error') {
$manager->clearJobContext();
$result = $manager->run($options);
$status = $result['status'] ?? 'error';
$result['fallback'] = true;
}
$response = [
'status' => $status === 'error' ? 'error' : 'success',
'data' => $result,
];
if (!empty($result['message'])) {
$response['message'] = $result['message'];
}
$this->sendJsonResponse($response);
return true;
}
/**
* Poll safe upgrade progress.
*
* Route: GET /update.json/task:safeUpgradeStatus (AJAX call)
*
* @return bool
*/
public function taskSafeUpgradeStatus()
{
if (!$this->authorizeTask('install grav', ['admin.super'])) {
$this->admin->json_response = [
'status' => 'error',
'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK')
];
return false;
}
$manager = $this->getSafeUpgradeManager();
$jobId = isset($_GET['job']) ? (string)$_GET['job'] : '';
if ($jobId !== '') {
$data = $manager->getJobStatus($jobId);
} else {
$data = [
'job' => null,
'progress' => $manager->getProgress(),
];
}
$this->sendJsonResponse([
'status' => 'success',
'data' => $data,
]);
return true;
}
/**
* Restore a safe-upgrade snapshot via Tools.
*
* Route: POST /tools/restore-grav?task:safeUpgradeRestore
*
* @return bool
*/
public function taskSafeUpgradeRestore()
{
if (!$this->authorizeTask('install grav', ['admin.super'])) {
$this->sendJsonResponse([
'status' => 'error',
'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK')
]);
return false;
}
$post = $this->getPost($_POST ?? []);
$snapshotId = isset($post['snapshot']) ? (string)$post['snapshot'] : '';
if ($snapshotId === '') {
$this->sendJsonResponse([
'status' => 'error',
'message' => $this->admin::translate('PLUGIN_ADMIN.RESTORE_GRAV_INVALID')
]);
return true;
}
$manager = $this->getSafeUpgradeManager();
$result = $manager->queueRestore($snapshotId);
$status = $result['status'] ?? 'error';
$response = [
'status' => $status === 'error' ? 'error' : 'success',
'data' => $result,
];
if (!empty($result['message'])) {
$response['message'] = $result['message'];
}
$this->sendJsonResponse($response);
return true;
}
/**
* Create a manual safe-upgrade snapshot via Tools.
*
* Route: POST /tools/restore-grav?task:safeUpgradeSnapshot
*
* @return bool
*/
public function taskSafeUpgradeSnapshot()
{
if (!$this->authorizeTask('install grav', ['admin.super'])) {
$this->sendJsonResponse([
'status' => 'error',
'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK')
]);
return false;
}
$post = $this->getPost($_POST ?? []);
$label = isset($post['label']) ? (string)$post['label'] : null;
$manager = $this->getSafeUpgradeManager();
$result = $manager->queueSnapshot($label);
$status = $result['status'] ?? 'error';
$response = [
'status' => $status === 'error' ? 'error' : 'success',
'data' => $result,
];
if (!empty($result['message'])) {
$response['message'] = $result['message'];
}
$this->sendJsonResponse($response);
return true;
}
/**
* Delete one or more safe-upgrade snapshots via Tools.
*
* Route: POST /tools/restore-grav?task:safeUpgradeDelete
*
* @return bool
*/
public function taskSafeUpgradeDelete()
{
if (!$this->authorizeTask('install grav', ['admin.super'])) {
return false;
}
$post = $this->getPost($_POST ?? []);
$snapshots = $post['snapshots'] ?? [];
if (is_string($snapshots)) {
$snapshots = [$snapshots];
}
if (empty($snapshots)) {
$this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.RESTORE_GRAV_INVALID'), 'error');
$this->setRedirect('/tools/restore-grav');
return false;
}
$manager = $this->getSafeUpgradeManager();
$results = $manager->deleteSnapshots($snapshots);
$success = array_filter($results, static function ($item) {
return ($item['status'] ?? 'error') === 'success';
});
$failed = array_filter($results, static function ($item) {
return ($item['status'] ?? 'error') !== 'success';
});
if ($success) {
$this->admin->setMessage(
sprintf($this->admin::translate('PLUGIN_ADMIN.RESTORE_GRAV_DELETE_SUCCESS'), count($success)),
'info'
);
}
foreach ($failed as $entry) {
$message = $entry['message'] ?? $this->admin::translate('PLUGIN_ADMIN.RESTORE_GRAV_DELETE_FAILED');
$this->admin->setMessage($message, 'error');
}
$this->setRedirect('/tools/restore-grav');
return true;
}
/**
* Handles uninstalling plugins and themes
*
@@ -2144,6 +2426,7 @@ class AdminController extends AdminBaseController
* @var string $name
* @var Medium|ImageMedium $medium
*/
$this->grav['log']->debug('[AI Pro][listmedia] route=' . $this->route . ' path=' . ($media->getPath() ?: 'n/a') . ' count=' . count($media->all()));
foreach ($media->all() as $name => $medium) {
$metadata = [];

View File

@@ -231,6 +231,34 @@ class LoginController extends AdminController
return $this->createRedirectResponse('/');
}
/**
* Return a fresh login nonce and keep anonymous session alive while on the login screen.
*
* Route: GET /login.json/task:nonce
*
* @return ResponseInterface
*/
public function taskNonce(): ResponseInterface
{
// Touch the anonymous session to prevent immediate expiry on the login page.
$session = $this->getSession();
if (!$session->isStarted()) {
$session->start();
}
$session->__set('admin_login_keepalive', time());
// Generate a fresh nonce for the login form.
$nonce = Admin::getNonce($this->nonce_action);
return $this->createJsonResponse([
'status' => 'success',
'message' => null,
'nonce_name' => $this->nonce_name,
'nonce_action' => $this->nonce_action,
'nonce' => $nonce
]);
}
/**
* Handle 2FA verification.
*

View File

@@ -47,8 +47,9 @@ class LoginRouter
];
}
$httpMethod = $request->getMethod();
$template = $this->taskTemplates[$task] ?? $adminInfo['view'];
$httpMethod = $request->getMethod() ?? '';
$template = $this->taskTemplates[$task ?? ''] ?? $adminInfo['view'];
$params = [];
switch ($template) {

File diff suppressed because it is too large Load Diff

View File

@@ -178,6 +178,11 @@ PLUGIN_ADMIN:
PAGES_FILTERED: "Pages filtered"
NO_PAGE_FOUND: "No Page found"
INVALID_PARAMETERS: "Invalid Parameters"
# Session expiration modal (Admin keep-alive/offline handling)
SESSION_EXPIRED: "Session Expired"
SESSION_EXPIRED_DESC: "Your admin login session has expired. Click OK to log in again."
OK: "OK"
NO_FILES_SENT: "No files sent"
EXCEEDED_FILESIZE_LIMIT: "Exceeded PHP configuration upload_max_filesize"
EXCEEDED_POSTMAX_LIMIT: "Exceeded PHP configuration post_max_size"
@@ -491,6 +496,58 @@ PLUGIN_ADMIN:
UPDATE_GRAV_NOW: "Update Grav Now"
GRAV_SYMBOLICALLY_LINKED: "Grav is symbolically linked. Upgrade won't be available"
UPDATING_PLEASE_WAIT: "Updating... please wait, downloading"
SAFE_UPGRADE_TITLE: "Safe Upgrade"
SAFE_UPGRADE_DESC: "Run Grav core upgrades with staging, validation, and rollback support."
SAFE_UPGRADE_CHECKING: "Running preflight checks..."
SAFE_UPGRADE_GENERIC_ERROR: "Safe upgrade could not complete. See Grav logs for details."
SAFE_UPGRADE_RECHECK: "Re-run Checks"
SAFE_UPGRADE_RECHECKING: "Re-running Checks..."
SAFE_UPGRADE_SUMMARY_CURRENT: "Current Grav version: <strong>v%s</strong>"
SAFE_UPGRADE_SUMMARY_REMOTE: "Available Grav version: <strong>v%s</strong>"
SAFE_UPGRADE_RELEASED_ON: "Released on %s"
SAFE_UPGRADE_PACKAGE_SIZE: "Package size: %s"
SAFE_UPGRADE_UNKNOWN_SIZE: "unknown"
SAFE_UPGRADE_WARNINGS: "Warnings"
SAFE_UPGRADE_WARNINGS_HINT: "These items may require attention before continuing."
SAFE_UPGRADE_WARNINGS_PSR_ITEM: "Potential psr/log conflict:"
SAFE_UPGRADE_WARNINGS_MONOLOG_ITEM: "Potential Monolog conflict:"
SAFE_UPGRADE_WARNINGS_MONOLOG_UNKNOWN: "Review the plugin for potential API changes."
SAFE_UPGRADE_PENDING_UPDATES: "Pending plugin or theme updates"
SAFE_UPGRADE_PENDING_INTRO: "Because this is a major Grav upgrade, update these extensions first to ensure maximum compatibility."
SAFE_UPGRADE_PENDING_MINOR_DESC: "These updates are optional for this release; apply them at your convenience."
SAFE_UPGRADE_PENDING_HINT: "Because this is a major upgrade, update all plugins and themes before continuing to ensure maximum compatibility."
SAFE_UPGRADE_UNKNOWN_VERSION: "unknown"
SAFE_UPGRADE_REQUIREMENTS_FAIL: "PHP %s or newer is required before continuing."
SAFE_UPGRADE_DISABLED: "Safe upgrade is disabled. Enable it in Configuration ▶ System ▶ Updates."
SAFE_UPGRADE_STAGING_ERROR: "Safe upgrade staging directory is not writable."
SAFE_UPGRADE_NOT_AVAILABLE: "No Grav update is available."
SAFE_UPGRADE_CONFLICTS_PSR: "Potential psr/log compatibility issues"
SAFE_UPGRADE_CONFLICTS_REQUIRES: "Requires psr/log %s"
SAFE_UPGRADE_CONFLICTS_MONOLOG: "Potential Monolog API compatibility issues"
SAFE_UPGRADE_CONFLICTS_HINT: "Choose how to handle conflicts before starting the upgrade."
SAFE_UPGRADE_DECISION_PROMPT: "When conflicts are detected:"
SAFE_UPGRADE_DECISION_DISABLE: "Disable conflicting plugins"
SAFE_UPGRADE_DECISION_DISABLE_DESC: "Temporarily disable conflicting plugins during the upgrade."
SAFE_UPGRADE_DECISION_CONTINUE: "Continue with plugins enabled"
SAFE_UPGRADE_DECISION_CONTINUE_DESC: "Proceed with plugins enabled. This may require manual fixes."
SAFE_UPGRADE_BLOCKERS_TITLE: "Action required before continuing"
SAFE_UPGRADE_BLOCKERS_DESC: "Resolve the following items to enable the upgrade."
SAFE_UPGRADE_START: "Start Safe Upgrade"
SAFE_UPGRADE_FINISH: "Finish"
SAFE_UPGRADE_STAGE_QUEUED: "Waiting for worker"
SAFE_UPGRADE_STAGE_INITIALIZING: "Preparing upgrade"
SAFE_UPGRADE_STAGE_DOWNLOADING: "Downloading update"
SAFE_UPGRADE_STAGE_SNAPSHOT: "Creating backup snapshot"
SAFE_UPGRADE_STAGE_INSTALLING: "Installing update"
SAFE_UPGRADE_STAGE_ROLLBACK: "Restoring snapshot"
SAFE_UPGRADE_STAGE_FINALIZING: "Finalizing changes"
SAFE_UPGRADE_STAGE_COMPLETE: "Upgrade complete"
SAFE_UPGRADE_STAGE_ERROR: "Upgrade encountered an error"
SAFE_UPGRADE_RESULT_SUCCESS: "Grav Upgrade to v%s Successful!"
SAFE_UPGRADE_RESULT_MANIFEST: "Snapshot reference: <code>%s</code>"
SAFE_UPGRADE_RESULT_HINT: "Restore snapshots from Tools → Restore Grav."
SAFE_UPGRADE_RESULT_NOOP: "Grav is already up to date."
SAFE_UPGRADE_RESULT_FAILURE: "Safe upgrade failed"
OF_THIS: "of this"
OF_YOUR: "of your"
HAVE_AN_UPDATE_AVAILABLE: "have an update available"
@@ -787,6 +844,30 @@ PLUGIN_ADMIN:
TOOLS_DIRECT_INSTALL_URL_TITLE: "Install Package via Remote URL Reference"
TOOLS_DIRECT_INSTALL_URL_DESC: "Alternatively, you can also reference a full URL to the package ZIP file and install it via this remote URL."
TOOLS_DIRECT_INSTALL_UPLOAD_BUTTON: "Upload and install"
RESTORE_GRAV: "Restore Grav"
RESTORE_GRAV_DESC: "Select a snapshot created by Safe Upgrade to restore your site or remove snapshots you no longer need."
RESTORE_GRAV_TABLE_SNAPSHOT: "Snapshot"
RESTORE_GRAV_TABLE_VERSION: "Version"
RESTORE_GRAV_TABLE_CREATED: "Created"
RESTORE_GRAV_TABLE_LOCATION: "Snapshot path"
RESTORE_GRAV_TABLE_ACTIONS: "Actions"
RESTORE_GRAV_RESTORE_BUTTON: "Restore"
RESTORE_GRAV_DELETE_SELECTED: "Delete Selected"
RESTORE_GRAV_NONE: "No safe upgrade snapshots are currently available."
RESTORE_GRAV_INVALID: "Select at least one snapshot before continuing."
RESTORE_GRAV_SUCCESS: "Snapshot %s restored (Grav %s)."
RESTORE_GRAV_SUCCESS_MESSAGE: "Snapshot %1$s restored (Grav %2$s)."
RESTORE_GRAV_SUCCESS_SIMPLE: "Snapshot %s restored."
RESTORE_GRAV_RUNNING: "Restoring snapshot %s..."
RESTORE_GRAV_FAILED: "Unable to restore the selected snapshot."
RESTORE_GRAV_CREATE_SNAPSHOT: "Create Snapshot"
RESTORE_GRAV_SNAPSHOT_PROMPT: "Enter an optional snapshot label"
RESTORE_GRAV_SNAPSHOT_RUNNING: "Creating snapshot %s..."
RESTORE_GRAV_SNAPSHOT_SUCCESS: "Snapshot %s created."
RESTORE_GRAV_SNAPSHOT_FAILED: "Snapshot creation failed."
RESTORE_GRAV_SNAPSHOT_FALLBACK: "Snapshot creation may have completed. Reloading..."
RESTORE_GRAV_DELETE_SUCCESS: "%d snapshot(s) deleted."
RESTORE_GRAV_DELETE_FAILED: "Failed to delete one or more snapshots."
ROUTE_OVERRIDES: "Route Overrides"
ROUTE_DEFAULT: "Default Route"
ROUTE_CANONICAL: "Canonical Route"

179
safe-upgrade-status.php Normal file
View File

@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
$scriptRoot = isset($_SERVER['SCRIPT_FILENAME']) ? dirname($_SERVER['SCRIPT_FILENAME']) : null;
$root = \defined('GRAV_ROOT') ? GRAV_ROOT : ($scriptRoot ?: dirname(__DIR__, 3));
$jobsDir = $root . '/user/data/upgrades/jobs';
$fallbackProgress = $root . '/user/data/upgrades/safe-upgrade-progress.json';
if (!\defined('GRAV_ROOT')) {
\define('GRAV_ROOT', $root);
}
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
$jobId = isset($_GET['job']) ? (string)$_GET['job'] : '';
if ($jobId !== '' && !preg_match('/^job-[A-Za-z0-9\\-]+$/', $jobId)) {
http_response_code(400);
echo json_encode([
'status' => 'error',
'message' => 'Invalid job identifier.',
]);
exit;
}
$readJson = static function (string $path): ?array {
if (!is_file($path)) {
return null;
}
$decoded = json_decode((string)file_get_contents($path), true);
return is_array($decoded) ? $decoded : null;
};
$progress = null;
$manifest = null;
$manifestPath = null;
$progressPath = null;
$normalizeDir = static function (string $path): string {
$normalized = str_replace('\\', '/', $path);
return rtrim($normalized, '/');
};
$jobsDirNormalized = $normalizeDir(realpath($jobsDir) ?: $jobsDir);
$userDataDirNormalized = $normalizeDir(realpath(dirname($jobsDir)) ?: dirname($jobsDir));
$toRelative = static function (string $path): string {
$normalized = str_replace('\\', '/', $path);
$root = str_replace('\\', '/', GRAV_ROOT);
if (strpos($normalized, $root) === 0) {
$relative = substr($normalized, strlen($root));
return ltrim($relative, '/');
}
return $normalized;
};
$contextParam = $_GET['context'] ?? '';
if ($contextParam !== '') {
$decodedRaw = base64_decode(strtr($contextParam, ' ', '+'), true);
if ($decodedRaw !== false) {
$decoded = json_decode($decodedRaw, true);
if (is_array($decoded)) {
$validatePath = static function (string $candidate) use ($normalizeDir, $jobsDirNormalized, $userDataDirNormalized) {
if ($candidate === '') {
return null;
}
$candidate = str_replace('\\', '/', $candidate);
if ($candidate[0] !== '/' && !preg_match('/^[A-Za-z]:[\\\\\/]/', $candidate)) {
$candidate = GRAV_ROOT . '/' . ltrim($candidate, '/');
$candidate = str_replace('\\', '/', $candidate);
}
$real = realpath($candidate);
if ($real === false) {
return null;
}
$real = $normalizeDir($real);
if (strpos($real, $jobsDirNormalized) !== 0 && strpos($real, $userDataDirNormalized) !== 0) {
return null;
}
return $real;
};
if (!empty($decoded['manifest'])) {
$candidate = $validatePath((string)$decoded['manifest']);
if ($candidate) {
$manifestPath = $candidate;
if (is_file($candidate)) {
$manifest = $readJson($candidate);
}
}
}
if (!empty($decoded['progress'])) {
$candidate = $validatePath((string)$decoded['progress']);
if ($candidate) {
$progressPath = $candidate;
if (is_file($candidate)) {
$progress = $readJson($candidate);
}
}
}
}
}
}
if ($jobId !== '') {
$jobPath = $jobsDir . '/' . $jobId;
$progressPath = $progressPath ?: ($jobPath . '/progress.json');
$manifestPath = $manifestPath ?: ($jobPath . '/manifest.json');
if (is_file($progressPath)) {
$progress = $readJson($progressPath);
}
if (is_file($manifestPath)) {
$manifest = $readJson($manifestPath);
}
if (!$progress && !$manifest && !is_dir($jobPath)) {
$progress = $readJson($fallbackProgress) ?: [
'stage' => 'idle',
'message' => '',
'percent' => null,
'timestamp' => time(),
];
}
}
if ($progress === null) {
if ($progressPath && is_file($progressPath)) {
$progress = $readJson($progressPath);
}
if ($progress === null) {
$progress = $readJson($fallbackProgress) ?: [
'stage' => 'idle',
'message' => '',
'percent' => null,
'timestamp' => time(),
];
$progressPath = $fallbackProgress;
}
}
if ($jobId !== '' && is_array($progress) && !isset($progress['job_id'])) {
$progress['job_id'] = $jobId;
}
$contextPayload = [];
if ($manifestPath) {
$contextPayload['manifest'] = $toRelative($manifestPath);
}
if ($progressPath) {
$contextPayload['progress'] = $toRelative($progressPath);
}
$contextToken = $contextPayload ? base64_encode(json_encode($contextPayload)) : null;
echo json_encode([
'status' => 'success',
'data' => [
'job' => $manifest ?: null,
'progress' => $progress,
'context' => $contextToken,
],
]);
exit;

View File

@@ -231,11 +231,15 @@ export default class CollectionsField {
// special case to preserve array field index keys
if (prop === 'name' && element.data('gravArrayType')) {
const match_index = element.attr(prop).match(/\[[0-9]{1,}\]$/);
const pattern = element[0].closest('[data-grav-array-name]').dataset.gravArrayName;
if (match_index && pattern) {
const array_container = element[0].closest('[data-grav-array-name]');
if (match_index) {
array_index = match_index[0];
element.attr(prop, `${pattern}${match_index[0]}`);
return;
element.attr(prop, element.attr(prop).slice(0, array_index.length * -1));
}
if (array_container && array_container.dataset && array_container.dataset.gravArrayName) {
element.attr(prop, array_container.dataset.gravArrayName);
}
}
@@ -255,14 +259,8 @@ export default class CollectionsField {
let matchedKey = currentKey;
let replaced = element.attr(prop).replace(regexps[0], (/* str, p1, offset */) => {
let extras = '';
if (array_index) {
extras = array_index;
console.log(indexes, extras);
}
matchedKey = indexes.shift() || matchedKey;
return `[${matchedKey}]${extras}`;
return `[${matchedKey}]`;
});
replaced = replaced.replace(regexps[1], (/* str, p1, offset */) => {
@@ -270,7 +268,7 @@ export default class CollectionsField {
return `.${matchedKey}.`;
});
element.attr(prop, replaced);
element.attr(prop, array_index ? `${replaced}${array_index}` : replaced);
});
});
});

View File

@@ -7,6 +7,7 @@ import 'simplebar/dist/simplebar.min.js';
import { UriToMarkdown } from './forms/fields/files.js';
import GPM, { Instance as gpm } from './utils/gpm';
import KeepAlive from './utils/keepalive';
import { config as GravConfig } from 'grav-config';
import Updates, { Instance as updates, Notifications, Feed } from './updates';
import Dashboard from './dashboard';
import Pages from './pages';
@@ -34,9 +35,20 @@ import './utils/changelog';
// Main Sidebar
import Sidebar, { Instance as sidebar } from './utils/sidebar';
import { bindGlobalAjaxTrap, installNavigationGuard } from './utils/session-expired';
// starts the keep alive, auto runs every X seconds
KeepAlive.start();
// starts the keep alive (if enabled), but never on auth views like login/forgot/reset/register
const AUTH_VIEWS = ['login', 'forgot', 'reset', 'register'];
const isAuthView = AUTH_VIEWS.includes(String(GravConfig.route || ''));
if (!isAuthView && String(GravConfig.keep_alive_enabled) !== '0') {
KeepAlive.start();
}
// catch legacy jQuery XHR 401/403 globally
bindGlobalAjaxTrap();
// intercept admin nav clicks to show modal before redirect on timeout
installNavigationGuard();
// global event to catch sidebar_state changes
$(global).on('sidebar_state._grav', () => {

View File

@@ -1 +1,2 @@
import './logs';
import './restore';

View File

@@ -0,0 +1,564 @@
import $ from 'jquery';
import { config, translations } from 'grav-config';
import request from '../utils/request';
import toastr from '../utils/toastr';
const paramSep = config.param_sep;
const task = `task${paramSep}`;
const nonce = `admin-nonce${paramSep}${config.admin_nonce}`;
const base = `${config.base_url_relative}/update.json`;
const urls = {
restore: `${base}/${task}safeUpgradeRestore/${nonce}`,
snapshot: `${base}/${task}safeUpgradeSnapshot/${nonce}`,
status: `${base}/${task}safeUpgradeStatus/${nonce}`,
};
const NICETIME_PERIODS_SHORT = [
'NICETIME.SEC',
'NICETIME.MIN',
'NICETIME.HR',
'NICETIME.DAY',
'NICETIME.WK',
'NICETIME.MO',
'NICETIME.YR',
'NICETIME.DEC'
];
const NICETIME_PERIODS_LONG = [
'NICETIME.SECOND',
'NICETIME.MINUTE',
'NICETIME.HOUR',
'NICETIME.DAY',
'NICETIME.WEEK',
'NICETIME.MONTH',
'NICETIME.YEAR',
'NICETIME.DECADE'
];
const NICETIME_LENGTHS = [60, 60, 24, 7, 4.35, 12, 10];
const FAST_UPDATE_THRESHOLD_SECONDS = 60;
const FAST_REFRESH_INTERVAL_MS = 1000;
const SLOW_REFRESH_INTERVAL_MS = 60000;
const NICETIME_TRANSLATION_ROOTS = ['GRAV_CORE', 'GRAV'];
const NICETIME_BASE_FALLBACKS = {
SECOND: 'second',
MINUTE: 'minute',
HOUR: 'hour',
DAY: 'day',
WEEK: 'week',
MONTH: 'month',
YEAR: 'year',
DECADE: 'decade',
SEC: 'sec',
MIN: 'min',
HR: 'hr',
WK: 'wk',
MO: 'mo',
YR: 'yr',
DEC: 'dec'
};
const NICETIME_PLURAL_FALLBACKS = {
SECOND: 'seconds',
MINUTE: 'minutes',
HOUR: 'hours',
DAY: 'days',
WEEK: 'weeks',
MONTH: 'months',
YEAR: 'years',
DECADE: 'decades',
SEC: 'secs',
MIN: 'mins',
HR: 'hrs',
WK: 'wks',
MO: 'mos',
YR: 'yrs',
DEC: 'decs'
};
const getTranslationKey = (key) => {
for (const root of NICETIME_TRANSLATION_ROOTS) {
const catalog = translations[root];
if (catalog && Object.prototype.hasOwnProperty.call(catalog, key)) {
const value = catalog[key];
if (typeof value === 'string' && value.trim() !== '') {
return value;
}
}
}
return undefined;
};
const nicetimeHasKey = (key) => typeof getTranslationKey(key) === 'string';
const nicetimeTranslate = (key, fallback) => {
const value = getTranslationKey(key);
if (typeof value === 'string' && value.length) {
return value;
}
return fallback;
};
const getFallbackForPeriodKey = (key) => {
const normalized = key.replace(/^GRAV\./, '');
const period = normalized.replace(/^NICETIME\./, '');
const plural = /_PLURAL/.test(period);
const baseKey = period.replace(/_PLURAL(_MORE_THAN_TWO)?$/, '');
const base = NICETIME_BASE_FALLBACKS[baseKey] || baseKey.toLowerCase();
if (!plural) {
return base;
}
const pluralKey = NICETIME_PLURAL_FALLBACKS[baseKey];
if (pluralKey) {
return pluralKey;
}
if (base.endsWith('y')) {
return `${base.slice(0, -1)}ies`;
}
if (base.endsWith('s')) {
return `${base}es`;
}
return `${base}s`;
};
const parseTimestampValue = (input) => {
if (input instanceof Date) {
return Math.floor(input.getTime() / 1000);
}
if (typeof input === 'number' && Number.isFinite(input)) {
return input > 1e12 ? Math.floor(input / 1000) : Math.floor(input);
}
if (typeof input === 'string') {
const trimmed = input.trim();
if (!trimmed) {
return null;
}
const numeric = Number(trimmed);
if (!Number.isNaN(numeric) && trimmed === String(numeric)) {
return numeric > 1e12 ? Math.floor(numeric / 1000) : Math.floor(numeric);
}
const parsed = Date.parse(trimmed);
if (!Number.isNaN(parsed)) {
return Math.floor(parsed / 1000);
}
}
return null;
};
const computeNicetime = (input, { longStrings = false, showTense = false } = {}) => {
if (input === null || input === undefined || input === '') {
return nicetimeTranslate('NICETIME.NO_DATE_PROVIDED', 'No date provided');
}
const unixDate = parseTimestampValue(input);
if (unixDate === null) {
return nicetimeTranslate('NICETIME.BAD_DATE', 'Bad date');
}
const now = Math.floor(Date.now() / 1000);
const periods = (longStrings ? NICETIME_PERIODS_LONG : NICETIME_PERIODS_SHORT).slice();
let difference;
let tense;
if (now > unixDate) {
difference = now - unixDate;
tense = nicetimeTranslate('NICETIME.AGO', 'ago');
} else if (now === unixDate) {
difference = 0;
tense = nicetimeTranslate('NICETIME.JUST_NOW', 'just now');
} else {
difference = unixDate - now;
tense = nicetimeTranslate('NICETIME.FROM_NOW', 'from now');
}
if (now === unixDate) {
return tense;
}
let index = 0;
while (index < NICETIME_LENGTHS.length - 1 && difference >= NICETIME_LENGTHS[index]) {
difference /= NICETIME_LENGTHS[index];
index += 1;
}
difference = Math.round(difference);
let periodKey = periods[index];
if (difference !== 1) {
periodKey += '_PLURAL';
const moreThanTwoKey = `${periodKey}_MORE_THAN_TWO`;
if (difference > 2 && nicetimeHasKey(moreThanTwoKey)) {
periodKey = moreThanTwoKey;
}
}
const labelFallback = periodKey.split('.').pop().toLowerCase();
const fallbackLabel = getFallbackForPeriodKey(periodKey) || labelFallback;
const periodLabel = nicetimeTranslate(periodKey, fallbackLabel);
const timeString = `${difference} ${periodLabel}`;
return showTense ? `${timeString} ${tense}` : timeString;
};
const parseBoolAttribute = (element, attributeName, defaultValue = false) => {
const rawValue = element.getAttribute(attributeName);
if (rawValue === null) {
return defaultValue;
}
const normalized = rawValue.trim().toLowerCase();
if (normalized === '') {
return true;
}
return ['1', 'true', 'yes', 'on'].includes(normalized);
};
const initialiseNicetimeUpdater = () => {
const selector = '[data-nicetime-timestamp]';
if (!document.querySelector(selector)) {
return null;
}
const update = () => {
const nowSeconds = Math.floor(Date.now() / 1000);
let youngestAge = Infinity;
document.querySelectorAll(selector).forEach((element) => {
const timestamp = element.getAttribute('data-nicetime-timestamp');
const longStrings = parseBoolAttribute(element, 'data-nicetime-long', false);
const showTense = parseBoolAttribute(element, 'data-nicetime-tense', false);
const unixTimestamp = parseTimestampValue(timestamp);
if (unixTimestamp !== null) {
const age = Math.max(0, nowSeconds - unixTimestamp);
if (age < youngestAge) {
youngestAge = age;
}
}
const updated = computeNicetime(timestamp, { longStrings, showTense });
if (updated && element.textContent !== updated) {
element.textContent = updated;
}
});
return youngestAge;
};
let timerId = null;
const scheduleNext = (lastAge) => {
const useFastInterval = Number.isFinite(lastAge) && lastAge < FAST_UPDATE_THRESHOLD_SECONDS;
const delay = useFastInterval ? FAST_REFRESH_INTERVAL_MS : SLOW_REFRESH_INTERVAL_MS;
timerId = window.setTimeout(() => {
const nextAge = update();
scheduleNext(nextAge);
}, delay);
};
const destroy = () => {
if (timerId !== null) {
window.clearTimeout(timerId);
timerId = null;
}
};
const initialAge = update();
scheduleNext(initialAge);
window.addEventListener('beforeunload', destroy, { once: true });
return { update, destroy };
};
class RestoreManager {
constructor() {
this.job = null;
this.pollTimer = null;
this.pollFailures = 0;
$(document).on('click', '[data-restore-snapshot]', (event) => {
event.preventDefault();
const button = $(event.currentTarget);
if (this.job) {
return;
}
this.startRestore(button);
});
$(document).on('click', '[data-create-snapshot]', (event) => {
event.preventDefault();
const button = $(event.currentTarget);
if (this.job) {
return;
}
this.startSnapshot(button);
});
}
startSnapshot(button) {
let label = null;
if (typeof window !== 'undefined' && window.prompt) {
const promptMessage = translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_PROMPT || 'Enter an optional snapshot label';
const input = window.prompt(promptMessage, '');
if (input === null) {
return;
}
label = input.trim();
if (label === '') {
label = null;
}
}
button.prop('disabled', true).addClass('is-loading');
const body = {};
if (label) {
body.label = label;
}
request(urls.snapshot, { method: 'post', body }, (response) => {
button.prop('disabled', false).removeClass('is-loading');
if (!response) {
toastr.error(translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_FAILED || 'Snapshot creation failed.');
return;
}
if (response.status === 'error') {
toastr.error(response.message || translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_FAILED || 'Snapshot creation failed.');
return;
}
const data = response.data || {};
const jobId = data.job_id || (data.job && data.job.id);
if (!jobId) {
const message = response.message || translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_FAILED || 'Snapshot creation failed.';
toastr.error(message);
return;
}
this.job = {
id: jobId,
operation: 'snapshot',
snapshot: null,
label
};
this.pollFailures = 0;
const descriptor = label || jobId;
const runningMessage = translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_RUNNING
? translations.PLUGIN_ADMIN.RESTORE_GRAV_SNAPSHOT_RUNNING.replace('%s', descriptor)
: 'Creating snapshot...';
toastr.info(runningMessage);
this.schedulePoll();
});
}
startRestore(button) {
const snapshot = button.data('restore-snapshot');
if (!snapshot) {
return;
}
button.prop('disabled', true).addClass('is-loading');
const body = { snapshot };
request(urls.restore, { method: 'post', body }, (response) => {
button.prop('disabled', false).removeClass('is-loading');
if (!response) {
toastr.error(translations.PLUGIN_ADMIN?.RESTORE_GRAV_FAILED || 'Snapshot restore failed.');
return;
}
if (response.status === 'error') {
toastr.error(response.message || translations.PLUGIN_ADMIN?.RESTORE_GRAV_FAILED || 'Snapshot restore failed.');
return;
}
const data = response.data || {};
const jobId = data.job_id || (data.job && data.job.id);
if (!jobId) {
const message = response.message || translations.PLUGIN_ADMIN?.RESTORE_GRAV_FAILED || 'Snapshot restore failed.';
toastr.error(message);
return;
}
this.job = {
id: jobId,
snapshot,
operation: 'restore',
};
this.pollFailures = 0;
const runningMessage = translations.PLUGIN_ADMIN?.RESTORE_GRAV_RUNNING
? translations.PLUGIN_ADMIN.RESTORE_GRAV_RUNNING.replace('%s', snapshot)
: `Restoring snapshot ${snapshot}...`;
toastr.info(runningMessage);
this.schedulePoll();
});
}
schedulePoll(delay = 1200) {
this.clearPoll();
this.pollTimer = setTimeout(() => this.pollStatus(), delay);
}
clearPoll() {
if (this.pollTimer) {
clearTimeout(this.pollTimer);
this.pollTimer = null;
}
}
pollStatus() {
if (!this.job) {
return;
}
const jobId = this.job.id;
let handled = false;
request(`${urls.status}?job=${encodeURIComponent(jobId)}`, { silentErrors: true }, (response) => {
handled = true;
this.pollFailures = 0;
if (!response || response.status !== 'success') {
this.schedulePoll();
return;
}
const data = response.data || {};
const job = data.job || {};
const progress = data.progress || {};
const stage = progress.stage || null;
const status = job.status || progress.status || null;
const operation = progress.operation || this.job.operation || null;
if (!this.job.snapshot && progress.snapshot) {
this.job.snapshot = progress.snapshot;
} else if (!this.job.snapshot && job.result && job.result.snapshot) {
this.job.snapshot = job.result.snapshot;
}
if (!this.job.label && progress.label) {
this.job.label = progress.label;
} else if (!this.job.label && job.result && job.result.label) {
this.job.label = job.result.label;
}
if (stage === 'error' || status === 'error') {
const message = job.error || progress.message || (operation === 'snapshot'
? translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_FAILED || 'Snapshot creation failed.'
: translations.PLUGIN_ADMIN?.RESTORE_GRAV_FAILED || 'Snapshot restore failed.');
toastr.error(message);
this.job = null;
this.clearPoll();
return;
}
if (stage === 'complete' || status === 'success') {
if (operation === 'snapshot') {
const snapshotId = progress.snapshot || (job.result && job.result.snapshot) || this.job.snapshot || '';
const labelValue = progress.label || (job.result && job.result.label) || this.job.label || '';
let displayName = labelValue || snapshotId || (translations.PLUGIN_ADMIN?.RESTORE_GRAV_TABLE_SNAPSHOT || 'snapshot');
if (labelValue && snapshotId && labelValue !== snapshotId) {
displayName = `${labelValue} (${snapshotId})`;
}
const successMessage = translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_SUCCESS
? translations.PLUGIN_ADMIN.RESTORE_GRAV_SNAPSHOT_SUCCESS.replace('%s', displayName)
: (snapshotId ? `Snapshot ${displayName} created.` : 'Snapshot created.');
toastr.success(successMessage);
this.job = null;
this.clearPoll();
setTimeout(() => window.location.reload(), 1500);
return;
}
const snapshotId = progress.snapshot || this.job.snapshot || '';
const labelValue = progress.label || (job.result && job.result.label) || this.job.label || '';
let snapshotDisplay = snapshotId || labelValue;
if (labelValue && snapshotId && labelValue !== snapshotId) {
snapshotDisplay = `${labelValue} (${snapshotId})`;
} else if (!snapshotDisplay) {
snapshotDisplay = translations.PLUGIN_ADMIN?.RESTORE_GRAV_TABLE_SNAPSHOT || 'snapshot';
}
const version = (job.result && job.result.version) || progress.version || '';
let successMessage;
if (translations.PLUGIN_ADMIN?.RESTORE_GRAV_SUCCESS_MESSAGE && version) {
successMessage = translations.PLUGIN_ADMIN.RESTORE_GRAV_SUCCESS_MESSAGE.replace('%1$s', snapshotDisplay).replace('%2$s', version);
} else if (translations.PLUGIN_ADMIN?.RESTORE_GRAV_SUCCESS_SIMPLE) {
successMessage = translations.PLUGIN_ADMIN.RESTORE_GRAV_SUCCESS_SIMPLE.replace('%s', snapshotDisplay);
} else {
successMessage = version ? `Snapshot ${snapshotDisplay} restored (Grav ${version}).` : `Snapshot ${snapshotDisplay} restored.`;
}
toastr.success(successMessage);
this.job = null;
this.clearPoll();
setTimeout(() => window.location.reload(), 1500);
return;
}
this.schedulePoll();
}).then(() => {
if (!handled) {
this.handleSilentFailure();
}
});
}
handleSilentFailure() {
if (!this.job) {
return;
}
this.pollFailures += 1;
const operation = this.job.operation || 'restore';
const snapshot = this.job.snapshot || '';
if (this.pollFailures >= 3) {
let message;
if (operation === 'snapshot') {
message = translations.PLUGIN_ADMIN?.RESTORE_GRAV_SNAPSHOT_FALLBACK || 'Snapshot creation may have completed. Reloading...';
} else {
message = snapshot
? `Snapshot ${snapshot} restore is completing. Reloading...`
: 'Snapshot restore is completing. Reloading...';
}
toastr.info(message);
this.job = null;
this.clearPoll();
setTimeout(() => window.location.reload(), 1500);
return;
}
const delay = Math.min(5000, 1200 * this.pollFailures);
this.schedulePoll(delay);
}
}
// Initialize restore manager when tools view loads.
$(document).ready(() => {
initialiseNicetimeUpdater();
new RestoreManager();
});

View File

@@ -8,16 +8,21 @@ import Feed from './feed';
import './check';
import './update';
import './channel-switcher';
import SafeUpgrade from './safe-upgrade';
export default class Updates {
constructor(payload = {}) {
this.setPayload(payload);
this.task = `task${config.param_sep}`;
this.updateURL = '';
this.safeUpgrade = new SafeUpgrade(this);
}
setPayload(payload = {}) {
this.payload = payload;
if (this.safeUpgrade) {
this.safeUpgrade.setPayload(payload);
}
return this;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import { config } from 'grav-config';
import { userFeedbackError } from './response';
import { showSessionExpiredModal } from './session-expired';
const MAX_SAFE_DELAY = 2147483647;
@@ -19,14 +20,30 @@ class KeepAlive {
this.active = false;
}
fetch() {
checkOnce() {
let data = new FormData();
data.append('admin-nonce', config.admin_nonce);
fetch(`${config.base_url_relative}/task${config.param_sep}keepAlive`, {
return fetch(`${config.base_url_relative}/task${config.param_sep}keepAlive`, {
credentials: 'same-origin',
headers: {
'Accept': 'application/json'
},
method: 'post',
body: data
})
.then((response) => {
if (response && (response.status === 401 || response.status === 403)) {
return false;
}
return true;
})
.catch(() => false);
}
fetch() {
return this.checkOnce().then((ok) => {
if (!ok) { showSessionExpiredModal(); }
}).catch(userFeedbackError);
}
}

View File

@@ -8,6 +8,11 @@ let request = function(url, options = {}, callback = () => true) {
options = {};
}
const silentErrors = !!options.silentErrors;
if (options.silentErrors) {
delete options.silentErrors;
}
if (options.method && options.method === 'post') {
let data = new FormData();
@@ -34,7 +39,16 @@ let request = function(url, options = {}, callback = () => true) {
.then(parseJSON)
.then(userFeedback)
.then((response) => callback(response, raw))
.catch(userFeedbackError);
.catch((error) => {
if (silentErrors) {
console.debug('[Request] silent failure', url, error);
return undefined;
}
userFeedbackError(error);
return undefined;
});
};
export default request;

View File

@@ -1,8 +1,9 @@
import $ from 'jquery';
import toastr from './toastr';
import isOnline from './offline';
import { config } from 'grav-config';
// import { config } from 'grav-config';
import trim from 'mout/string/trim';
import { showSessionExpiredModal } from './session-expired';
let UNLOADING = false;
let error = function(response) {
@@ -25,6 +26,12 @@ export function parseStatus(response) {
}
export function parseJSON(response) {
// If the session is no longer valid, surface a blocking modal instead of generic errors
if (response && (response.status === 401 || response.status === 403)) {
showSessionExpiredModal();
throw new Error('Unauthorized');
}
return response.text().then((text) => {
let parsed = text;
try {
@@ -53,7 +60,8 @@ export function userFeedback(response) {
switch (status) {
case 'unauthenticated':
document.location.href = config.base_url_relative;
// Show a blocking modal and stop further processing
showSessionExpiredModal();
throw error('Logged out');
case 'unauthorized':
status = 'error';
@@ -91,6 +99,13 @@ export function userFeedback(response) {
export function userFeedbackError(error) {
if (UNLOADING) { return true; }
// If we can detect an unauthorized state here, show modal
const unauthorized = (error && (error.message === 'Unauthorized' || (error.response && (error.response.status === 401 || error.response.status === 403))));
if (unauthorized) {
showSessionExpiredModal();
return;
}
let stack = error.stack ? `<pre><code>${error.stack}</code></pre>` : '';
toastr.error(`Fetch Failed: <br /> ${error.message} ${stack}`);
console.error(`${error.message} at ${error.stack}`);

View File

@@ -0,0 +1,91 @@
import $ from 'jquery';
import { config } from 'grav-config';
import KeepAlive from './keepalive';
let shown = false;
export function showSessionExpiredModal() {
if (shown) { return; }
shown = true;
try { localStorage.setItem('grav:admin:sessionExpiredShown', '1'); } catch (e) {}
try { KeepAlive.stop(); } catch (e) {}
// Ensure modal exists (in case a custom layout removed it)
let $modal = $('[data-remodal-id="session-expired"]');
if (!$modal.length) {
const html = `
<div class="remodal" data-remodal-id="session-expired" data-remodal-options="hashTracking: false">
<form>
<h1>Session Expired</h1>
<p class="bigger">Your admin login session has expired. Please log in again.</p>
<div class="button-bar">
<a class="button remodal-confirm" data-remodal-action="confirm" href="#">OK</a>
</div>
</form>
</div>`;
$('body').append(html);
$modal = $('[data-remodal-id="session-expired"]');
}
// Harden the modal: no escape/overlay close
const instance = $modal.remodal({ hashTracking: false, closeOnEscape: false, closeOnOutsideClick: false, closeOnCancel: false, closeOnConfirm: true, stack: false });
// Style overlay + blur background
$('html').addClass('session-expired-active');
$('.remodal-overlay').addClass('session-expired');
// On confirm, redirect to login
$modal.off('.session-expired').on('confirmation.session-expired', () => {
// Keep suppression flag for the next page load (login) so we don't double prompt
window.location.href = config.base_url_relative;
});
// Open modal
instance.open();
}
// Bind a jQuery global ajax error trap for legacy XHR paths
export function bindGlobalAjaxTrap() {
$(document).off('ajaxError._grav_session').on('ajaxError._grav_session', (event, xhr) => {
if (!xhr) { return; }
const status = xhr.status || 0;
if (status === 401 || status === 403) {
showSessionExpiredModal();
}
});
}
// Intercept in-admin link clicks to show the modal before any server redirect to login
export function installNavigationGuard() {
$(document).off('click._grav_session_nav').on('click._grav_session_nav', 'a[href]', function(e) {
const $a = $(this);
const href = $a.attr('href');
if (!href || href === '#' || href.indexOf('javascript:') === 0) { return; }
if (e.isDefaultPrevented()) { return; }
if ($a.attr('target') === '_blank' || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { return; }
// Only guard admin-relative links
const base = (window.GravAdmin && window.GravAdmin.config && window.GravAdmin.config.base_url_relative) || '';
const isAdminLink = href.indexOf(base + '/') === 0 || href === base || href.indexOf('/') === 0;
if (!isAdminLink) { return; }
e.preventDefault();
// Quick session check, if invalid show modal, else proceed with navigation
try {
KeepAlive.checkOnce().then((ok) => {
if (ok) {
window.location.href = href;
} else {
showSessionExpiredModal();
}
});
} catch (err) {
// On any error, just navigate
window.location.href = href;
}
});
}
export default { showSessionExpiredModal, bindGlobalAjaxTrap };

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,13 +1 @@
body, h5, h6,
.badge, .note, .grav-mdeditor-preview,
input, select, textarea, button, .selectize-input,
h1, h2, h3, h4,
.fontfamily-sans .CodeMirror pre,
#admin-menu li, .form-tabs > label, .label {
font-family: "Helvetica Neue", "Helvetica", "Tahoma", "Geneva", "Arial", sans-serif; }
.CodeMirror pre,
code, kbd, pre, samp, .mono {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; }
/*# sourceMappingURL=simple-fonts.css.map */
body,h5,h6,.badge,.note,.grav-mdeditor-preview,input,select,textarea,button,.selectize-input,h1,h2,h3,h4,.fontfamily-sans .CodeMirror pre,#admin-menu li,.form-tabs>label,.label{font-family:"Helvetica Neue","Helvetica","Tahoma","Geneva","Arial",sans-serif}.CodeMirror pre,code,kbd,pre,samp,.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,Courier,monospace}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -5,58 +5,62 @@
"repository": "https://github.com/getgrav/grav-admin",
"main": "app/main.js",
"scripts": {
"watch:sass": "sass --watch scss:css-compiled --style=expanded",
"build:css": "sass scss:css-compiled --style=compressed --no-source-map",
"dev": "sh -c 'npm run watch & npm run watch:sass'",
"watch": "webpack --mode development --watch --progress --color --mode development --config webpack.conf.js",
"prod": "webpack --mode production --config webpack.conf.js"
},
"author": "Trilby Media, LLC",
"license": "MIT",
"dependencies": {
"babel-loader": "^8.2.3",
"babel-loader": "^10.0.0",
"buffer": "^6.0.3",
"chartist": "0.11.4",
"codemirror": "^5.65.1",
"debounce": "^1.2.1",
"codemirror": "^5.65.16",
"debounce": "^2.2.0",
"dropzone": "^5.9.3",
"eonasdan-bootstrap-datetimepicker": "^4.17.49",
"eventemitter3": "^4.0.7",
"eventemitter3": "^5.0.1",
"events": "^3.3.0",
"exif-js": "^2.3.0",
"immutable": "^4.0.0",
"immutable": "^4.3.7",
"immutablediff": "^0.4.4",
"js-yaml": "^4.1.0",
"mout": "^1.2.3",
"popper.js": "^1.14.4",
"mout": "^1.2.4",
"popper.js": "^1.16.1",
"rangetouch": "^2.0.1",
"remodal": "^1.1.1",
"sass": "^1.92.1",
"selectize": "^0.12.6",
"simplebar": "^5.3.6",
"sortablejs": "^1.14.0",
"sortablejs": "^1.15.6",
"speakingurl": "^14.0.1",
"toastr": "^2.1.4",
"watchjs": "0.0.0",
"whatwg-fetch": "^3.6.2"
"whatwg-fetch": "^3.6.20"
},
"devDependencies": {
"@babel/core": "^7.17.0",
"@babel/plugin-proposal-class-properties": "^7.16.7",
"@babel/plugin-proposal-json-strings": "^7.16.7",
"@babel/plugin-proposal-object-rest-spread": "^7.16.7",
"@babel/core": "^7.28.4",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/plugin-proposal-json-strings": "^7.18.6",
"@babel/plugin-proposal-object-rest-spread": "^7.20.7",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-syntax-import-meta": "^7.10.4",
"@babel/polyfill": "^7.12.1",
"@babel/preset-env": "^7.16.11",
"@babel/register": "^7.17.0",
"css-loader": "^6.6.0",
"eslint": "^8.8.0",
"eslint-webpack-plugin": "^3.1.1",
"exports-loader": "^3.1.0",
"imports-loader": "^3.1.1",
"@babel/preset-env": "^7.28.3",
"@babel/register": "^7.28.3",
"css-loader": "^7.1.2",
"eslint": "^9.35.0",
"eslint-webpack-plugin": "^5.0.2",
"exports-loader": "^5.0.0",
"imports-loader": "^5.0.0",
"json-loader": "^0.5.7",
"minimist": "^1.2.5",
"style-loader": "^3.3.1",
"terser-webpack-plugin": "^5.3.1",
"webpack": "^5.68.0",
"webpack-cli": "^4.9.2"
"minimist": "^1.2.8",
"style-loader": "^4.0.0",
"terser-webpack-plugin": "^5.3.14",
"webpack": "^5.101.3",
"webpack-cli": "^6.0.1"
},
"resolutions": {
"minimist": "^1.2.5"

View File

@@ -105,5 +105,8 @@
// Horizontal Scroll
@import "template/horizontal-scroll";
// Safe upgrade
@import "template/safe-upgrade";
// Custom
@import "template/custom";

View File

@@ -20,6 +20,14 @@ $content-padding: 1.5rem;
font-size: 2.5rem;
}
}
&.error::before {
content: "\f071";
font-family: "FontAwesome";
margin-right: 0.65rem;
font-size: 1.2rem;
color: #c0392b;
}
}
.custom-logo {
@@ -726,6 +734,324 @@ body.sidebar-quickopen #admin-main {
height: calc(100vh - #{$topbar-height} - #{$update-height});
}
.safe-upgrade-modal {
text-align: left;
.safe-upgrade-form {
padding: 1.5rem 1.75rem 1.5rem;
}
.safe-upgrade-header {
margin-bottom: 1.25rem;
h2 {
font-size: 1.6rem;
margin: 0 0 0.5rem;
line-height: 1.25;
}
p {
margin: 0;
}
}
.safe-upgrade-body {
max-height: 60vh;
overflow-y: auto;
margin-bottom: 1.5rem;
}
[data-safe-upgrade-footer] {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
.button {
min-width: 9rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
&.is-loading {
pointer-events: none;
}
.button-spinner {
margin-right: 0.35rem;
}
}
}
.safe-upgrade-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem 1.25rem;
background: rgba(20, 42, 68, 0.05);
border: 1px solid rgba(20, 42, 68, 0.12);
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 1.35rem;
p {
margin: 0;
display: flex;
flex-direction: column;
font-weight: 500;
color: #2f3b48;
strong {
font-size: 1.05rem;
margin-top: 0.2rem;
font-weight: 600;
color: #0e355a;
}
}
}
.safe-upgrade-error {
background: rgba(192, 57, 43, 0.1);
border: 1px solid rgba(192, 57, 43, 0.35);
border-radius: 10px;
padding: 1.25rem 1.5rem;
margin-bottom: 1.25rem;
box-shadow: 0 8px 24px rgba(192, 57, 43, 0.08);
p {
margin: 0 0 0.85rem;
font-size: 1rem;
font-weight: 600;
color: #922b21;
}
.button {
min-width: 10rem;
}
}
.safe-upgrade-panel {
--safe-upgrade-accent: #3f83d1;
background: #ffffff;
border: 1px solid rgba(20, 42, 68, 0.14);
border-left: 6px solid var(--safe-upgrade-accent);
border-radius: 8px;
padding: 1rem 1.25rem 1.15rem;
margin-bottom: 1.15rem;
box-shadow: 0 4px 14px rgba(15, 24, 44, 0.08);
&__header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
&__title-wrap {
display: flex;
align-items: flex-start;
gap: 0.85rem;
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: var(--safe-upgrade-accent);
color: #ffffff;
font-size: 1.2rem;
box-shadow: 0 2px 6px rgba(15, 24, 44, 0.16);
}
&__title {
display: block;
font-size: 1.05rem;
line-height: 1.35;
}
&__subtitle {
display: block;
margin-top: 0.15rem;
font-size: 0.9rem;
color: rgba(47, 59, 72, 0.8);
}
&__body {
margin-top: 0.85rem;
ul {
margin: 0;
padding: 0 0 0 1.25rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
color: #2f3b48;
}
p {
margin: 0;
font-size: 0.9rem;
color: rgba(47, 59, 72, 0.85);
}
}
&__action {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-width: 100%;
flex: 1 1 100%;
width: 100%;
max-width: 100%;
margin-left: 0;
padding-top: 0.25rem;
}
&--alert {
--safe-upgrade-accent: #e67e22;
background: rgba(230, 126, 34, 0.08);
}
&--info {
--safe-upgrade-accent: #17a2b8;
background: rgba(23, 162, 184, 0.08);
}
&--conflict {
--safe-upgrade-accent: #8e44ad;
background: rgba(142, 68, 173, 0.08);
}
&--blocker {
--safe-upgrade-accent: #c0392b;
background: rgba(192, 57, 43, 0.08);
}
}
.safe-upgrade-decision {
display: flex;
flex-direction: column;
gap: 0.65rem;
width: 100%;
.safe-upgrade-decision-option {
display: flex;
align-items: flex-start;
gap: 0.65rem;
padding: 0.65rem 0.75rem;
border: 1px solid rgba(20, 42, 68, 0.2);
border-radius: 6px;
background: rgba(255, 255, 255, 0.92);
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
cursor: pointer;
&:hover,
&:focus-within {
border-color: var(--safe-upgrade-accent);
background: #ffffff;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.05);
}
input {
margin-top: 0.2rem;
flex-shrink: 0;
accent-color: var(--safe-upgrade-accent);
}
.safe-upgrade-decision-option__content {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.safe-upgrade-decision-option__title {
font-weight: 600;
font-size: 0.95rem;
color: #13243b;
}
.safe-upgrade-decision-option__description {
font-size: 0.85rem;
color: rgba(47, 59, 72, 0.75);
}
}
}
.safe-upgrade-loading {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.05rem;
padding: 1.5rem 0;
}
.safe-upgrade-progress {
text-align: center;
h3 {
margin-bottom: 0.5rem;
}
.safe-upgrade-progress-bar {
width: 100%;
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
height: 10px;
overflow: hidden;
margin-top: 0.75rem;
span {
display: block;
height: 100%;
background: #4dbc8b;
transition: width 0.3s ease;
}
}
.progress-value {
font-weight: 600;
margin-top: 0.5rem;
}
}
.safe-upgrade-result {
h3 {
margin-bottom: 0.25rem;
border-bottom: 0;
}
&.success h3 {
color: #45b854;
}
&.error h3 {
color: #922b21;
}
&.neutral h3 {
color: #6c7a89;
}
&.error {
background: rgba(192, 57, 43, 0.08);
border: 1px solid rgba(192, 57, 43, 0.22);
border-radius: 10px;
padding: 1.2rem 1.4rem;
box-shadow: 0 12px 32px rgba(192, 57, 43, 0.12);
margin-bottom: 1rem;
p {
margin: 0;
font-size: 0.95rem;
color: rgba(47, 59, 72, 0.85);
}
}
}
}
.content-wrapper {
position: relative;
//overflow-y: hidden;
@@ -1435,4 +1761,16 @@ body.sidebar-quickopen #admin-main {
}
}
.simplebar-content-wrapper { overflow: auto; }
/* Session expired blur + overlay */
html.session-expired-active .remodal-bg { filter: blur(4px); pointer-events: none; user-select: none; }
html.session-expired-active #admin-login { filter: blur(4px); pointer-events: none; user-select: none; }
.remodal-overlay.session-expired { background: rgba(0,0,0,0.55); backdrop-filter: blur(3px); }
[data-remodal-id="session-expired"] .button-bar .button { min-width: 120px;text-align: center; }
/* Lightweight modal used on login page when vendor JS is not loaded */
.grav-expired-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); backdrop-filter: blur(3px); z-index: 9999; display: flex; align-items: center; justify-content: center; }
.grav-expired-modal { background: #fff; border-radius: 6px; width: min(640px, 90%); box-shadow: 0 10px 30px rgba(0,0,0,.25); overflow: hidden; font-family: inherit; }
.grav-expired-modal h1 { margin: 0; padding: 24px 28px; font-size: 28px; color: #3D424E; border-bottom: 1px solid #f0f0f0; }
.grav-expired-modal p { margin: 0; padding: 20px 28px; font-size: 16px; color: #6f7b8a; }
.grav-expired-actions { padding: 18px 24px; display: flex; justify-content: flex-end; background: #f7f7f7; }
.grav-expired-actions .button { min-width: 120px;text-align: center; }

View File

@@ -429,7 +429,7 @@ form {
.dynfields, [data-grav-field="array"], [data-grav-field="multilevel"] {
input[type=text] {
width: 40%;
width: calc(50% - 50px);
float: left;
margin: 0 5px 5px 0;
}
@@ -445,6 +445,7 @@ form {
display: inline-block;
line-height: 1.5;
cursor: pointer;
margin-right: 8px;
&[data-grav-array-action="sort"] {
float: left;
@@ -453,7 +454,7 @@ form {
}
&.array-field-value_only {
width: 100%;
width: calc(100% - 100px);
}
}
}

View File

@@ -326,4 +326,5 @@ html.remodal-is-locked {
.remodal ul li {
margin-left: 27px;
list-style-type: square;
}
}

View File

@@ -0,0 +1,423 @@
.remodal {
.safe-upgrade-header, .safe-upgrade-body {
padding: 0 1.5rem;
h2 {
padding-top: 0;
margin-top: 0;
}
p {
padding-left: 0;
}
}
.safe-upgrade-body {
margin-bottom: 1rem;
.safe-upgrade-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem 1.25rem;
background: rgba(20, 42, 68, 0.05);
border: 1px solid rgba(20, 42, 68, 0.12);
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 1.35rem;
p {
margin: 0;
display: flex;
flex-direction: column;
font-weight: 500;
color: #2f3b48;
strong {
font-size: 1.05rem;
margin-top: 0.2rem;
font-weight: 600;
color: #0e355a;
}
}
}
.safe-upgrade-error {
background: rgba(192, 57, 43, 0.1);
border: 1px solid rgba(192, 57, 43, 0.35);
border-radius: 10px;
padding: 1.25rem 1.5rem;
margin-bottom: 1.25rem;
box-shadow: 0 8px 24px rgba(192, 57, 43, 0.08);
text-align: center;
p {
margin: 0 0 0.85rem;
font-size: 1rem;
font-weight: 600;
color: #922b21;
}
.button {
min-width: 10rem;
}
}
[data-safe-upgrade-footer] {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
.button {
min-width: 9rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
&.is-loading {
pointer-events: none;
}
.button-spinner {
margin-right: 0.35rem;
}
}
}
.safe-upgrade-panel {
--safe-upgrade-accent: #3f83d1;
background: #ffffff;
border: 1px solid rgba(20, 42, 68, 0.14);
border-left: 6px solid var(--safe-upgrade-accent);
border-radius: 8px;
padding: 1rem 1.25rem 1.15rem;
margin-bottom: 1.15rem;
box-shadow: 0 4px 14px rgba(15, 24, 44, 0.08);
&__header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
&__title-wrap {
display: flex;
align-items: flex-start;
gap: 0.85rem;
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: var(--safe-upgrade-accent);
color: #ffffff;
font-size: 1.2rem;
box-shadow: 0 2px 6px rgba(15, 24, 44, 0.16);
}
&__title {
display: block;
font-size: 1.05rem;
line-height: 1.35;
}
&__subtitle {
display: block;
margin-top: 0.15rem;
font-size: 0.9rem;
color: rgba(47, 59, 72, 0.8);
}
&__body {
margin-top: 0.85rem;
ul {
margin: 0;
padding: 0 0 0 1.25rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
color: #2f3b48;
}
p {
margin: 0;
font-size: 0.9rem;
color: rgba(47, 59, 72, 0.85);
}
}
&__action {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-width: 100%;
flex: 1 1 100%;
width: 100%;
max-width: 100%;
margin-left: 0;
padding-top: 0.25rem;
}
&--alert {
--safe-upgrade-accent: #e67e22;
background: rgba(230, 126, 34, 0.08);
}
&--info {
--safe-upgrade-accent: #17a2b8;
background: rgba(23, 162, 184, 0.08);
}
&--conflict {
--safe-upgrade-accent: #8e44ad;
background: rgba(142, 68, 173, 0.08);
}
&--blocker {
--safe-upgrade-accent: #c0392b;
background: rgba(192, 57, 43, 0.08);
}
}
.safe-upgrade-decision {
display: flex;
flex-direction: column;
gap: 0.65rem;
width: 100%;
.safe-upgrade-decision-option {
display: flex;
align-items: flex-start;
gap: 0.65rem;
padding: 0.65rem 0.75rem;
border: 1px solid rgba(20, 42, 68, 0.2);
border-radius: 6px;
background: rgba(255, 255, 255, 0.92);
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
cursor: pointer;
&:hover,
&:focus-within {
border-color: var(--safe-upgrade-accent);
background: #ffffff;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.05);
}
input {
margin-top: 0.2rem;
flex-shrink: 0;
accent-color: var(--safe-upgrade-accent);
}
.safe-upgrade-decision-option__content {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.safe-upgrade-decision-option__title {
font-weight: 600;
font-size: 0.95rem;
color: #13243b;
}
.safe-upgrade-decision-option__description {
font-size: 0.85rem;
color: rgba(47, 59, 72, 0.75);
}
}
}
.safe-upgrade-loading {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.05rem;
padding: 1.5rem 0;
}
.safe-upgrade-progress {
text-align: center;
h3 {
margin-bottom: 0.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
padding-bottom: 0.5rem;
}
.safe-upgrade-progress-bar {
width: 100%;
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
height: 10px;
overflow: hidden;
margin-top: 0.75rem;
span {
display: block;
height: 100%;
background: #4dbc8b;
transition: width 0.3s ease;
}
&.is-active span {
animation: safe-upgrade-pulse 1.6s ease-in-out infinite;
}
}
.safe-upgrade-status {
margin-top: 0.5rem;
font-size: 0.95rem;
strong {
font-weight: 600;
}
}
.progress-value {
font-weight: 600;
margin-top: 0.5rem;
}
}
.safe-upgrade-result {
h3 {
margin-bottom: 0.25rem;
border-bottom: 0;
}
&.success {
background: rgba(41, 182, 94, 0.08);
border: 1px solid rgba(41, 182, 94, 0.24);
border-radius: 12px;
padding: 1.3rem 1.4rem;
box-shadow: 0 14px 32px rgba(41, 182, 94, 0.18);
margin-bottom: 1rem;
text-align: left;
.safe-upgrade-result__banner {
display: flex;
align-items: center;
gap: 0.9rem;
margin-bottom: 0.85rem;
}
.safe-upgrade-result__icon {
width: 44px;
height: 44px;
border-radius: 50%;
background: #27ae60;
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
box-shadow: 0 10px 22px rgba(39, 174, 96, 0.35);
}
.safe-upgrade-result__label {
font-size: 0.82rem;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 600;
margin: 0 0 0.15rem;
color: rgba(39, 174, 96, 0.85);
}
h3 {
margin: 0;
font-size: 1.2rem;
color: #14301d;
}
.safe-upgrade-result__details {
font-size: 0.95rem;
color: rgba(20, 48, 29, 0.9);
p {
margin: 0.4rem 0;
}
}
}
&.error {
background: rgba(192, 57, 43, 0.08);
border: 1px solid rgba(192, 57, 43, 0.22);
border-radius: 10px;
padding: 1.2rem 1.4rem;
box-shadow: 0 12px 32px rgba(192, 57, 43, 0.12);
margin-bottom: 1rem;
text-align: left;
h3 {
color: #922b21;
font-size: 1.1rem;
}
p {
margin: 0;
font-size: 0.95rem;
color: rgba(47, 59, 72, 0.85);
}
}
}
}
}
.restore-grav-content {
padding: 0 1.5rem 2rem;
.button-bar {
margin: 1.5rem 0 0;
}
}
.restore-grav-intro {
margin: 0 0 1.5rem;
}
.restore-grav-table {
margin-bottom: 1.5rem;
code {
white-space: nowrap;
}
}
table.restore-grav-table {
.checkbox-cell {
flex: .1;
& + td + td, & + th + th {
flex: 0.5;
}
}
.actions-cell {
flex: .5;
}
.hint {
opacity: 0.7;
size: 90%;
&:before, &:after {
content: '';
background: none;
padding: 0;
}
}
}
@keyframes safe-upgrade-pulse {
0% {
filter: brightness(100%);
}
50% {
filter: brightness(115%);
}
100% {
filter: brightness(100%);
}
}

View File

@@ -33,12 +33,6 @@
{{ assets.js()|raw }}
{% endblock %}
<style>
.simplebar-content-wrapper {
overflow: auto;
}
</style>
</head>
{% block body %}
{% set sidebarStatus = get_cookie('grav-admin-sidebar') %}
@@ -89,6 +83,16 @@
{% endblock %}
{% block modals %}
{# Session expired blocking modal #}
<div class="remodal" data-remodal-id="session-expired" data-remodal-options="hashTracking: false">
<form>
<h1>{{ 'PLUGIN_ADMIN.SESSION_EXPIRED'|t|default('Session Expired') }}</h1>
<p class="bigger">{{ 'PLUGIN_ADMIN.SESSION_EXPIRED_DESC'|t|default('Your admin login session has expired. Click OK to log in again.') }}</p>
<div class="button-bar">
<a class="button remodal-confirm" data-remodal-action="confirm" href="#">{{ 'PLUGIN_ADMIN.OK'|t|default('OK') }}</a>
</div>
</form>
</div>
<div class="remodal" data-remodal-id="generic" data-remodal-options="hashTracking: false">
<form>
<h1>{{ "PLUGIN_ADMIN.ERROR"|t }}</h1>
@@ -123,16 +127,22 @@
</div>
</form>
</div>
<div class="remodal" data-remodal-id="update-grav" data-remodal-options="hashTracking: false">
<form>
<h1>{{ "PLUGIN_ADMIN.MODAL_DELETE_FILE_CONFIRMATION_REQUIRED_TITLE"|t }}</h1>
<p class="bigger">
{{ "PLUGIN_ADMIN.MODAL_UPDATE_GRAV_CONFIRMATION_REQUIRED_DESC"|t }}
</p>
<br>
<div class="button-bar">
<button data-remodal-action="cancel" class="button secondary remodal-cancel"><i class="fa fa-fw fa-close"></i> {{ "PLUGIN_ADMIN.CANCEL"|t }}</button>
<button data-remodal-action="confirm" class="button remodal-confirm disable-after-click"><i class="fa fa-fw fa-check"></i> {{ "PLUGIN_ADMIN.CONTINUE"|t }}</button>
<div class="remodal safe-upgrade-modal" data-remodal-id="update-grav" data-remodal-options="hashTracking: false">
<form class="safe-upgrade-form">
<div class="safe-upgrade-header">
<h2>{{ "PLUGIN_ADMIN.SAFE_UPGRADE_TITLE"|t }}</h2>
<p class="bigger">{{ "PLUGIN_ADMIN.SAFE_UPGRADE_DESC"|t }}</p>
</div>
<div class="safe-upgrade-body">
<section data-safe-upgrade-step="preflight"></section>
<section data-safe-upgrade-step="progress" class="hidden"></section>
<section data-safe-upgrade-step="result" class="hidden"></section>
</div>
<div class="button-bar" data-safe-upgrade-footer>
<button data-remodal-action="cancel" data-safe-upgrade-action="cancel" class="button secondary remodal-cancel"><i class="fa fa-fw fa-close"></i> {{ "PLUGIN_ADMIN.CANCEL"|t }}</button>
<button data-safe-upgrade-action="recheck" class="button secondary">{{ "PLUGIN_ADMIN.SAFE_UPGRADE_RECHECK"|t }}</button>
<button data-safe-upgrade-action="start" class="button primary hidden" disabled>{{ "PLUGIN_ADMIN.SAFE_UPGRADE_START"|t }}</button>
<button data-safe-upgrade-action="finish" class="button primary hidden">{{ "PLUGIN_ADMIN.SAFE_UPGRADE_FINISH"|t }}</button>
</div>
</form>
</div>

View File

@@ -5,7 +5,7 @@
</div>
<h1>{{ "PLUGIN_ADMIN.LATEST_PAGE_UPDATES"|t }}</h1>
<table>
{% for latest in admin.latestPages if admin.latestPages %}
{% for latest in admin.latestPages|default([]) %}
{% set route = latest.rawRoute %}
<tr>
<td class="triple page-title">

View File

@@ -27,6 +27,7 @@
enable_auto_updates_check: '{{ config.plugins.admin.enable_auto_updates_check }}',
{% endif %}
admin_timeout: '{{ config.plugins.admin.session.timeout ?: 1800 }}',
keep_alive_enabled: {{ (config.plugins.admin.session.keep_alive is defined ? config.plugins.admin.session.keep_alive : true) ? 1 : 0 }},
admin_nonce: '{{ admin.getNonce }}',
language: '{{ grav.user.language|default('en') }}',
pro_enabled: '{{ config.plugins["admin-pro"].enabled }}',

View File

@@ -1,11 +1,47 @@
{% do assets.add('jquery',101) %}
{% if authorize(['admin.login', 'admin.super']) %}
{% do assets.addJs(theme_url~'/js/vendor.min.js', { 'loading':'defer' }) %}
{% do assets.addJs(theme_url~'/js/admin.min.js' , { 'loading':'defer' }) %}
{% do assets.addJs(theme_url~'/js/vendor.min.js', { 'loading':'defer' }) %}
{% do assets.addJs(theme_url~'/js/admin.min.js' , { 'loading':'defer' }) %}
{% if browser.getBrowser == 'msie' or browser.getBrowser == 'edge' %}
{% do assets.addJs(theme_url~'/js/form-attr.polyfill.js') %}
{% endif %}
{% if browser.getBrowser == 'msie' or browser.getBrowser == 'edge' %}
{% do assets.addJs(theme_url~'/js/form-attr.polyfill.js') %}
{% endif %}
{% include 'partials/javascripts-extra.html.twig' ignore missing %}
{% include 'partials/javascripts-extra.html.twig' ignore missing %}
{% else %}
{# Not authorized (e.g., login page). Keep session + login nonce fresh. No session-expired overlay here. #}
<script>
(function() {
var base = '{{ base_url_relative }}';
var sep = '{{ config.system.param_sep }}';
// Use Admin's configured timeout if set; fall back to system session timeout
var adminTimeout = {{ (config.plugins.admin.session.timeout is defined ? config.plugins.admin.session.timeout : (config.system.session.timeout|default(1800))) }}; // seconds
// Refresh faster than the admin timeout; aim for ~1/3 of it, but at least 2s and at most 20s
var interval = Math.max(2, Math.min(20, Math.floor(adminTimeout / 3)));
function refreshLoginNonce() {
var url = base + '/task' + sep + 'nonce?ts=' + Date.now();
fetch(url, { credentials: 'same-origin', headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache' }})
.then(function(r){ return r.ok ? r.json() : null; })
.then(function(data){
if (!data || !data.nonce) { return; }
var name = (data.nonce_name || 'login-nonce');
var inputs = document.querySelectorAll('input[name="' + name + '"]');
inputs.forEach(function(i){ i.value = data.nonce; });
})
.catch(function(){ /* silent */ });
}
function boot() {
if (document.getElementById('admin-login')) {
refreshLoginNonce();
setInterval(refreshLoginNonce, interval * 1000);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else { boot(); }
})();
</script>
{% endif %}

View File

@@ -0,0 +1,71 @@
<h1>{{ "PLUGIN_ADMIN.RESTORE_GRAV"|t }}</h1>
<div class="restore-grav-content">
{% set snapshots = admin.safeUpgradeSnapshots() %}
{% if snapshots %}
<p class="restore-grav-intro">
{{ "PLUGIN_ADMIN.RESTORE_GRAV_DESC"|t }}
</p>
<form id="snapshot-delete-form" action="{{ admin_route('/tools/restore-grav') }}" method="post" class="snapshot-delete-form">
<input type="hidden" name="task" value="safeUpgradeDelete">
{{ nonce_field('admin-form', 'admin-nonce')|raw }}
</form>
<table class="restore-grav-table">
<thead>
<tr>
<th class="checkbox-cell"></th>
<th>{{ "PLUGIN_ADMIN.RESTORE_GRAV_TABLE_SNAPSHOT"|t }}</th>
<th>{{ "PLUGIN_ADMIN.RESTORE_GRAV_TABLE_VERSION"|t }}</th>
<th>{{ "PLUGIN_ADMIN.RESTORE_GRAV_TABLE_CREATED"|t }}</th>
<th class="actions-cell">{{ "PLUGIN_ADMIN.RESTORE_GRAV_TABLE_ACTIONS"|t }}</th>
</tr>
</thead>
<tbody>
{% for snapshot in snapshots %}
{% set version = snapshot.source_version ?: snapshot.target_version %}
<tr>
<td class="checkbox-cell">
<input type="checkbox" name="snapshots[]" value="{{ snapshot.id }}" form="snapshot-delete-form" />
</td>
<td>
{% if snapshot.label %}
<strong>{{ snapshot.label }}</strong><br>
<code>{{ snapshot.id }}</code>
{% else %}
<code>{{ snapshot.id }}</code>
{% endif %}
</td>
<td>{{ version ?: "PLUGIN_ADMIN.UNKNOWN"|t }}</td>
<td>
{% if snapshot.created_at %}
{{ snapshot.created_at|date('Y-m-d H:i:s') }}
<span class="hint" data-nicetime-timestamp="{{ snapshot.created_at|date('c') }}">{{ snapshot.created_at|nicetime(false, false) }}</span>
{% else %}
{{ "PLUGIN_ADMIN.UNKNOWN"|t }}
{% endif %}
</td>
<td class="actions-cell">
<button type="button" class="button" data-restore-snapshot="{{ snapshot.id }}">{{ "PLUGIN_ADMIN.RESTORE_GRAV_RESTORE_BUTTON"|t }}</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="button-bar">
<button type="button" class="button primary" data-create-snapshot>
{{ "PLUGIN_ADMIN.RESTORE_GRAV_CREATE_SNAPSHOT"|t }}
</button>
<button type="submit" form="snapshot-delete-form" class="button danger" name="task" value="safeUpgradeDelete">
{{ "PLUGIN_ADMIN.RESTORE_GRAV_DELETE_SELECTED"|t }}
</button>
</div>
{% else %}
<div class="notice secondary">
<p>{{ "PLUGIN_ADMIN.RESTORE_GRAV_NONE"|t }}</p>
</div>
{% endif %}
</div>

View File

@@ -2,8 +2,11 @@
{% set tools_slug = uri.basename %}
{% if tools_slug == 'tools' %}{% set tools_slug = 'backups' %}{% endif %}
{% set title = "PLUGIN_ADMIN.TOOLS"|t ~ ": " ~ ("PLUGIN_ADMIN." ~ tools_slug|underscorize|upper)|t %}
{% set tools = admin.tools() %}
{% if tools[tools_slug] is not defined %}
{% set tools_slug = tools|keys|first %}
{% endif %}
{% set title = "PLUGIN_ADMIN.TOOLS"|t ~ ": " ~ ("PLUGIN_ADMIN." ~ tools_slug|underscorize|upper)|t %}
{% set titlebar -%}
{% include 'partials/tools-' ~ tools_slug ~ '-titlebar.html.twig' ignore missing %}
@@ -36,6 +39,7 @@
</div>
</div>
{% endif %}
{{ dump(tools) }}
{% endblock %}
{% block content %}
@@ -47,4 +51,3 @@
<h1>Unauthorized</h1>
{% endif %}
{% endblock %}

View File

@@ -1,27 +0,0 @@
#!/bin/sh
#
# Configuration
#
# sass source
SASS_SOURCE_PATH="scss"
# sass options
SASS_OPTIONS="--source-map=true --style=nested"
# css target
CSS_TARGET_PATH="css-compiled"
#
# Check prerequisites
#
wtfile=$(command -v wt) || { echo "install wellington with 'brew install wellington"; exit 1; }
#
# Watch folder for changes
#
cd -P `pwd`
$wtfile compile "$SASS_SOURCE_PATH" -b "$CSS_TARGET_PATH" $SASS_OPTIONS
$wtfile watch "$SASS_SOURCE_PATH" -b "$CSS_TARGET_PATH" $SASS_OPTIONS

View File

@@ -33,10 +33,7 @@ module.exports = (env, argv) => ({
jquery: 'jQuery',
'grav-config': 'GravAdmin'
},
plugins: [new ESLintPlugin({
extensions: ['js', 'jsx'],
exclude: ['/node_modules/']
})],
plugins: [],
module: {
rules: [
{ enforce: 'pre', test: /\.json$/, loader: 'json-loader' },

File diff suppressed because it is too large Load Diff