diff --git a/wire/core/Config.php b/wire/core/Config.php index 6636e367..59de713d 100644 --- a/wire/core/Config.php +++ b/wire/core/Config.php @@ -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 diff --git a/wire/core/Page.php b/wire/core/Page.php index 203da297..1391a601 100644 --- a/wire/core/Page.php +++ b/wire/core/Page.php @@ -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? * diff --git a/wire/core/PagefilesManager.php b/wire/core/PagefilesManager.php index 8f568509..0dfa3b3d 100644 --- a/wire/core/PagefilesManager.php +++ b/wire/core/PagefilesManager.php @@ -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/) * diff --git a/wire/core/Template.php b/wire/core/Template.php index 72b8f5a8..2e087b82 100644 --- a/wire/core/Template.php +++ b/wire/core/Template.php @@ -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 * diff --git a/wire/modules/PagePermissions.module b/wire/modules/PagePermissions.module index ead706f7..e0acfb9a 100644 --- a/wire/modules/PagePermissions.module +++ b/wire/modules/PagePermissions.module @@ -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 isn’t 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; diff --git a/wire/modules/Process/ProcessPageView.module b/wire/modules/Process/ProcessPageView.module index 901c4b4e..ebaca3dd 100644 --- a/wire/modules/Process/ProcessPageView.module +++ b/wire/modules/Process/ProcessPageView.module @@ -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; } - */ } diff --git a/wire/modules/Process/ProcessTemplate/ProcessTemplate.module b/wire/modules/Process/ProcessTemplate/ProcessTemplate.module index 2cb36b23..99937f81 100644 --- a/wire/modules/Process/ProcessTemplate/ProcessTemplate.module +++ b/wire/modules/Process/ProcessTemplate/ProcessTemplate.module @@ -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);