1
0
mirror of https://github.com/processwire/processwire.git synced 2025-08-15 03:05:26 +02:00

Add support for $template->pagefileSecure option configured per-template. This also expands upon what $config->pagefileSecure could do before, now supporting the ability to secure Pagefiles even for public pages when appropriate.

This commit is contained in:
Ryan Cramer
2020-08-28 14:56:19 -04:00
parent 853a5cc490
commit 9dc13f977e
7 changed files with 222 additions and 64 deletions

View File

@@ -149,6 +149,7 @@
*
* @property string $userAuthSalt Salt generated at install time to be used as a secondary/non-database salt for the password system. #pw-group-session
* @property string $userAuthHashType Default is 'sha1' - used only if Blowfish is not supported by the system. #pw-group-session
* @property string $tableSalt #pw-group-system Additional hash for other (non-authentication) purposes, present only on installations start from 3.0.164+. #pw-group-system
*
* @property bool $internal This is automatically set to FALSE when PW is externally bootstrapped. #pw-group-runtime
* @property bool $external This is automatically set to TRUE when PW is externally bootstrapped. #pw-internal

View File

@@ -4107,6 +4107,26 @@ class Page extends WireData implements \Countable, WireMatchable {
return $this->filesManager;
}
/**
* Does this Page use secure Pagefiles?
*
* See also `$template->pagefileSecure` and `$config->pagefileSecure` which determine the return value.
*
* #pw-group-files
*
* @return bool|null Returns boolean true if yes, false if no, or null if not known
* @since 3.0.166
*
*/
public function secureFiles() {
if($this->wire()->config->pagefileSecure && !$this->isPublic()) return true;
if(!$this->template) return null;
$value = $this->template->pagefileSecure;
if($value < 1) return false; // 0: disabled
if($value > 1) return true; // 2: files always secure
return !$this->isPublic(); // 1: secure only if page not public
}
/**
* Does the page have a files path for storing files?
*

View File

@@ -62,6 +62,14 @@ class PagefilesManager extends Wire {
const metaFileName = '.pw';
*/
/**
* Count of renamed paths when changing between pagefileSecure and non-pagefileSecure
*
* @var int
*
*/
static $numRenamedPaths = 0;
/**
* Reference to the Page object this PagefilesManager is managing
*
@@ -528,7 +536,7 @@ class PagefilesManager extends Wire {
*/
static public function _path(Page $page, $extended = false) {
$config = $page->wire('config');
$config = $page->wire()->config;
$path = $config->paths->files;
$securePrefix = $config->pagefileSecurePathPrefix;
@@ -541,39 +549,39 @@ class PagefilesManager extends Wire {
$publicPath = $path . $page->id . '/';
$securePath = $path . $securePrefix . $page->id . '/';
}
/* @todo 3.0.150:
$filesPublic = true;
if(!$page->isPublic()) {
// page not publicly viewable to all, check if files are public or not
if($config->pagefileSecure) {
$filesPublic = false;
} else if($page->template && $page->template->pagefileSecure) {
$filesPublic = false; // 3.0.150+
}
}
if($filesPublic) {
*/
if($page->isPublic() || !$config->pagefileSecure) {
$secureFiles = $page->secureFiles();
if($secureFiles === false) {
// use the public path, renaming a secure path to public if it exists
if(is_dir($securePath) && !is_dir($publicPath)) {
@rename($securePath, $publicPath);
if(is_dir($securePath) && !is_dir($publicPath) && $secureFiles !== null) {
$page->wire()->files->rename($securePath, $publicPath);
self::$numRenamedPaths++;
}
$filesPath = $publicPath;
} else if($secureFiles === null) {
$filesPath = $publicPath;
} else {
// use the secure path, renaming the public to secure if it exists
$hasSecurePath = is_dir($securePath);
if(is_dir($publicPath) && !$hasSecurePath) {
@rename($publicPath, $securePath);
$page->wire()->files->rename($publicPath, $securePath);
self::$numRenamedPaths++;
} else if(!$hasSecurePath && self::defaultSecurePathPrefix != $securePrefix) {
// we track this just in case the prefix was newly added to config.php, this prevents
// losing track of the original directories
$securePath2 = $extended ? $path . self::_dirExtended($page->id, self::defaultSecurePathPrefix) : $path . self::defaultSecurePathPrefix . $page->id . '/';
if($extended) {
$securePath2 = $path . self::_dirExtended($page->id, self::defaultSecurePathPrefix);
} else {
$securePath2 = $path . self::defaultSecurePathPrefix . $page->id . '/';
}
if(is_dir($securePath2)) {
// if the secure path prefix has been changed from undefined to defined
@rename($securePath2, $securePath);
$page->wire()->files->rename($securePath2, $securePath);
self::$numRenamedPaths++;
}
}
$filesPath = $securePath;
@@ -587,6 +595,22 @@ class PagefilesManager extends Wire {
return $filesPath;
}
/**
* Get quantity of renamed paths to to pagefileSecure changes
*
* #pw-internal
*
* @param bool $reset Also reset to 0?
* @return int
* @since 3.0.166
*
*/
static public function numRenamedPaths($reset = false) {
$num = self::$numRenamedPaths;
if($reset) self::$numRenamedPaths = 0;
return $num;
}
/**
* Generate the directory name (after /site/assets/files/)
*

View File

@@ -81,7 +81,7 @@
* @property int|bool $noAppendTemplateFile Disabe automatic append of $config->appendTemplateFile (if in use). #pw-group-files
* @property string $prependFile File to prepend to template file (separate from $config->prependTemplateFile). #pw-group-files
* @property string $appendFile File to append to template file (separate from $config->appendTemplateFile). #pw-group-files
* @property bool $pagefileSecure Use secure pagefiles for pages using this template? (3.0.150+) #pw-group-files
* @property int $pagefileSecure Use secure pagefiles for pages using this template? 0=No/not set, 1=Yes (for non-public pages), 2=Always (3.0.166+) #pw-group-files
*
* Page Editor
*
@@ -272,7 +272,7 @@ class Template extends WireData implements Saveable, Exportable {
'noAppendTemplateFile' => 0, // disable automatic inclusion of $config->appendTemplateFile
'prependFile' => '', // file to prepend (relative to /site/templates/)
'appendFile' => '', // file to append (relative to /site/templates/)
'pagefileSecure' => false, // secure files connected with page? (3.0.150+)
'pagefileSecure' => 0, // secure files connected with page? 0=Off, 1=Yes for unpub/non-public pages, 2=Always (3.0.166+)
'tabContent' => '', // label for the Content tab (if different from 'Content')
'tabChildren' => '', // label for the Children tab (if different from 'Children')
'nameLabel' => '', // label for the "name" property of the page (if something other than "Name")
@@ -1376,6 +1376,23 @@ class Template extends WireData implements Saveable, Exportable {
return $this->wire('templates')->getPageClass($this, $withNamespace);
}
/**
* Check that all file asset paths are consistent with current pagefileSecure setting and access control
*
* #pw-internal
*
* @return int Returns quantity of renamed paths, or 0 if all is in order
* @since 3.0.166
*
*/
public function checkPagefileSecure() {
PagefilesManager::numRenamedPaths(true);
foreach($this->wire()->pages->findMany("template=$this, include=all") as $p) {
PagefilesManager::_path($p);
}
return PagefilesManager::numRenamedPaths(true);
}
/**
* Set the icon to use with this template
*

View File

@@ -266,7 +266,7 @@ class PagePermissions extends WireData implements Module {
} else if($this->wire('page') && $this->wire('page')->process == 'ProcessProfile') {
// user editing themself in ProcessProfile, when process not yet established
return true;
} else if($process == 'ProcessPageView' && $config->pagefileSecure && $options['viewable']) {
} else if($process == 'ProcessPageView' && $page->secureFiles() && $options['viewable']) {
// user is viewing a file that is part of their User page when pagefileSecure mode active
return $process->getResponseType() == ProcessPageView::responseTypeFile;
}
@@ -481,6 +481,28 @@ class PagePermissions extends WireData implements Module {
return true;
}
/**
* Is given file viewable?
*
* It is assumed that you have already determined the Page is viewable.
*
* @param Page $page
* @param Pagefile|string $pagefile
* @return bool|null Returns bool, or null if not known
* @since 3.0.166
*
*/
protected function fileViewable(Page $page, $pagefile) {
if($this->wire()->user->isSuperuser()) return true;
if(!$pagefile instanceof Pagefile) {
$pagefile = $page->hasFile(basename($pagefile), array('getPagefile' => true));
if(!$pagefile) return null;
}
$field = $pagefile->field;
if(!$field) return null;
return $this->fieldViewable($page, $field, false);
}
/**
* Is the given field editable by the current user in their user profile?
*
@@ -511,6 +533,11 @@ class PagePermissions extends WireData implements Module {
* - Optionally specify Language object or language name as first argument to check if viewable
* in that language (requires LanguageSupportPageNames module).
* - Optionally specify boolean false as first or second argument to bypass template filename check.
* - Optionally specify a Pagefile object or file basename to check if file is viewable. (3.0.166+)
*
* Returns boolean true or false. If given a Pagefile or file basename, it can also return null if
* the Page itself is viewable but the file did not map to something we recognize as access controlled,
* like a file basename that isnt present in any file fields on the page.
*
* @param HookEvent $event
*
@@ -520,11 +547,12 @@ class PagePermissions extends WireData implements Module {
/** @var Page $page */
$page = $event->object;
$viewable = true;
$user = $this->wire('user');
$user = $this->wire()->user;
$arg0 = $event->arguments(0);
$arg1 = $event->arguments(1);
$field = null; // field name or Field object, if specified as arg0
$checkFile = true; // return false if template filename doesn't exist
$checkTemplateFile = true; // return false if template filename doesn't exist
$pagefile = null;
$status = $page->status;
// allow specifying User instance as argument 0
@@ -533,16 +561,21 @@ class PagePermissions extends WireData implements Module {
if($arg0 instanceof User) {
// user specified
$user = $arg0;
} else if($arg0 instanceof Pagefile || (is_string($arg0) && strpos($arg0, '.'))) {
// Pagefile or file basename
$pagefile = $arg0;
$checkTemplateFile = false;
} else if($arg0 instanceof Field || is_string($arg0)) {
// field name, Field object or language name specified
// @todo: prevent possible collision of field name and language name
$field = $arg0;
$checkFile = false;
$checkTemplateFile = false;
}
}
if($arg0 === false || $arg1 === false) {
// bypass template filename check
$checkFile = false;
$checkTemplateFile = false;
}
// if page has corrupted status, this need not affect viewable access
@@ -552,7 +585,7 @@ class PagePermissions extends WireData implements Module {
if($status >= Page::statusUnpublished) {
// unpublished pages are not viewable, but see override below this if/else statement
$viewable = false;
} else if(!$page->template || ($checkFile && !$page->template->filenameExists())) {
} else if(!$page->template || ($checkTemplateFile && !$page->template->filenameExists())) {
// template file does not exist
$viewable = false;
} else if($user->isSuperuser()) {
@@ -574,11 +607,13 @@ class PagePermissions extends WireData implements Module {
// if the page is editable by the current user, force it to be viewable (if not viewable due to being unpublished)
if(!$viewable && !$user->isGuest() && ($status & Page::statusUnpublished)) {
if($page->editable() && (!$checkFile || $page->template->filenameExists())) $viewable = true;
if($page->editable() && (!$checkTemplateFile || $page->template->filenameExists())) $viewable = true;
}
if($field && $viewable) {
$viewable = $this->fieldViewable($page, $field, false);
} else if($pagefile && $viewable) {
$viewable = $this->fileViewable($page, $pagefile);
}
$event->return = $viewable;

View File

@@ -29,7 +29,7 @@ class ProcessPageView extends Process {
'version' => 104,
'permanent' => true,
'permission' => 'page-view',
);
);
}
/**
@@ -372,8 +372,7 @@ class ProcessPageView extends Process {
$numParts = substr_count($it, '/');
if($numParts > $config->maxUrlDepth) return null;
// if($this->isSecurePagefileUrl($it)) { // @todo replace next line with this in 3.0.150
if($config->pagefileSecure) {
if($this->pagefileSecurePossible($it)) {
$page = $this->checkRequestFile($it);
if(is_object($page)) {
$this->responseType = self::responseTypeFile;
@@ -482,10 +481,8 @@ class ProcessPageView extends Process {
*/
protected function checkRequestFile(&$it) {
/** @var Config $config */
$config = $this->wire('config');
/** @var Pages $pages */
$pages = $this->wire('pages');
$config = $this->wire()->config;
$pages = $this->wire()->pages;
// request with url to root (applies only if site runs from subdirectory)
$itRoot = rtrim($config->urls->root, '/') . $it;
@@ -501,11 +498,14 @@ class ProcessPageView extends Process {
$idPath = trim($matches[1], '/');
$file = trim($matches[2], '.');
if(!strpos($file, '.')) return $pages->newNullPage();
if(!ctype_digit("$idPath")) {
// extended paths where id separated by slashes, i.e. 1/2/3/4
if($config->pagefileExtendedPaths) {
// allow extended paths
$idPath = str_replace('/', '', $matches[1]);
if(!ctype_digit("$idPath")) return $pages->newNullPage();
} else {
// extended paths not allowed
return $pages->newNullPage();
@@ -525,7 +525,7 @@ class ProcessPageView extends Process {
return $pages->newNullPage();
} else if(!preg_match('/^[a-zA-Z0-9][-_a-zA-Z0-9]+$/', $subdir)) {
// subdirectory nat in expected format
// subdirectory not in expected format
return $pages->newNullPage();
}
@@ -702,6 +702,7 @@ class ProcessPageView extends Process {
if($this->requestFile) {
// if a file was requested, we still allow view even if page doesn't have template file
if($page->viewable($this->requestFile) === false) return null;
if($page->viewable(false)) return $page;
// if($page->editable()) return $page;
if($this->checkAccessDelegated($page)) return $page;
@@ -863,29 +864,37 @@ class ProcessPageView extends Process {
* If the page is public, then it just does a 301 redirect to the file.
*
* @param Page $page
* @param string $basename
* @param string $basename
* @param array $options
* @throws Wire404Exception
*
*/
protected function ___sendFile($page, $basename) {
protected function ___sendFile($page, $basename, array $options = array()) {
$err = 'File not found';
// use the static hasPath first to make sure this page actually has a files directory
// this ensures one isn't automatically created when we call $page->filesManager->path below
if(!PagefilesManager::hasPath($page)) throw new Wire404Exception($err, Wire404Exception::codeFile);
$filename = $page->filesManager->path() . $basename;
if(!is_file($filename)) throw new Wire404Exception($err, Wire404Exception::codeFile);
if($page->isPublic()) {
// deprecated, only necessary for method 2 in checkRequestFile
$this->wire('session')->redirect($page->filesManager->url() . $basename);
} else {
$options = array('exit' => false);
wireSendFile($filename, $options);
if(!$page->hasFilesPath()) {
throw new Wire404Exception($err, Wire404Exception::codeFile);
}
$filename = $page->filesPath() . $basename;
if(!file_exists($filename)) {
throw new Wire404Exception($err, Wire404Exception::codeFile);
}
if(!$page->secureFiles()) {
// if file is not secured, redirect to it
// (potentially deprecated, only necessary for method 2 in checkRequestFile)
$this->wire()->session->redirect($page->filesManager->url() . $basename);
return;
}
// options for WireHttp::sendFile
$defaults = array('exit' => false);
$options = array_merge($defaults, $options);
$this->wire()->files->send($filename, $options);
}
/**
@@ -971,10 +980,11 @@ class ProcessPageView extends Process {
*
* @param string $url
* @return bool
* @todo enable in 3.0.150
* @since 3.0.166
*
protected function isSecurePagefileUrl($url) {
$config = $this->wire('config');
*/
protected function pagefileSecurePossible($url) {
$config = $this->wire()->config;
// if URL does not start from root, prepend root
if(strpos($url, $config->urls->root) !== 0) $url = $config->urls->root . ltrim($url, '/');
@@ -987,7 +997,7 @@ class ProcessPageView extends Process {
// check if any templates allow pagefileSecure option
$allow = false;
foreach($this->wire('templates') as $template) {
foreach($this->wire()->templates as $template) {
if(!$template->pagefileSecure) continue;
$allow = true;
break;
@@ -996,7 +1006,6 @@ class ProcessPageView extends Process {
// if at least one template supports pagefileSecure option we will return true here
return $allow;
}
*/
}

View File

@@ -684,6 +684,7 @@ class ProcessTemplate extends Process {
$t->add($this->buildEditFormAccess($template));
$overrides = $this->buildEditFormAccessOverrides($template);
if($overrides) $t->add($overrides);
$t->add($this->buildEditFormAccessFiles($template));
$form->add($t);
$t = $this->wire(new InputfieldWrapper());
@@ -1246,6 +1247,7 @@ class ProcessTemplate extends Process {
$form = $this->wire(new InputfieldWrapper());
/** @var Languages|null $languages */
$languages = $this->wire('languages');
$advanced = $this->wire()->config->advanced;
// --------------------
@@ -1392,7 +1394,7 @@ class ProcessTemplate extends Process {
$field->attr('name', 'noChangeTemplate');
$field->label = $this->_("Don't allow pages to change their template?");
$field->description = $this->_("When checked, pages using this template will be unable to change to another template."); // noChangeTemplate option, description
if($this->wire('config')->advanced) $field->notes = 'API: $template->noChangeTemplate = 1; // ' . $label0;
if($advanced) $field->notes = 'API: $template->noChangeTemplate = 1; // ' . $label0;
$field->attr('value', 1);
if($template->noChangeTemplate) {
@@ -1409,7 +1411,7 @@ class ProcessTemplate extends Process {
$field->attr('name', 'noUnpublish');
$field->label = $this->_("Don't allow unpublished pages");
$field->description = $this->_("When checked, pages using this template may only exist in a published state and may not be unpublished."); // noUnpublish option, description
if($this->wire('config')->advanced) $field->notes = 'API: $template->noUnpublish = 1; // ' . $label0;
if($advanced) $field->notes = 'API: $template->noUnpublish = 1; // ' . $label0;
$field->attr('value', 1);
if($template->noUnpublish) {
@@ -1426,7 +1428,7 @@ class ProcessTemplate extends Process {
$field->attr('name', 'allowChangeUser');
$field->label = $this->_("Allow the 'created user' to be changed on pages?");
$field->description = $this->_("When checked, pages using this template will have an option to change the 'created by user' (for superusers only). It will also enable the \$page->createdUser or \$page->created_users_id fields to be saved via the API."); // allowChangeUser option, description
if($this->wire('config')->advanced) $field->notes = 'API: $template->allowChangeUser = 1; // ' . $label0;
if($advanced) $field->notes = 'API: $template->allowChangeUser = 1; // ' . $label0;
$field->attr('value', 1);
if($template->allowChangeUser) {
@@ -1443,7 +1445,7 @@ class ProcessTemplate extends Process {
$field->attr('name', 'noMove');
$field->label = $this->_("Don't allow pages to be moved?");
$field->description = $this->_("If you want to prevent pages using this template from being moved (changing parent) then check this box."); // noMove option, description
if($this->wire('config')->advanced) $field->notes = 'API: $template->noMove = 1; // ' . $label0;
if($advanced) $field->notes = 'API: $template->noMove = 1; // ' . $label0;
$field->attr('value', 1);
if($template->noMove) {
@@ -1461,7 +1463,7 @@ class ProcessTemplate extends Process {
$field->attr('name', 'noLang');
$field->label = $this->_("Disable multi-language support for this template?");
$field->description = $this->_("When checked, pages using this template will only use the default language.");
if($this->wire('config')->advanced) $field->notes = 'API: $template->noLang = 1; // ' . $label0;
if($advanced) $field->notes = 'API: $template->noLang = 1; // ' . $label0;
$field->attr('value', 1);
if($template->noLang) {
@@ -2128,10 +2130,40 @@ class ProcessTemplate extends Process {
$field->attr('value', $template->noInherit ? 1 : 0);
$fieldset->add($field);
return $form;
}
/**
* Build the "pagefileSecure" field for the "access" tab
*
* @param Template $template
* @return InputfieldRadios
*
*/
protected function buildEditFormAccessFiles(Template $template) {
/** @var InputfieldRadios $f */
$f = $this->modules->get('InputfieldRadios');
$f->attr('id+name', 'pagefileSecure');
$f->label = $this->_('Prevent direct access to file assets owned by pages using this template?');
$f->icon = 'download';
$f->description =
$this->_('When direct access to a file in [u]/site/assets/files/[/u] is blocked, ProcessWire can manage delivery of the file, rather than Apache.') . ' ' .
$this->_('This enables the file to be access controlled in the same manner as the page that owns it, while still using the original file URL.') . ' ' .
$this->_('Note that it takes more overhead to deliver a file this way, so only choose the “Yes always” option if you need it.');
$f->notes =
$this->_('Always test that the access control is working how you expect by attempting to access the protected file(s) in your browser.') . ' ' .
$this->_('Do this for when you expect to have access (logged-in) and when you do not (logged-out).');
$f->addOption(0, $this->_('No') . ' ' .
'[span.detail] ' . $this->_('(uses site-wide configuration instead)') . ' [/span]');
$f->addOption(1, $this->_('Yes when page is unpublished, in the trash, or not publicly accessible'));
$f->addOption(2, $this->_('Yes always, regardless of page status or access control'));
$f->val((int) $template->pagefileSecure);
if(!$template->pagefileSecure) $f->collapsed = Inputfield::collapsedYes;
return $f;
}
/**
* Build the "roles" field for "access" tab in edit form
*
@@ -2455,6 +2487,9 @@ class ProcessTemplate extends Process {
$template->altFilename = basename($form->get('altFilename')->attr('value'), "." . $config->templateExtension);
$template->guestSearchable = (int) $form->get('guestSearchable')->attr('value');
$template->noInherit = (int) $form->get('noInherit')->attr('value') ? 1 : 0;
$pagefileSecurePrev = (int) $template->pagefileSecure;
$template->pagefileSecure = (int) $input->post('pagefileSecure');
$pageLabelField = $form->get('pageLabelField')->attr('value');
if(strpos($pageLabelField, '{') !== false && strpos($pageLabelField, '}')) {
@@ -2666,6 +2701,23 @@ class ProcessTemplate extends Process {
}
unset($cloneTemplateName, $_cloneTemplateName);
}
// change to the pagefileSecure setting
if($pagefileSecurePrev !== $template->pagefileSecure) {
$findSelector = "templates_id=$template->id, include=all";
$qty = $this->wire()->pages->count($findSelector);
if($qty < 1000) {
$qty = $template->checkPagefileSecure();
$this->message(sprintf($this->_('Renamed %d page file path(s) for change to secure files option'), $qty), Notice::noGroup);
} else {
$this->warning(
sprintf($this->_('Your change to the secure files option will be applied as file/image fields on each of the %d affected pages are accessed.'), $qty) . ' ' .
$this->_('Note that this may take some time. To apply to all now, execute the following API code from a template file:') . ' ' .
"`\$templates->get('$template->name')->checkPagefileSecure();`",
Notice::noGroup
);
}
}
if(!$redirectUrl) $redirectUrl = "edit?id={$template->id}";
$session->redirect($redirectUrl);