-
Notifications
You must be signed in to change notification settings - Fork 94
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
NEW AdminController as superclass for /admin/* routed controllers #1836
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Basically everything in here was just lifted straight from |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,359 @@ | ||
<?php | ||
|
||
namespace SilverStripe\Admin; | ||
|
||
use BadMethodCallException; | ||
use InvalidArgumentException; | ||
use SilverStripe\Control\ContentNegotiator; | ||
use SilverStripe\Control\Controller; | ||
use SilverStripe\Control\HTTPRequest; | ||
use SilverStripe\Control\HTTPResponse; | ||
use SilverStripe\Control\HTTPResponse_Exception; | ||
use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware; | ||
use SilverStripe\Forms\HTMLEditor\HTMLEditorConfig; | ||
use SilverStripe\i18n\i18n; | ||
use SilverStripe\Security\Permission; | ||
use SilverStripe\Security\Security; | ||
use SilverStripe\Versioned\Versioned; | ||
use SilverStripe\View\SSViewer; | ||
|
||
/** | ||
* The base class for all controllers routed using the /admin/* route. | ||
* | ||
* This class is automatically routed via the AdminRootController. | ||
* It's responsible for ensuring permissions are respected. | ||
*/ | ||
abstract class AdminController extends Controller | ||
{ | ||
/** | ||
* The current url segment attached to the controller | ||
*/ | ||
private static ?string $url_segment = null; | ||
|
||
/** | ||
* Used by {@link AdminRootController} to augment Director route rules for subclasses of AdminController | ||
*/ | ||
private static string $url_rule = '/$Action/$ID/$OtherID'; | ||
|
||
/** | ||
* Priority order for routing rules. If two controllers match a given request, the one with a higher | ||
* priority will handle the request. | ||
*/ | ||
private static int $url_priority = 50; | ||
|
||
/** | ||
* Codes which are required from the current user to view this controller. | ||
* | ||
* If multiple codes are provided, all of them are required. | ||
* All CMS controllers require "CMS_ACCESS_LeftAndMain" as a baseline check, | ||
* and fall back to "CMS_ACCESS_<class>" if no permissions are defined here. | ||
* See {@link canView()} for more details on permission checks. | ||
*/ | ||
private static string|array $required_permission_codes = []; | ||
|
||
/** | ||
* The configuration passed to the supporting JS for each CMS section includes a 'name' key | ||
* that by default matches the FQCN of the current class. This setting allows you to change | ||
* the key if necessary (for example, if you are overloading CMSMain or another core class | ||
* and want to keep the core JS - which depends on the core class names - functioning, you | ||
* would need to set this to the FQCN of the class you are overloading). | ||
* | ||
* See getClientConfig() | ||
*/ | ||
private static ?string $section_name = null; | ||
|
||
/** | ||
* Get list of required permissions for accessing this controller. | ||
* If false, no permission is required. | ||
*/ | ||
public static function getRequiredPermissions(): array|string|false | ||
{ | ||
if (static::class === AdminController::class) { | ||
throw new BadMethodCallException('getRequiredPermissions should be called on a subclass'); | ||
} | ||
Comment on lines
+71
to
+73
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just to avoid it being called here, since this is explicitly abstract there's no permission for this class (or reason to want one) |
||
// If the user is accessing LeftAndMain directly, only generic permissions are required. | ||
if (static::class === LeftAndMain::class) { | ||
return 'CMS_ACCESS'; | ||
} | ||
$codes = static::config()->get('required_permission_codes'); | ||
// allow explicit FALSE to disable subclass check | ||
if ($codes === false) { | ||
return false; | ||
} | ||
if ($codes) { | ||
return $codes; | ||
} | ||
// Fallback if no explicit permission was declared | ||
return 'CMS_ACCESS_' . static::class; | ||
} | ||
|
||
public function canView($member = null) | ||
{ | ||
if (!$member && $member !== false) { | ||
$member = Security::getCurrentUser(); | ||
} | ||
|
||
// don't allow unauthenticated users | ||
if (!$member) { | ||
return false; | ||
} | ||
|
||
// alternative extended checks | ||
if ($this->hasMethod('alternateAccessCheck')) { | ||
$alternateAllowed = $this->alternateAccessCheck($member); | ||
if ($alternateAllowed === false) { | ||
return false; | ||
} | ||
} | ||
|
||
// Check for "Access to all CMS sections" permission | ||
if (Permission::checkMember($member, 'CMS_ACCESS_LeftAndMain')) { | ||
return true; | ||
} | ||
|
||
// Check for permission to access this specific controller | ||
$codes = static::getRequiredPermissions(); | ||
// allow explicit FALSE to disable subclass check | ||
if ($codes === false) { | ||
return true; | ||
} | ||
foreach ((array) $codes as $code) { | ||
if (!Permission::checkMember($member, $code)) { | ||
return false; | ||
} | ||
} | ||
|
||
return true; | ||
} | ||
|
||
public function Link($action = null) | ||
{ | ||
// LeftAndMain methods have a top-level uri access | ||
if (static::class === LeftAndMain::class) { | ||
$segment = ''; | ||
Comment on lines
+131
to
+133
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A follow-up card will make |
||
} else { | ||
// Get url_segment | ||
$segment = static::config()->get('url_segment'); | ||
if (!$segment) { | ||
throw new BadMethodCallException( | ||
sprintf('AdminController subclasses (%s) must have url_segment', static::class) | ||
); | ||
} | ||
} | ||
|
||
$link = Controller::join_links( | ||
AdminRootController::admin_url(), | ||
$segment, | ||
"$action" | ||
); | ||
$this->extend('updateLink', $link); | ||
return $link; | ||
} | ||
|
||
/** | ||
* Overloaded redirection logic to trigger a fake redirect on ajax requests. | ||
* While this violates HTTP principles, its the only way to work around the | ||
* fact that browsers handle HTTP redirects opaquely, no intervention via JS is possible. | ||
* In isolation, that's not a problem - but combined with history.pushState() | ||
* it means we would request the same redirection URL twice if we want to update the URL as well. | ||
* See LeftAndMain.js for the required jQuery ajaxComplete handlers. | ||
*/ | ||
public function redirect(string $url, int $code = 302): HTTPResponse | ||
{ | ||
if ($this->getRequest()->isAjax()) { | ||
$response = $this->getResponse(); | ||
$response->addHeader('X-ControllerURL', $url); | ||
if ($this->getRequest()->getHeader('X-Pjax') && !$response->getHeader('X-Pjax')) { | ||
$response->addHeader('X-Pjax', $this->getRequest()->getHeader('X-Pjax')); | ||
} | ||
$newResponse = new LeftAndMain_HTTPResponse( | ||
$response->getBody(), | ||
$response->getStatusCode(), | ||
$response->getStatusDescription() | ||
); | ||
foreach ($response->getHeaders() as $k => $v) { | ||
$newResponse->addHeader($k, $v); | ||
} | ||
$newResponse->setIsFinished(true); | ||
$this->setResponse($newResponse); | ||
// Actual response will be re-requested by client | ||
return $newResponse; | ||
} else { | ||
return parent::redirect($url, $code); | ||
} | ||
} | ||
Comment on lines
+161
to
+184
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not 100% sure exactly what all of the ramifications of this are - but since it's dealing explicitly with AJAX requests and that's mostly what this method is for, I decided to lift this out of LeftAndMain and into this class. |
||
|
||
/** | ||
* Returns configuration required by the client app | ||
*/ | ||
public function getClientConfig(): array | ||
{ | ||
// Allows the section name to be overridden in config | ||
$name = static::config()->get('section_name') ?: static::class; | ||
// Trim leading/trailing slash to make it easier to concatenate URL | ||
// and use in routing definitions. | ||
$url = trim($this->Link(), '/'); | ||
$clientConfig = [ | ||
'name' => $name, | ||
'url' => $url, | ||
'reactRoutePath' => preg_replace('/^' . preg_quote(AdminRootController::admin_url(), '/') . '/', '', $url), | ||
]; | ||
$this->extend('updateClientConfig', $clientConfig); | ||
return $clientConfig; | ||
} | ||
Comment on lines
+189
to
+203
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is lifted over here so that we can inject links for these controllers into the config that JS gets a hold of. |
||
|
||
protected function init() | ||
{ | ||
parent::init(); | ||
|
||
HTTPCacheControlMiddleware::singleton()->disableCache(); | ||
|
||
SSViewer::setRewriteHashLinksDefault(false); | ||
ContentNegotiator::setEnabled(false); | ||
|
||
// set language | ||
$member = Security::getCurrentUser(); | ||
if (!empty($member->Locale)) { | ||
i18n::set_locale($member->Locale); | ||
} | ||
|
||
// Don't allow access if the request hasn't finished being handled and the user can't access this controller | ||
if (!$this->canView() && !$this->getResponse()->isFinished()) { | ||
// Allow subclasses and extensions to redirect somewhere more appropriate | ||
$this->invokeWithExtensions('onInitPermissionFailure'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This lets LeftAndMain do some redirecting it was doing before without making the method abstract (and therefore required on classes that don't need it) and without doing a |
||
|
||
// If we're redirecting away, just let that happen | ||
if ($this->getResponse()->isRedirect()) { | ||
return; | ||
} | ||
|
||
if (Security::getCurrentUser()) { | ||
$this->getRequest()->getSession()->clear("BackURL"); | ||
} | ||
|
||
// if no alternate menu items have matched, return a permission error | ||
$messageSet = [ | ||
'default' => _t( | ||
__CLASS__ . '.PERMDEFAULT', | ||
"You must be logged in to access the administration area." | ||
), | ||
'alreadyLoggedIn' => _t( | ||
__CLASS__ . '.PERMALREADY', | ||
"I'm sorry, but you can't access that part of the CMS." | ||
), | ||
'logInAgain' => _t( | ||
__CLASS__ . '.PERMAGAIN', | ||
"You have been logged out of the CMS." | ||
), | ||
]; | ||
Comment on lines
+234
to
+248
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shortened these messages to suit the fact that most of the time these controllers will be handling AJAX requests and therefore there won't be a menu "below" like the old messages suggest. I considered finding a way to use the longer messages in LeftAndMain specifically, but the context of "there's a form below" is unnecessary because... well, there's a form below. You can see it (or tab to it if you're on a screen reader) |
||
|
||
$this->suppressAdminErrorContext = true; | ||
Security::permissionFailure($this, $messageSet); | ||
return; | ||
} | ||
|
||
// Don't continue if there's already been a redirection request. | ||
if ($this->getResponse()->isRedirect()) { | ||
return; | ||
} | ||
|
||
$this->extend('onInit'); | ||
GuySartorelli marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
// Load the editor with original user themes before overwriting | ||
// them with admin themes | ||
$themes = HTMLEditorConfig::getThemes(); | ||
if (empty($themes)) { | ||
HTMLEditorConfig::setThemes(SSViewer::get_themes()); | ||
} | ||
|
||
// Assign default cms theme and replace user-specified themes | ||
// This ensures any templates rendered use appropriate templates and resources | ||
// instead of the front-end ones | ||
SSViewer::set_themes(LeftAndMain::config()->uninherited('admin_themes')); | ||
|
||
// Set the current reading mode | ||
Versioned::set_stage(Versioned::DRAFT); | ||
|
||
// Set default reading mode to suppress ?stage=Stage querystring params in CMS | ||
Versioned::set_default_reading_mode(Versioned::get_reading_mode()); | ||
} | ||
|
||
/** | ||
* Get a data value from JSON in body of the POST request, ensuring it exists | ||
* Will only read from the root node of the JSON body | ||
*/ | ||
protected function getPostedJsonValue(HTTPRequest $request, string $key): mixed | ||
{ | ||
$data = json_decode($request->getBody(), true); | ||
if (!array_key_exists($key, $data)) { | ||
$this->jsonError(400); | ||
} | ||
return $data[$key]; | ||
} | ||
|
||
/** | ||
* Creates a successful json response | ||
*/ | ||
protected function jsonSuccess(int $statusCode, ?array $data = null): HTTPResponse | ||
{ | ||
if ($statusCode < 200 || $statusCode >= 300) { | ||
throw new InvalidArgumentException("Status code $statusCode must be between 200 and 299"); | ||
} | ||
if (is_null($data)) { | ||
$body = ''; | ||
} else { | ||
$body = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); | ||
} | ||
return $this->getResponse() | ||
->addHeader('Content-Type', 'application/json') | ||
->setStatusCode($statusCode) | ||
->setBody($body); | ||
} | ||
|
||
/** | ||
* Throw an error HTTPResponse encoded as json | ||
* | ||
* @throws HTTPResponse_Exception which interrupts request handling with the appropriate response | ||
*/ | ||
protected function jsonError(int $errorCode, string $errorMessage = ''): void | ||
{ | ||
// Build error from message | ||
$error = [ | ||
'type' => 'error', | ||
'code' => $errorCode, | ||
]; | ||
if ($errorMessage) { | ||
$error['value'] = $errorMessage; | ||
} else { | ||
$messageDefault = match ($errorCode) { | ||
400 => 'Sorry, it seems there was something wrong with the request.', | ||
401 => 'Sorry, it seems you are not authorised to access this section or object.', | ||
403 => 'Sorry, it seems the action you were trying to perform is forbidden.', | ||
404 => 'Sorry, it seems you were trying to access a section or object that doesn\'t exist.', | ||
500 => 'Sorry, it seems there was an internal server error.', | ||
503 => 'Sorry, it seems the service is temporarily unavailable.', | ||
default => 'Error', | ||
}; | ||
/** @phpstan-ignore translation.key (we need the key to be dynamic here) */ | ||
$error['value'] = _t(__CLASS__ . ".ErrorMessage{$errorCode}", $messageDefault); | ||
} | ||
|
||
// Support explicit error handling with status = error, or generic message handling | ||
// with a message of type = error | ||
$result = [ | ||
'status' => 'error', | ||
'errors' => [$error] | ||
]; | ||
$response = HTTPResponse::create(json_encode($result), $errorCode) | ||
->addHeader('Content-Type', 'application/json'); | ||
|
||
// Call a handler method such as onBeforeHTTPError404 | ||
$this->extend("onBeforeJSONError{$errorCode}", $request, $response); | ||
|
||
// Call a handler method such as onBeforeHTTPError, passing 404 as the first arg | ||
$this->extend('onBeforeJSONError', $errorCode, $request, $response); | ||
|
||
// Throw a new exception | ||
throw new HTTPResponse_Exception($response); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's handled by config in the class itself now.