* @link https://getkirby.com * @copyright Bastian Allgeier * @license https://getkirby.com/license */ class Home { /** * Returns an alternative URL if access * to the first choice is blocked. * * It will go through the entire menu and * take the first area which is not disabled * or locked in other ways * * @param \Kirby\Cms\User $user * @return string */ public static function alternative(User $user): string { $permissions = $user->role()->permissions(); // no access to the panel? The only good alternative is the main url if ($permissions->for('access', 'panel') === false) { return App::instance()->site()->url(); } // needed to create a proper menu $areas = Panel::areas(); $menu = View::menu($areas, $permissions->toArray()); // go through the menu and search for the first // available view we can go to foreach ($menu as $menuItem) { // skip separators if ($menuItem === '-') { continue; } // skip disabled items if (($menuItem['disabled'] ?? false) === true) { continue; } // skip the logout button if ($menuItem['id'] === 'logout') { continue; } return Panel::url($menuItem['link']); } throw new NotFoundException('There’s no available Panel page to redirect to'); } /** * Checks if the user has access to the given * panel path. This is quite tricky, because we * need to call a trimmed down router to check * for available routes and their firewall status. * * @param \Kirby\Cms\User * @param string $path * @return bool */ public static function hasAccess(User $user, string $path): bool { $areas = Panel::areas(); $routes = Panel::routes($areas); // Remove fallback routes. Otherwise a route // would be found even if the view does // not exist at all. foreach ($routes as $index => $route) { if ($route['pattern'] === '(:all)') { unset($routes[$index]); } } // create a dummy router to check if we can access this route at all try { return Router::execute($path, 'GET', $routes, function ($route) use ($user) { $auth = $route->attributes()['auth'] ?? true; $areaId = $route->attributes()['area'] ?? null; $type = $route->attributes()['type'] ?? 'view'; // only allow redirects to views if ($type !== 'view') { return false; } // if auth is not required the redirect is allowed if ($auth === false) { return true; } // check the firewall return Panel::hasAccess($user, $areaId); }); } catch (Throwable $e) { return false; } } /** * Checks if the given Uri has the same domain * as the index URL of the Kirby installation. * This is used to block external URLs to third-party * domains as redirect options. * * @param \Kirby\Http\Uri $uri * @return bool */ public static function hasValidDomain(Uri $uri): bool { $rootUrl = App::instance()->site()->url(); return $uri->domain() === (new Uri($rootUrl))->domain(); } /** * Checks if the given URL is a Panel Url. * * @param string $url * @return bool */ public static function isPanelUrl(string $url): bool { return Str::startsWith($url, App::instance()->url('panel')); } /** * Returns the path after /panel/ which can then * be used in the router or to find a matching view * * @param string $url * @return string|null */ public static function panelPath(string $url): ?string { $after = Str::after($url, App::instance()->url('panel')); return trim($after, '/'); } /** * Returns the Url that has been stored in the session * before the last logout. We take this Url if possible * to redirect the user back to the last point where they * left before they got logged out. * * @return string|null */ public static function remembered(): ?string { // check for a stored path after login $remembered = App::instance()->session()->pull('panel.path'); // convert the result to an absolute URL if available return $remembered ? Panel::url($remembered) : null; } /** * Tries to find the best possible Url to redirect * the user to after the login. * * When the user got logged out, we try to send them back * to the point where they left. * * If they have a custom redirect Url defined in their blueprint * via the `home` option, we send them there if no Url is stored * in the session. * * If none of the options above find any result, we try to send * them to the site view. * * Before the redirect happens, the final Url is sanitized, the query * and params are removed to avoid any attacks and the domain is compared * to avoid redirects to external Urls. * * Afterwards, we also check for permissions before the redirect happens * to avoid redirects to inaccessible Panel views. In such a case * the next best accessible view is picked from the menu. * * @return string */ public static function url(): string { $user = App::instance()->user(); // if there's no authenticated user, all internal // redirects will be blocked and the user is redirected // to the login instead if (!$user) { return Panel::url('login'); } // get the last visited url from the session or the custom home $url = static::remembered() ?? $user->panel()->home(); // inspect the given URL $uri = new Uri($url); // compare domains to avoid external redirects if (static::hasValidDomain($uri) !== true) { throw new InvalidArgumentException('External URLs are not allowed for Panel redirects'); } // remove all params to avoid // possible attack vectors $uri->params = ''; $uri->query = ''; // get a clean version of the URL $url = $uri->toString(); // Don't further inspect URLs outside of the Panel if (static::isPanelUrl($url) === false) { return $url; } // get the plain panel path $path = static::panelPath($url); // a redirect to login, logout or installation // views would lead to an infinite redirect loop if (in_array($path, ['', 'login', 'logout', 'installation'], true) === true) { $path = 'site'; } // Check if the user can access the URL if (static::hasAccess($user, $path) === true) { return Panel::url($path); } // Try to find an alternative return static::alternative($user); } }