@@ -247,7 +247,7 @@ |
||
| 247 | 247 | public function afterException($controller, $methodName, \Exception $exception): Response { |
| 248 | 248 | if ($exception instanceof SecurityException) { |
| 249 | 249 | if ($exception instanceof StrictCookieMissingException) { |
| 250 | - return new RedirectResponse(\OC::$WEBROOT . '/'); |
|
| 250 | + return new RedirectResponse(\OC::$WEBROOT.'/'); |
|
| 251 | 251 | } |
| 252 | 252 | if (stripos($this->request->getHeader('Accept'), 'html') === false) { |
| 253 | 253 | $response = new JSONResponse( |
@@ -55,145 +55,145 @@ discard block |
||
| 55 | 55 | * check fails |
| 56 | 56 | */ |
| 57 | 57 | class SecurityMiddleware extends Middleware { |
| 58 | - private ?bool $isAdminUser = null; |
|
| 59 | - private ?bool $isSubAdmin = null; |
|
| 58 | + private ?bool $isAdminUser = null; |
|
| 59 | + private ?bool $isSubAdmin = null; |
|
| 60 | 60 | |
| 61 | - public function __construct( |
|
| 62 | - private readonly IRequest $request, |
|
| 63 | - private readonly MiddlewareUtils $middlewareUtils, |
|
| 64 | - private readonly INavigationManager $navigationManager, |
|
| 65 | - private readonly IURLGenerator $urlGenerator, |
|
| 66 | - private readonly LoggerInterface $logger, |
|
| 67 | - private readonly string $appName, |
|
| 68 | - private readonly bool $isLoggedIn, |
|
| 69 | - private readonly IGroupManager $groupManager, |
|
| 70 | - private readonly ISubAdmin $subAdminManager, |
|
| 71 | - private readonly IAppManager $appManager, |
|
| 72 | - private readonly IL10N $l10n, |
|
| 73 | - private readonly AuthorizedGroupMapper $groupAuthorizationMapper, |
|
| 74 | - private readonly IUserSession $userSession, |
|
| 75 | - private readonly IRemoteAddress $remoteAddress, |
|
| 76 | - ) { |
|
| 77 | - } |
|
| 61 | + public function __construct( |
|
| 62 | + private readonly IRequest $request, |
|
| 63 | + private readonly MiddlewareUtils $middlewareUtils, |
|
| 64 | + private readonly INavigationManager $navigationManager, |
|
| 65 | + private readonly IURLGenerator $urlGenerator, |
|
| 66 | + private readonly LoggerInterface $logger, |
|
| 67 | + private readonly string $appName, |
|
| 68 | + private readonly bool $isLoggedIn, |
|
| 69 | + private readonly IGroupManager $groupManager, |
|
| 70 | + private readonly ISubAdmin $subAdminManager, |
|
| 71 | + private readonly IAppManager $appManager, |
|
| 72 | + private readonly IL10N $l10n, |
|
| 73 | + private readonly AuthorizedGroupMapper $groupAuthorizationMapper, |
|
| 74 | + private readonly IUserSession $userSession, |
|
| 75 | + private readonly IRemoteAddress $remoteAddress, |
|
| 76 | + ) { |
|
| 77 | + } |
|
| 78 | 78 | |
| 79 | - private function isAdminUser(): bool { |
|
| 80 | - if ($this->isAdminUser === null) { |
|
| 81 | - $user = $this->userSession->getUser(); |
|
| 82 | - $this->isAdminUser = $user && $this->groupManager->isAdmin($user->getUID()); |
|
| 83 | - } |
|
| 84 | - return $this->isAdminUser; |
|
| 85 | - } |
|
| 79 | + private function isAdminUser(): bool { |
|
| 80 | + if ($this->isAdminUser === null) { |
|
| 81 | + $user = $this->userSession->getUser(); |
|
| 82 | + $this->isAdminUser = $user && $this->groupManager->isAdmin($user->getUID()); |
|
| 83 | + } |
|
| 84 | + return $this->isAdminUser; |
|
| 85 | + } |
|
| 86 | 86 | |
| 87 | - private function isSubAdmin(): bool { |
|
| 88 | - if ($this->isSubAdmin === null) { |
|
| 89 | - $user = $this->userSession->getUser(); |
|
| 90 | - $this->isSubAdmin = $user && $this->subAdminManager->isSubAdmin($user); |
|
| 91 | - } |
|
| 92 | - return $this->isSubAdmin; |
|
| 93 | - } |
|
| 87 | + private function isSubAdmin(): bool { |
|
| 88 | + if ($this->isSubAdmin === null) { |
|
| 89 | + $user = $this->userSession->getUser(); |
|
| 90 | + $this->isSubAdmin = $user && $this->subAdminManager->isSubAdmin($user); |
|
| 91 | + } |
|
| 92 | + return $this->isSubAdmin; |
|
| 93 | + } |
|
| 94 | 94 | |
| 95 | - /** |
|
| 96 | - * This runs all the security checks before a method call. The |
|
| 97 | - * security checks are determined by inspecting the controller method |
|
| 98 | - * annotations |
|
| 99 | - * |
|
| 100 | - * @param Controller $controller the controller |
|
| 101 | - * @param string $methodName the name of the method |
|
| 102 | - * @throws SecurityException when a security check fails |
|
| 103 | - * |
|
| 104 | - * @suppress PhanUndeclaredClassConstant |
|
| 105 | - */ |
|
| 106 | - public function beforeController($controller, $methodName) { |
|
| 107 | - // this will set the current navigation entry of the app, use this only |
|
| 108 | - // for normal HTML requests and not for AJAX requests |
|
| 109 | - $this->navigationManager->setActiveEntry($this->appName); |
|
| 95 | + /** |
|
| 96 | + * This runs all the security checks before a method call. The |
|
| 97 | + * security checks are determined by inspecting the controller method |
|
| 98 | + * annotations |
|
| 99 | + * |
|
| 100 | + * @param Controller $controller the controller |
|
| 101 | + * @param string $methodName the name of the method |
|
| 102 | + * @throws SecurityException when a security check fails |
|
| 103 | + * |
|
| 104 | + * @suppress PhanUndeclaredClassConstant |
|
| 105 | + */ |
|
| 106 | + public function beforeController($controller, $methodName) { |
|
| 107 | + // this will set the current navigation entry of the app, use this only |
|
| 108 | + // for normal HTML requests and not for AJAX requests |
|
| 109 | + $this->navigationManager->setActiveEntry($this->appName); |
|
| 110 | 110 | |
| 111 | - if (get_class($controller) === \OCA\Talk\Controller\PageController::class && $methodName === 'showCall') { |
|
| 112 | - $this->navigationManager->setActiveEntry('spreed'); |
|
| 113 | - } |
|
| 111 | + if (get_class($controller) === \OCA\Talk\Controller\PageController::class && $methodName === 'showCall') { |
|
| 112 | + $this->navigationManager->setActiveEntry('spreed'); |
|
| 113 | + } |
|
| 114 | 114 | |
| 115 | - $reflectionMethod = new ReflectionMethod($controller, $methodName); |
|
| 115 | + $reflectionMethod = new ReflectionMethod($controller, $methodName); |
|
| 116 | 116 | |
| 117 | - // security checks |
|
| 118 | - $isPublicPage = $this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'PublicPage', PublicPage::class); |
|
| 117 | + // security checks |
|
| 118 | + $isPublicPage = $this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'PublicPage', PublicPage::class); |
|
| 119 | 119 | |
| 120 | - if ($this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'ExAppRequired', ExAppRequired::class)) { |
|
| 121 | - if (!$this->userSession instanceof Session || $this->userSession->getSession()->get('app_api') !== true) { |
|
| 122 | - throw new ExAppRequiredException(); |
|
| 123 | - } |
|
| 124 | - } elseif (!$isPublicPage) { |
|
| 125 | - $authorized = false; |
|
| 126 | - if ($this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, null, AppApiAdminAccessWithoutUser::class)) { |
|
| 127 | - // this attribute allows ExApp to access admin endpoints only if "userId" is "null" |
|
| 128 | - if ($this->userSession instanceof Session && $this->userSession->getSession()->get('app_api') === true && $this->userSession->getUser() === null) { |
|
| 129 | - $authorized = true; |
|
| 130 | - } |
|
| 131 | - } |
|
| 120 | + if ($this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'ExAppRequired', ExAppRequired::class)) { |
|
| 121 | + if (!$this->userSession instanceof Session || $this->userSession->getSession()->get('app_api') !== true) { |
|
| 122 | + throw new ExAppRequiredException(); |
|
| 123 | + } |
|
| 124 | + } elseif (!$isPublicPage) { |
|
| 125 | + $authorized = false; |
|
| 126 | + if ($this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, null, AppApiAdminAccessWithoutUser::class)) { |
|
| 127 | + // this attribute allows ExApp to access admin endpoints only if "userId" is "null" |
|
| 128 | + if ($this->userSession instanceof Session && $this->userSession->getSession()->get('app_api') === true && $this->userSession->getUser() === null) { |
|
| 129 | + $authorized = true; |
|
| 130 | + } |
|
| 131 | + } |
|
| 132 | 132 | |
| 133 | - if (!$authorized && !$this->isLoggedIn) { |
|
| 134 | - throw new NotLoggedInException(); |
|
| 135 | - } |
|
| 133 | + if (!$authorized && !$this->isLoggedIn) { |
|
| 134 | + throw new NotLoggedInException(); |
|
| 135 | + } |
|
| 136 | 136 | |
| 137 | - if (!$authorized && $this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'AuthorizedAdminSetting', AuthorizedAdminSetting::class)) { |
|
| 138 | - $authorized = $this->isAdminUser(); |
|
| 137 | + if (!$authorized && $this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'AuthorizedAdminSetting', AuthorizedAdminSetting::class)) { |
|
| 138 | + $authorized = $this->isAdminUser(); |
|
| 139 | 139 | |
| 140 | - if (!$authorized && $this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class)) { |
|
| 141 | - $authorized = $this->isSubAdmin(); |
|
| 142 | - } |
|
| 140 | + if (!$authorized && $this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class)) { |
|
| 141 | + $authorized = $this->isSubAdmin(); |
|
| 142 | + } |
|
| 143 | 143 | |
| 144 | - if (!$authorized) { |
|
| 145 | - $settingClasses = $this->middlewareUtils->getAuthorizedAdminSettingClasses($reflectionMethod); |
|
| 146 | - $authorizedClasses = $this->groupAuthorizationMapper->findAllClassesForUser($this->userSession->getUser()); |
|
| 147 | - foreach ($settingClasses as $settingClass) { |
|
| 148 | - $authorized = in_array($settingClass, $authorizedClasses, true); |
|
| 144 | + if (!$authorized) { |
|
| 145 | + $settingClasses = $this->middlewareUtils->getAuthorizedAdminSettingClasses($reflectionMethod); |
|
| 146 | + $authorizedClasses = $this->groupAuthorizationMapper->findAllClassesForUser($this->userSession->getUser()); |
|
| 147 | + foreach ($settingClasses as $settingClass) { |
|
| 148 | + $authorized = in_array($settingClass, $authorizedClasses, true); |
|
| 149 | 149 | |
| 150 | - if ($authorized) { |
|
| 151 | - break; |
|
| 152 | - } |
|
| 153 | - } |
|
| 154 | - } |
|
| 155 | - if (!$authorized) { |
|
| 156 | - throw new NotAdminException($this->l10n->t('Logged in account must be an admin, a sub admin or gotten special right to access this setting')); |
|
| 157 | - } |
|
| 158 | - if (!$this->remoteAddress->allowsAdminActions()) { |
|
| 159 | - throw new AdminIpNotAllowedException($this->l10n->t('Your current IP address doesn\'t allow you to perform admin actions')); |
|
| 160 | - } |
|
| 161 | - } |
|
| 162 | - if ($this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class) |
|
| 163 | - && !$this->isSubAdmin() |
|
| 164 | - && !$this->isAdminUser() |
|
| 165 | - && !$authorized) { |
|
| 166 | - throw new NotAdminException($this->l10n->t('Logged in account must be an admin or sub admin')); |
|
| 167 | - } |
|
| 168 | - if (!$this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class) |
|
| 169 | - && !$this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'NoAdminRequired', NoAdminRequired::class) |
|
| 170 | - && !$this->isAdminUser() |
|
| 171 | - && !$authorized) { |
|
| 172 | - throw new NotAdminException($this->l10n->t('Logged in account must be an admin')); |
|
| 173 | - } |
|
| 174 | - if ($this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class) |
|
| 175 | - && !$this->remoteAddress->allowsAdminActions()) { |
|
| 176 | - throw new AdminIpNotAllowedException($this->l10n->t('Your current IP address doesn\'t allow you to perform admin actions')); |
|
| 177 | - } |
|
| 178 | - if (!$this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class) |
|
| 179 | - && !$this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'NoAdminRequired', NoAdminRequired::class) |
|
| 180 | - && !$this->remoteAddress->allowsAdminActions()) { |
|
| 181 | - throw new AdminIpNotAllowedException($this->l10n->t('Your current IP address doesn\'t allow you to perform admin actions')); |
|
| 182 | - } |
|
| 150 | + if ($authorized) { |
|
| 151 | + break; |
|
| 152 | + } |
|
| 153 | + } |
|
| 154 | + } |
|
| 155 | + if (!$authorized) { |
|
| 156 | + throw new NotAdminException($this->l10n->t('Logged in account must be an admin, a sub admin or gotten special right to access this setting')); |
|
| 157 | + } |
|
| 158 | + if (!$this->remoteAddress->allowsAdminActions()) { |
|
| 159 | + throw new AdminIpNotAllowedException($this->l10n->t('Your current IP address doesn\'t allow you to perform admin actions')); |
|
| 160 | + } |
|
| 161 | + } |
|
| 162 | + if ($this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class) |
|
| 163 | + && !$this->isSubAdmin() |
|
| 164 | + && !$this->isAdminUser() |
|
| 165 | + && !$authorized) { |
|
| 166 | + throw new NotAdminException($this->l10n->t('Logged in account must be an admin or sub admin')); |
|
| 167 | + } |
|
| 168 | + if (!$this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class) |
|
| 169 | + && !$this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'NoAdminRequired', NoAdminRequired::class) |
|
| 170 | + && !$this->isAdminUser() |
|
| 171 | + && !$authorized) { |
|
| 172 | + throw new NotAdminException($this->l10n->t('Logged in account must be an admin')); |
|
| 173 | + } |
|
| 174 | + if ($this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class) |
|
| 175 | + && !$this->remoteAddress->allowsAdminActions()) { |
|
| 176 | + throw new AdminIpNotAllowedException($this->l10n->t('Your current IP address doesn\'t allow you to perform admin actions')); |
|
| 177 | + } |
|
| 178 | + if (!$this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'SubAdminRequired', SubAdminRequired::class) |
|
| 179 | + && !$this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'NoAdminRequired', NoAdminRequired::class) |
|
| 180 | + && !$this->remoteAddress->allowsAdminActions()) { |
|
| 181 | + throw new AdminIpNotAllowedException($this->l10n->t('Your current IP address doesn\'t allow you to perform admin actions')); |
|
| 182 | + } |
|
| 183 | 183 | |
| 184 | - } |
|
| 184 | + } |
|
| 185 | 185 | |
| 186 | - // Check for strict cookie requirement |
|
| 187 | - if ($this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'StrictCookieRequired', StrictCookiesRequired::class) |
|
| 188 | - || !$this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'NoCSRFRequired', NoCSRFRequired::class)) { |
|
| 189 | - if (!$this->request->passesStrictCookieCheck()) { |
|
| 190 | - throw new StrictCookieMissingException(); |
|
| 191 | - } |
|
| 192 | - } |
|
| 193 | - // CSRF check - also registers the CSRF token since the session may be closed later |
|
| 194 | - Util::callRegister(); |
|
| 195 | - if ($this->isInvalidCSRFRequired($reflectionMethod)) { |
|
| 196 | - /* |
|
| 186 | + // Check for strict cookie requirement |
|
| 187 | + if ($this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'StrictCookieRequired', StrictCookiesRequired::class) |
|
| 188 | + || !$this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'NoCSRFRequired', NoCSRFRequired::class)) { |
|
| 189 | + if (!$this->request->passesStrictCookieCheck()) { |
|
| 190 | + throw new StrictCookieMissingException(); |
|
| 191 | + } |
|
| 192 | + } |
|
| 193 | + // CSRF check - also registers the CSRF token since the session may be closed later |
|
| 194 | + Util::callRegister(); |
|
| 195 | + if ($this->isInvalidCSRFRequired($reflectionMethod)) { |
|
| 196 | + /* |
|
| 197 | 197 | * Only allow the CSRF check to fail on OCS Requests. This kind of |
| 198 | 198 | * hacks around that we have no full token auth in place yet and we |
| 199 | 199 | * do want to offer CSRF checks for web requests. |
@@ -201,89 +201,89 @@ discard block |
||
| 201 | 201 | * Additionally we allow Bearer authenticated requests to pass on OCS routes. |
| 202 | 202 | * This allows oauth apps (e.g. moodle) to use the OCS endpoints |
| 203 | 203 | */ |
| 204 | - if (!$controller instanceof OCSController || !$this->isValidOCSRequest()) { |
|
| 205 | - throw new CrossSiteRequestForgeryException(); |
|
| 206 | - } |
|
| 207 | - } |
|
| 204 | + if (!$controller instanceof OCSController || !$this->isValidOCSRequest()) { |
|
| 205 | + throw new CrossSiteRequestForgeryException(); |
|
| 206 | + } |
|
| 207 | + } |
|
| 208 | 208 | |
| 209 | - /** |
|
| 210 | - * Checks if app is enabled (also includes a check whether user is allowed to access the resource) |
|
| 211 | - * The getAppPath() check is here since components such as settings also use the AppFramework and |
|
| 212 | - * therefore won't pass this check. |
|
| 213 | - * If page is public, app does not need to be enabled for current user/visitor |
|
| 214 | - */ |
|
| 215 | - try { |
|
| 216 | - $appPath = $this->appManager->getAppPath($this->appName); |
|
| 217 | - } catch (AppPathNotFoundException $e) { |
|
| 218 | - $appPath = false; |
|
| 219 | - } |
|
| 209 | + /** |
|
| 210 | + * Checks if app is enabled (also includes a check whether user is allowed to access the resource) |
|
| 211 | + * The getAppPath() check is here since components such as settings also use the AppFramework and |
|
| 212 | + * therefore won't pass this check. |
|
| 213 | + * If page is public, app does not need to be enabled for current user/visitor |
|
| 214 | + */ |
|
| 215 | + try { |
|
| 216 | + $appPath = $this->appManager->getAppPath($this->appName); |
|
| 217 | + } catch (AppPathNotFoundException $e) { |
|
| 218 | + $appPath = false; |
|
| 219 | + } |
|
| 220 | 220 | |
| 221 | - if ($appPath !== false && !$isPublicPage && !$this->appManager->isEnabledForUser($this->appName)) { |
|
| 222 | - throw new AppNotEnabledException(); |
|
| 223 | - } |
|
| 224 | - } |
|
| 221 | + if ($appPath !== false && !$isPublicPage && !$this->appManager->isEnabledForUser($this->appName)) { |
|
| 222 | + throw new AppNotEnabledException(); |
|
| 223 | + } |
|
| 224 | + } |
|
| 225 | 225 | |
| 226 | - private function isInvalidCSRFRequired(ReflectionMethod $reflectionMethod): bool { |
|
| 227 | - if ($this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'NoCSRFRequired', NoCSRFRequired::class)) { |
|
| 228 | - return false; |
|
| 229 | - } |
|
| 226 | + private function isInvalidCSRFRequired(ReflectionMethod $reflectionMethod): bool { |
|
| 227 | + if ($this->middlewareUtils->hasAnnotationOrAttribute($reflectionMethod, 'NoCSRFRequired', NoCSRFRequired::class)) { |
|
| 228 | + return false; |
|
| 229 | + } |
|
| 230 | 230 | |
| 231 | - return !$this->request->passesCSRFCheck(); |
|
| 232 | - } |
|
| 231 | + return !$this->request->passesCSRFCheck(); |
|
| 232 | + } |
|
| 233 | 233 | |
| 234 | - private function isValidOCSRequest(): bool { |
|
| 235 | - return $this->request->getHeader('OCS-APIREQUEST') === 'true' |
|
| 236 | - || str_starts_with($this->request->getHeader('Authorization'), 'Bearer '); |
|
| 237 | - } |
|
| 234 | + private function isValidOCSRequest(): bool { |
|
| 235 | + return $this->request->getHeader('OCS-APIREQUEST') === 'true' |
|
| 236 | + || str_starts_with($this->request->getHeader('Authorization'), 'Bearer '); |
|
| 237 | + } |
|
| 238 | 238 | |
| 239 | - /** |
|
| 240 | - * If an SecurityException is being caught, ajax requests return a JSON error |
|
| 241 | - * response and non ajax requests redirect to the index |
|
| 242 | - * |
|
| 243 | - * @param Controller $controller the controller that is being called |
|
| 244 | - * @param string $methodName the name of the method that will be called on |
|
| 245 | - * the controller |
|
| 246 | - * @param \Exception $exception the thrown exception |
|
| 247 | - * @return Response a Response object or null in case that the exception could not be handled |
|
| 248 | - * @throws \Exception the passed in exception if it can't handle it |
|
| 249 | - */ |
|
| 250 | - public function afterException($controller, $methodName, \Exception $exception): Response { |
|
| 251 | - if ($exception instanceof SecurityException) { |
|
| 252 | - if ($exception instanceof StrictCookieMissingException) { |
|
| 253 | - return new RedirectResponse(\OC::$WEBROOT . '/'); |
|
| 254 | - } |
|
| 255 | - if (stripos($this->request->getHeader('Accept'), 'html') === false) { |
|
| 256 | - $response = new JSONResponse( |
|
| 257 | - ['message' => $exception->getMessage()], |
|
| 258 | - $exception->getCode() |
|
| 259 | - ); |
|
| 260 | - } else { |
|
| 261 | - if ($exception instanceof NotLoggedInException) { |
|
| 262 | - $params = []; |
|
| 263 | - if (isset($this->request->server['REQUEST_URI'])) { |
|
| 264 | - $params['redirect_url'] = $this->request->server['REQUEST_URI']; |
|
| 265 | - } |
|
| 266 | - $usernamePrefill = $this->request->getParam('user', ''); |
|
| 267 | - if ($usernamePrefill !== '') { |
|
| 268 | - $params['user'] = $usernamePrefill; |
|
| 269 | - } |
|
| 270 | - if ($this->request->getParam('direct')) { |
|
| 271 | - $params['direct'] = 1; |
|
| 272 | - } |
|
| 273 | - $url = $this->urlGenerator->linkToRoute('core.login.showLoginForm', $params); |
|
| 274 | - $response = new RedirectResponse($url); |
|
| 275 | - } else { |
|
| 276 | - $response = new TemplateResponse('core', '403', ['message' => $exception->getMessage()], 'guest'); |
|
| 277 | - $response->setStatus($exception->getCode()); |
|
| 278 | - } |
|
| 279 | - } |
|
| 239 | + /** |
|
| 240 | + * If an SecurityException is being caught, ajax requests return a JSON error |
|
| 241 | + * response and non ajax requests redirect to the index |
|
| 242 | + * |
|
| 243 | + * @param Controller $controller the controller that is being called |
|
| 244 | + * @param string $methodName the name of the method that will be called on |
|
| 245 | + * the controller |
|
| 246 | + * @param \Exception $exception the thrown exception |
|
| 247 | + * @return Response a Response object or null in case that the exception could not be handled |
|
| 248 | + * @throws \Exception the passed in exception if it can't handle it |
|
| 249 | + */ |
|
| 250 | + public function afterException($controller, $methodName, \Exception $exception): Response { |
|
| 251 | + if ($exception instanceof SecurityException) { |
|
| 252 | + if ($exception instanceof StrictCookieMissingException) { |
|
| 253 | + return new RedirectResponse(\OC::$WEBROOT . '/'); |
|
| 254 | + } |
|
| 255 | + if (stripos($this->request->getHeader('Accept'), 'html') === false) { |
|
| 256 | + $response = new JSONResponse( |
|
| 257 | + ['message' => $exception->getMessage()], |
|
| 258 | + $exception->getCode() |
|
| 259 | + ); |
|
| 260 | + } else { |
|
| 261 | + if ($exception instanceof NotLoggedInException) { |
|
| 262 | + $params = []; |
|
| 263 | + if (isset($this->request->server['REQUEST_URI'])) { |
|
| 264 | + $params['redirect_url'] = $this->request->server['REQUEST_URI']; |
|
| 265 | + } |
|
| 266 | + $usernamePrefill = $this->request->getParam('user', ''); |
|
| 267 | + if ($usernamePrefill !== '') { |
|
| 268 | + $params['user'] = $usernamePrefill; |
|
| 269 | + } |
|
| 270 | + if ($this->request->getParam('direct')) { |
|
| 271 | + $params['direct'] = 1; |
|
| 272 | + } |
|
| 273 | + $url = $this->urlGenerator->linkToRoute('core.login.showLoginForm', $params); |
|
| 274 | + $response = new RedirectResponse($url); |
|
| 275 | + } else { |
|
| 276 | + $response = new TemplateResponse('core', '403', ['message' => $exception->getMessage()], 'guest'); |
|
| 277 | + $response->setStatus($exception->getCode()); |
|
| 278 | + } |
|
| 279 | + } |
|
| 280 | 280 | |
| 281 | - $this->logger->debug($exception->getMessage(), [ |
|
| 282 | - 'exception' => $exception, |
|
| 283 | - ]); |
|
| 284 | - return $response; |
|
| 285 | - } |
|
| 281 | + $this->logger->debug($exception->getMessage(), [ |
|
| 282 | + 'exception' => $exception, |
|
| 283 | + ]); |
|
| 284 | + return $response; |
|
| 285 | + } |
|
| 286 | 286 | |
| 287 | - throw $exception; |
|
| 288 | - } |
|
| 287 | + throw $exception; |
|
| 288 | + } |
|
| 289 | 289 | } |
@@ -38,271 +38,271 @@ |
||
| 38 | 38 | use Test\TestCase; |
| 39 | 39 | |
| 40 | 40 | class HasTwoFactorAnnotationController extends Controller { |
| 41 | - #[NoTwoFactorRequired] |
|
| 42 | - public function index(): Response { |
|
| 43 | - return new Response(); |
|
| 44 | - } |
|
| 41 | + #[NoTwoFactorRequired] |
|
| 42 | + public function index(): Response { |
|
| 43 | + return new Response(); |
|
| 44 | + } |
|
| 45 | 45 | } |
| 46 | 46 | |
| 47 | 47 | class LoginSetupController extends ALoginSetupController { |
| 48 | - public function index(): Response { |
|
| 49 | - return new Response(); |
|
| 50 | - } |
|
| 48 | + public function index(): Response { |
|
| 49 | + return new Response(); |
|
| 50 | + } |
|
| 51 | 51 | } |
| 52 | 52 | |
| 53 | 53 | class NoTwoFactorAnnotationController extends Controller { |
| 54 | - public function index(): Response { |
|
| 55 | - return new Response(); |
|
| 56 | - } |
|
| 54 | + public function index(): Response { |
|
| 55 | + return new Response(); |
|
| 56 | + } |
|
| 57 | 57 | } |
| 58 | 58 | |
| 59 | 59 | class NoTwoFactorChallengeAnnotationController extends TwoFactorChallengeController { |
| 60 | - public function index(): Response { |
|
| 61 | - return new Response(); |
|
| 62 | - } |
|
| 60 | + public function index(): Response { |
|
| 61 | + return new Response(); |
|
| 62 | + } |
|
| 63 | 63 | } |
| 64 | 64 | |
| 65 | 65 | class HasTwoFactorSetUpDoneAnnotationController extends TwoFactorChallengeController { |
| 66 | - #[TwoFactorSetUpDoneRequired] |
|
| 67 | - public function index(): Response { |
|
| 68 | - return new Response(); |
|
| 69 | - } |
|
| 66 | + #[TwoFactorSetUpDoneRequired] |
|
| 67 | + public function index(): Response { |
|
| 68 | + return new Response(); |
|
| 69 | + } |
|
| 70 | 70 | } |
| 71 | 71 | |
| 72 | 72 | class TwoFactorMiddlewareTest extends TestCase { |
| 73 | - private Manager&MockObject $twoFactorManager; |
|
| 74 | - private IUserSession&MockObject $userSession; |
|
| 75 | - private ISession&MockObject $session; |
|
| 76 | - private IURLGenerator&MockObject $urlGenerator; |
|
| 77 | - private ControllerMethodReflector&MockObject $reflector; |
|
| 78 | - private IRequest $request; |
|
| 79 | - private TwoFactorMiddleware $middleware; |
|
| 80 | - private LoggerInterface&MockObject $logger; |
|
| 81 | - |
|
| 82 | - protected function setUp(): void { |
|
| 83 | - parent::setUp(); |
|
| 84 | - |
|
| 85 | - $this->twoFactorManager = $this->getMockBuilder(Manager::class) |
|
| 86 | - ->disableOriginalConstructor() |
|
| 87 | - ->getMock(); |
|
| 88 | - $this->userSession = $this->getMockBuilder(Session::class) |
|
| 89 | - ->disableOriginalConstructor() |
|
| 90 | - ->getMock(); |
|
| 91 | - $this->session = $this->createMock(ISession::class); |
|
| 92 | - $this->urlGenerator = $this->createMock(IURLGenerator::class); |
|
| 93 | - $this->reflector = $this->createMock(ControllerMethodReflector::class); |
|
| 94 | - $this->logger = $this->createMock(LoggerInterface::class); |
|
| 95 | - $this->request = new Request( |
|
| 96 | - [ |
|
| 97 | - 'server' => [ |
|
| 98 | - 'REQUEST_URI' => 'test/url' |
|
| 99 | - ] |
|
| 100 | - ], |
|
| 101 | - $this->createMock(IRequestId::class), |
|
| 102 | - $this->createMock(IConfig::class) |
|
| 103 | - ); |
|
| 104 | - |
|
| 105 | - $this->middleware = new TwoFactorMiddleware($this->twoFactorManager, $this->userSession, $this->session, $this->urlGenerator, new MiddlewareUtils($this->reflector, $this->logger), $this->request); |
|
| 106 | - } |
|
| 107 | - |
|
| 108 | - public function testBeforeControllerNotLoggedIn(): void { |
|
| 109 | - $this->userSession->expects($this->once()) |
|
| 110 | - ->method('isLoggedIn') |
|
| 111 | - ->willReturn(false); |
|
| 112 | - |
|
| 113 | - $this->userSession->expects($this->never()) |
|
| 114 | - ->method('getUser'); |
|
| 115 | - |
|
| 116 | - $controller = $this->getMockBuilder(NoTwoFactorAnnotationController::class) |
|
| 117 | - ->disableOriginalConstructor() |
|
| 118 | - ->getMock(); |
|
| 119 | - $this->middleware->beforeController($controller, 'index'); |
|
| 120 | - } |
|
| 121 | - |
|
| 122 | - public function testBeforeSetupController(): void { |
|
| 123 | - $user = $this->createMock(IUser::class); |
|
| 124 | - $this->userSession->expects($this->any()) |
|
| 125 | - ->method('getUser') |
|
| 126 | - ->willReturn($user); |
|
| 127 | - $this->twoFactorManager->expects($this->once()) |
|
| 128 | - ->method('needsSecondFactor') |
|
| 129 | - ->willReturn(true); |
|
| 130 | - $this->userSession->expects($this->never()) |
|
| 131 | - ->method('isLoggedIn'); |
|
| 132 | - |
|
| 133 | - $this->middleware->beforeController(new LoginSetupController('foo', $this->request), 'index'); |
|
| 134 | - } |
|
| 135 | - |
|
| 136 | - public function testBeforeControllerNoTwoFactorCheckNeeded(): void { |
|
| 137 | - $user = $this->createMock(IUser::class); |
|
| 138 | - |
|
| 139 | - $this->userSession->expects($this->once()) |
|
| 140 | - ->method('isLoggedIn') |
|
| 141 | - ->willReturn(true); |
|
| 142 | - $this->userSession->expects($this->once()) |
|
| 143 | - ->method('getUser') |
|
| 144 | - ->willReturn($user); |
|
| 145 | - $this->twoFactorManager->expects($this->once()) |
|
| 146 | - ->method('isTwoFactorAuthenticated') |
|
| 147 | - ->with($user) |
|
| 148 | - ->willReturn(false); |
|
| 149 | - |
|
| 150 | - $controller = $this->getMockBuilder(NoTwoFactorAnnotationController::class) |
|
| 151 | - ->disableOriginalConstructor() |
|
| 152 | - ->getMock(); |
|
| 153 | - $this->middleware->beforeController($controller, 'index'); |
|
| 154 | - } |
|
| 155 | - |
|
| 156 | - |
|
| 157 | - public function testBeforeControllerTwoFactorAuthRequired(): void { |
|
| 158 | - $this->expectException(TwoFactorAuthRequiredException::class); |
|
| 159 | - |
|
| 160 | - $user = $this->createMock(IUser::class); |
|
| 161 | - |
|
| 162 | - $this->userSession->expects($this->once()) |
|
| 163 | - ->method('isLoggedIn') |
|
| 164 | - ->willReturn(true); |
|
| 165 | - $this->userSession->expects($this->once()) |
|
| 166 | - ->method('getUser') |
|
| 167 | - ->willReturn($user); |
|
| 168 | - $this->twoFactorManager->expects($this->once()) |
|
| 169 | - ->method('isTwoFactorAuthenticated') |
|
| 170 | - ->with($user) |
|
| 171 | - ->willReturn(true); |
|
| 172 | - $this->twoFactorManager->expects($this->once()) |
|
| 173 | - ->method('needsSecondFactor') |
|
| 174 | - ->with($user) |
|
| 175 | - ->willReturn(true); |
|
| 176 | - |
|
| 177 | - $controller = $this->getMockBuilder(NoTwoFactorAnnotationController::class) |
|
| 178 | - ->disableOriginalConstructor() |
|
| 179 | - ->getMock(); |
|
| 180 | - $this->middleware->beforeController($controller, 'index'); |
|
| 181 | - } |
|
| 182 | - |
|
| 183 | - |
|
| 184 | - public function testBeforeControllerUserAlreadyLoggedIn(): void { |
|
| 185 | - $this->expectException(UserAlreadyLoggedInException::class); |
|
| 186 | - |
|
| 187 | - $user = $this->createMock(IUser::class); |
|
| 188 | - |
|
| 189 | - $this->userSession->expects($this->once()) |
|
| 190 | - ->method('isLoggedIn') |
|
| 191 | - ->willReturn(true); |
|
| 192 | - $this->userSession |
|
| 193 | - ->method('getUser') |
|
| 194 | - ->willReturn($user); |
|
| 195 | - $this->twoFactorManager->expects($this->once()) |
|
| 196 | - ->method('isTwoFactorAuthenticated') |
|
| 197 | - ->with($user) |
|
| 198 | - ->willReturn(true); |
|
| 199 | - $this->twoFactorManager->expects($this->once()) |
|
| 200 | - ->method('needsSecondFactor') |
|
| 201 | - ->with($user) |
|
| 202 | - ->willReturn(false); |
|
| 203 | - |
|
| 204 | - $twoFactorChallengeController = $this->getMockBuilder(NoTwoFactorChallengeAnnotationController::class) |
|
| 205 | - ->disableOriginalConstructor() |
|
| 206 | - ->getMock(); |
|
| 207 | - $this->middleware->beforeController($twoFactorChallengeController, 'index'); |
|
| 208 | - } |
|
| 209 | - |
|
| 210 | - public function testAfterExceptionTwoFactorAuthRequired(): void { |
|
| 211 | - $ex = new TwoFactorAuthRequiredException(); |
|
| 212 | - |
|
| 213 | - $this->urlGenerator->expects($this->once()) |
|
| 214 | - ->method('linkToRoute') |
|
| 215 | - ->with('core.TwoFactorChallenge.selectChallenge') |
|
| 216 | - ->willReturn('test/url'); |
|
| 217 | - $expected = new RedirectResponse('test/url'); |
|
| 218 | - |
|
| 219 | - $controller = new HasTwoFactorAnnotationController('foo', $this->request); |
|
| 220 | - $this->assertEquals($expected, $this->middleware->afterException($controller, 'index', $ex)); |
|
| 221 | - } |
|
| 222 | - |
|
| 223 | - public function testAfterException(): void { |
|
| 224 | - $ex = new UserAlreadyLoggedInException(); |
|
| 225 | - |
|
| 226 | - $this->urlGenerator->expects($this->once()) |
|
| 227 | - ->method('linkToRoute') |
|
| 228 | - ->with('files.view.index') |
|
| 229 | - ->willReturn('redirect/url'); |
|
| 230 | - $expected = new RedirectResponse('redirect/url'); |
|
| 231 | - |
|
| 232 | - $controller = new HasTwoFactorAnnotationController('foo', $this->request); |
|
| 233 | - $this->assertEquals($expected, $this->middleware->afterException($controller, 'index', $ex)); |
|
| 234 | - } |
|
| 235 | - |
|
| 236 | - public function testRequires2FASetupDoneAnnotated(): void { |
|
| 237 | - $user = $this->createMock(IUser::class); |
|
| 238 | - |
|
| 239 | - $this->userSession->expects($this->once()) |
|
| 240 | - ->method('isLoggedIn') |
|
| 241 | - ->willReturn(true); |
|
| 242 | - $this->userSession |
|
| 243 | - ->method('getUser') |
|
| 244 | - ->willReturn($user); |
|
| 245 | - $this->twoFactorManager->expects($this->once()) |
|
| 246 | - ->method('isTwoFactorAuthenticated') |
|
| 247 | - ->with($user) |
|
| 248 | - ->willReturn(true); |
|
| 249 | - $this->twoFactorManager->expects($this->once()) |
|
| 250 | - ->method('needsSecondFactor') |
|
| 251 | - ->with($user) |
|
| 252 | - ->willReturn(false); |
|
| 253 | - |
|
| 254 | - $this->expectException(UserAlreadyLoggedInException::class); |
|
| 255 | - |
|
| 256 | - $controller = $this->getMockBuilder(HasTwoFactorSetUpDoneAnnotationController::class) |
|
| 257 | - ->disableOriginalConstructor() |
|
| 258 | - ->getMock(); |
|
| 259 | - $this->middleware->beforeController($controller, 'index'); |
|
| 260 | - } |
|
| 261 | - |
|
| 262 | - public static function dataRequires2FASetupDone(): array { |
|
| 263 | - return [ |
|
| 264 | - [false, false, false], |
|
| 265 | - [false, true, true], |
|
| 266 | - [true, false, true], |
|
| 267 | - [true, true, true], |
|
| 268 | - ]; |
|
| 269 | - } |
|
| 270 | - |
|
| 271 | - #[DataProvider('dataRequires2FASetupDone')] |
|
| 272 | - public function testRequires2FASetupDone(bool $hasProvider, bool $missingProviders, bool $expectEception): void { |
|
| 273 | - if ($hasProvider) { |
|
| 274 | - $provider = $this->createMock(IProvider::class); |
|
| 275 | - $provider->method('getId') |
|
| 276 | - ->willReturn('2FAftw'); |
|
| 277 | - $providers = [$provider]; |
|
| 278 | - } else { |
|
| 279 | - $providers = []; |
|
| 280 | - } |
|
| 281 | - |
|
| 282 | - |
|
| 283 | - $user = $this->createMock(IUser::class); |
|
| 284 | - |
|
| 285 | - $this->userSession |
|
| 286 | - ->method('getUser') |
|
| 287 | - ->willReturn($user); |
|
| 288 | - $providerSet = new ProviderSet($providers, $missingProviders); |
|
| 289 | - $this->twoFactorManager->method('getProviderSet') |
|
| 290 | - ->with($user) |
|
| 291 | - ->willReturn($providerSet); |
|
| 292 | - $this->userSession |
|
| 293 | - ->method('isLoggedIn') |
|
| 294 | - ->willReturn(false); |
|
| 295 | - |
|
| 296 | - if ($expectEception) { |
|
| 297 | - $this->expectException(TwoFactorAuthRequiredException::class); |
|
| 298 | - } else { |
|
| 299 | - // hack to make phpunit shut up. Since we don't expect an exception here... |
|
| 300 | - $this->assertTrue(true); |
|
| 301 | - } |
|
| 302 | - |
|
| 303 | - $controller = $this->getMockBuilder(NoTwoFactorChallengeAnnotationController::class) |
|
| 304 | - ->disableOriginalConstructor() |
|
| 305 | - ->getMock(); |
|
| 306 | - $this->middleware->beforeController($controller, 'index'); |
|
| 307 | - } |
|
| 73 | + private Manager&MockObject $twoFactorManager; |
|
| 74 | + private IUserSession&MockObject $userSession; |
|
| 75 | + private ISession&MockObject $session; |
|
| 76 | + private IURLGenerator&MockObject $urlGenerator; |
|
| 77 | + private ControllerMethodReflector&MockObject $reflector; |
|
| 78 | + private IRequest $request; |
|
| 79 | + private TwoFactorMiddleware $middleware; |
|
| 80 | + private LoggerInterface&MockObject $logger; |
|
| 81 | + |
|
| 82 | + protected function setUp(): void { |
|
| 83 | + parent::setUp(); |
|
| 84 | + |
|
| 85 | + $this->twoFactorManager = $this->getMockBuilder(Manager::class) |
|
| 86 | + ->disableOriginalConstructor() |
|
| 87 | + ->getMock(); |
|
| 88 | + $this->userSession = $this->getMockBuilder(Session::class) |
|
| 89 | + ->disableOriginalConstructor() |
|
| 90 | + ->getMock(); |
|
| 91 | + $this->session = $this->createMock(ISession::class); |
|
| 92 | + $this->urlGenerator = $this->createMock(IURLGenerator::class); |
|
| 93 | + $this->reflector = $this->createMock(ControllerMethodReflector::class); |
|
| 94 | + $this->logger = $this->createMock(LoggerInterface::class); |
|
| 95 | + $this->request = new Request( |
|
| 96 | + [ |
|
| 97 | + 'server' => [ |
|
| 98 | + 'REQUEST_URI' => 'test/url' |
|
| 99 | + ] |
|
| 100 | + ], |
|
| 101 | + $this->createMock(IRequestId::class), |
|
| 102 | + $this->createMock(IConfig::class) |
|
| 103 | + ); |
|
| 104 | + |
|
| 105 | + $this->middleware = new TwoFactorMiddleware($this->twoFactorManager, $this->userSession, $this->session, $this->urlGenerator, new MiddlewareUtils($this->reflector, $this->logger), $this->request); |
|
| 106 | + } |
|
| 107 | + |
|
| 108 | + public function testBeforeControllerNotLoggedIn(): void { |
|
| 109 | + $this->userSession->expects($this->once()) |
|
| 110 | + ->method('isLoggedIn') |
|
| 111 | + ->willReturn(false); |
|
| 112 | + |
|
| 113 | + $this->userSession->expects($this->never()) |
|
| 114 | + ->method('getUser'); |
|
| 115 | + |
|
| 116 | + $controller = $this->getMockBuilder(NoTwoFactorAnnotationController::class) |
|
| 117 | + ->disableOriginalConstructor() |
|
| 118 | + ->getMock(); |
|
| 119 | + $this->middleware->beforeController($controller, 'index'); |
|
| 120 | + } |
|
| 121 | + |
|
| 122 | + public function testBeforeSetupController(): void { |
|
| 123 | + $user = $this->createMock(IUser::class); |
|
| 124 | + $this->userSession->expects($this->any()) |
|
| 125 | + ->method('getUser') |
|
| 126 | + ->willReturn($user); |
|
| 127 | + $this->twoFactorManager->expects($this->once()) |
|
| 128 | + ->method('needsSecondFactor') |
|
| 129 | + ->willReturn(true); |
|
| 130 | + $this->userSession->expects($this->never()) |
|
| 131 | + ->method('isLoggedIn'); |
|
| 132 | + |
|
| 133 | + $this->middleware->beforeController(new LoginSetupController('foo', $this->request), 'index'); |
|
| 134 | + } |
|
| 135 | + |
|
| 136 | + public function testBeforeControllerNoTwoFactorCheckNeeded(): void { |
|
| 137 | + $user = $this->createMock(IUser::class); |
|
| 138 | + |
|
| 139 | + $this->userSession->expects($this->once()) |
|
| 140 | + ->method('isLoggedIn') |
|
| 141 | + ->willReturn(true); |
|
| 142 | + $this->userSession->expects($this->once()) |
|
| 143 | + ->method('getUser') |
|
| 144 | + ->willReturn($user); |
|
| 145 | + $this->twoFactorManager->expects($this->once()) |
|
| 146 | + ->method('isTwoFactorAuthenticated') |
|
| 147 | + ->with($user) |
|
| 148 | + ->willReturn(false); |
|
| 149 | + |
|
| 150 | + $controller = $this->getMockBuilder(NoTwoFactorAnnotationController::class) |
|
| 151 | + ->disableOriginalConstructor() |
|
| 152 | + ->getMock(); |
|
| 153 | + $this->middleware->beforeController($controller, 'index'); |
|
| 154 | + } |
|
| 155 | + |
|
| 156 | + |
|
| 157 | + public function testBeforeControllerTwoFactorAuthRequired(): void { |
|
| 158 | + $this->expectException(TwoFactorAuthRequiredException::class); |
|
| 159 | + |
|
| 160 | + $user = $this->createMock(IUser::class); |
|
| 161 | + |
|
| 162 | + $this->userSession->expects($this->once()) |
|
| 163 | + ->method('isLoggedIn') |
|
| 164 | + ->willReturn(true); |
|
| 165 | + $this->userSession->expects($this->once()) |
|
| 166 | + ->method('getUser') |
|
| 167 | + ->willReturn($user); |
|
| 168 | + $this->twoFactorManager->expects($this->once()) |
|
| 169 | + ->method('isTwoFactorAuthenticated') |
|
| 170 | + ->with($user) |
|
| 171 | + ->willReturn(true); |
|
| 172 | + $this->twoFactorManager->expects($this->once()) |
|
| 173 | + ->method('needsSecondFactor') |
|
| 174 | + ->with($user) |
|
| 175 | + ->willReturn(true); |
|
| 176 | + |
|
| 177 | + $controller = $this->getMockBuilder(NoTwoFactorAnnotationController::class) |
|
| 178 | + ->disableOriginalConstructor() |
|
| 179 | + ->getMock(); |
|
| 180 | + $this->middleware->beforeController($controller, 'index'); |
|
| 181 | + } |
|
| 182 | + |
|
| 183 | + |
|
| 184 | + public function testBeforeControllerUserAlreadyLoggedIn(): void { |
|
| 185 | + $this->expectException(UserAlreadyLoggedInException::class); |
|
| 186 | + |
|
| 187 | + $user = $this->createMock(IUser::class); |
|
| 188 | + |
|
| 189 | + $this->userSession->expects($this->once()) |
|
| 190 | + ->method('isLoggedIn') |
|
| 191 | + ->willReturn(true); |
|
| 192 | + $this->userSession |
|
| 193 | + ->method('getUser') |
|
| 194 | + ->willReturn($user); |
|
| 195 | + $this->twoFactorManager->expects($this->once()) |
|
| 196 | + ->method('isTwoFactorAuthenticated') |
|
| 197 | + ->with($user) |
|
| 198 | + ->willReturn(true); |
|
| 199 | + $this->twoFactorManager->expects($this->once()) |
|
| 200 | + ->method('needsSecondFactor') |
|
| 201 | + ->with($user) |
|
| 202 | + ->willReturn(false); |
|
| 203 | + |
|
| 204 | + $twoFactorChallengeController = $this->getMockBuilder(NoTwoFactorChallengeAnnotationController::class) |
|
| 205 | + ->disableOriginalConstructor() |
|
| 206 | + ->getMock(); |
|
| 207 | + $this->middleware->beforeController($twoFactorChallengeController, 'index'); |
|
| 208 | + } |
|
| 209 | + |
|
| 210 | + public function testAfterExceptionTwoFactorAuthRequired(): void { |
|
| 211 | + $ex = new TwoFactorAuthRequiredException(); |
|
| 212 | + |
|
| 213 | + $this->urlGenerator->expects($this->once()) |
|
| 214 | + ->method('linkToRoute') |
|
| 215 | + ->with('core.TwoFactorChallenge.selectChallenge') |
|
| 216 | + ->willReturn('test/url'); |
|
| 217 | + $expected = new RedirectResponse('test/url'); |
|
| 218 | + |
|
| 219 | + $controller = new HasTwoFactorAnnotationController('foo', $this->request); |
|
| 220 | + $this->assertEquals($expected, $this->middleware->afterException($controller, 'index', $ex)); |
|
| 221 | + } |
|
| 222 | + |
|
| 223 | + public function testAfterException(): void { |
|
| 224 | + $ex = new UserAlreadyLoggedInException(); |
|
| 225 | + |
|
| 226 | + $this->urlGenerator->expects($this->once()) |
|
| 227 | + ->method('linkToRoute') |
|
| 228 | + ->with('files.view.index') |
|
| 229 | + ->willReturn('redirect/url'); |
|
| 230 | + $expected = new RedirectResponse('redirect/url'); |
|
| 231 | + |
|
| 232 | + $controller = new HasTwoFactorAnnotationController('foo', $this->request); |
|
| 233 | + $this->assertEquals($expected, $this->middleware->afterException($controller, 'index', $ex)); |
|
| 234 | + } |
|
| 235 | + |
|
| 236 | + public function testRequires2FASetupDoneAnnotated(): void { |
|
| 237 | + $user = $this->createMock(IUser::class); |
|
| 238 | + |
|
| 239 | + $this->userSession->expects($this->once()) |
|
| 240 | + ->method('isLoggedIn') |
|
| 241 | + ->willReturn(true); |
|
| 242 | + $this->userSession |
|
| 243 | + ->method('getUser') |
|
| 244 | + ->willReturn($user); |
|
| 245 | + $this->twoFactorManager->expects($this->once()) |
|
| 246 | + ->method('isTwoFactorAuthenticated') |
|
| 247 | + ->with($user) |
|
| 248 | + ->willReturn(true); |
|
| 249 | + $this->twoFactorManager->expects($this->once()) |
|
| 250 | + ->method('needsSecondFactor') |
|
| 251 | + ->with($user) |
|
| 252 | + ->willReturn(false); |
|
| 253 | + |
|
| 254 | + $this->expectException(UserAlreadyLoggedInException::class); |
|
| 255 | + |
|
| 256 | + $controller = $this->getMockBuilder(HasTwoFactorSetUpDoneAnnotationController::class) |
|
| 257 | + ->disableOriginalConstructor() |
|
| 258 | + ->getMock(); |
|
| 259 | + $this->middleware->beforeController($controller, 'index'); |
|
| 260 | + } |
|
| 261 | + |
|
| 262 | + public static function dataRequires2FASetupDone(): array { |
|
| 263 | + return [ |
|
| 264 | + [false, false, false], |
|
| 265 | + [false, true, true], |
|
| 266 | + [true, false, true], |
|
| 267 | + [true, true, true], |
|
| 268 | + ]; |
|
| 269 | + } |
|
| 270 | + |
|
| 271 | + #[DataProvider('dataRequires2FASetupDone')] |
|
| 272 | + public function testRequires2FASetupDone(bool $hasProvider, bool $missingProviders, bool $expectEception): void { |
|
| 273 | + if ($hasProvider) { |
|
| 274 | + $provider = $this->createMock(IProvider::class); |
|
| 275 | + $provider->method('getId') |
|
| 276 | + ->willReturn('2FAftw'); |
|
| 277 | + $providers = [$provider]; |
|
| 278 | + } else { |
|
| 279 | + $providers = []; |
|
| 280 | + } |
|
| 281 | + |
|
| 282 | + |
|
| 283 | + $user = $this->createMock(IUser::class); |
|
| 284 | + |
|
| 285 | + $this->userSession |
|
| 286 | + ->method('getUser') |
|
| 287 | + ->willReturn($user); |
|
| 288 | + $providerSet = new ProviderSet($providers, $missingProviders); |
|
| 289 | + $this->twoFactorManager->method('getProviderSet') |
|
| 290 | + ->with($user) |
|
| 291 | + ->willReturn($providerSet); |
|
| 292 | + $this->userSession |
|
| 293 | + ->method('isLoggedIn') |
|
| 294 | + ->willReturn(false); |
|
| 295 | + |
|
| 296 | + if ($expectEception) { |
|
| 297 | + $this->expectException(TwoFactorAuthRequiredException::class); |
|
| 298 | + } else { |
|
| 299 | + // hack to make phpunit shut up. Since we don't expect an exception here... |
|
| 300 | + $this->assertTrue(true); |
|
| 301 | + } |
|
| 302 | + |
|
| 303 | + $controller = $this->getMockBuilder(NoTwoFactorChallengeAnnotationController::class) |
|
| 304 | + ->disableOriginalConstructor() |
|
| 305 | + ->getMock(); |
|
| 306 | + $this->middleware->beforeController($controller, 'index'); |
|
| 307 | + } |
|
| 308 | 308 | } |
@@ -262,9 +262,9 @@ |
||
| 262 | 262 | public static function dataRequires2FASetupDone(): array { |
| 263 | 263 | return [ |
| 264 | 264 | [false, false, false], |
| 265 | - [false, true, true], |
|
| 265 | + [false, true, true], |
|
| 266 | 266 | [true, false, true], |
| 267 | - [true, true, true], |
|
| 267 | + [true, true, true], |
|
| 268 | 268 | ]; |
| 269 | 269 | } |
| 270 | 270 | |
@@ -25,317 +25,317 @@ |
||
| 25 | 25 | use Test\AppFramework\Middleware\Security\Mock\CORSMiddlewareController; |
| 26 | 26 | |
| 27 | 27 | class CORSMiddlewareTest extends \Test\TestCase { |
| 28 | - private ControllerMethodReflector $reflector; |
|
| 29 | - private Session&MockObject $session; |
|
| 30 | - private IThrottler&MockObject $throttler; |
|
| 31 | - private CORSMiddlewareController $controller; |
|
| 32 | - private LoggerInterface $logger; |
|
| 33 | - |
|
| 34 | - protected function setUp(): void { |
|
| 35 | - parent::setUp(); |
|
| 36 | - $this->reflector = new ControllerMethodReflector(); |
|
| 37 | - $this->session = $this->createMock(Session::class); |
|
| 38 | - $this->throttler = $this->createMock(IThrottler::class); |
|
| 39 | - $this->logger = $this->createMock(LoggerInterface::class); |
|
| 40 | - $this->controller = new CORSMiddlewareController( |
|
| 41 | - 'test', |
|
| 42 | - $this->createMock(IRequest::class) |
|
| 43 | - ); |
|
| 44 | - } |
|
| 45 | - |
|
| 46 | - public static function dataSetCORSAPIHeader(): array { |
|
| 47 | - return [ |
|
| 48 | - ['testSetCORSAPIHeader'], |
|
| 49 | - ['testSetCORSAPIHeaderAttribute'], |
|
| 50 | - ]; |
|
| 51 | - } |
|
| 52 | - |
|
| 53 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataSetCORSAPIHeader')] |
|
| 54 | - public function testSetCORSAPIHeader(string $method): void { |
|
| 55 | - $request = new Request( |
|
| 56 | - [ |
|
| 57 | - 'server' => [ |
|
| 58 | - 'HTTP_ORIGIN' => 'test' |
|
| 59 | - ] |
|
| 60 | - ], |
|
| 61 | - $this->createMock(IRequestId::class), |
|
| 62 | - $this->createMock(IConfig::class) |
|
| 63 | - ); |
|
| 64 | - $this->reflector->reflect($this->controller, $method); |
|
| 65 | - $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler); |
|
| 66 | - |
|
| 67 | - $response = $middleware->afterController($this->controller, $method, new Response()); |
|
| 68 | - $headers = $response->getHeaders(); |
|
| 69 | - $this->assertEquals('test', $headers['Access-Control-Allow-Origin']); |
|
| 70 | - } |
|
| 71 | - |
|
| 72 | - public function testNoAnnotationNoCORSHEADER(): void { |
|
| 73 | - $request = new Request( |
|
| 74 | - [ |
|
| 75 | - 'server' => [ |
|
| 76 | - 'HTTP_ORIGIN' => 'test' |
|
| 77 | - ] |
|
| 78 | - ], |
|
| 79 | - $this->createMock(IRequestId::class), |
|
| 80 | - $this->createMock(IConfig::class) |
|
| 81 | - ); |
|
| 82 | - $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler); |
|
| 83 | - |
|
| 84 | - $response = $middleware->afterController($this->controller, __FUNCTION__, new Response()); |
|
| 85 | - $headers = $response->getHeaders(); |
|
| 86 | - $this->assertFalse(array_key_exists('Access-Control-Allow-Origin', $headers)); |
|
| 87 | - } |
|
| 88 | - |
|
| 89 | - public static function dataNoOriginHeaderNoCORSHEADER(): array { |
|
| 90 | - return [ |
|
| 91 | - ['testNoOriginHeaderNoCORSHEADER'], |
|
| 92 | - ['testNoOriginHeaderNoCORSHEADERAttribute'], |
|
| 93 | - ]; |
|
| 94 | - } |
|
| 95 | - |
|
| 96 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataNoOriginHeaderNoCORSHEADER')] |
|
| 97 | - public function testNoOriginHeaderNoCORSHEADER(string $method): void { |
|
| 98 | - $request = new Request( |
|
| 99 | - [], |
|
| 100 | - $this->createMock(IRequestId::class), |
|
| 101 | - $this->createMock(IConfig::class) |
|
| 102 | - ); |
|
| 103 | - $this->reflector->reflect($this->controller, $method); |
|
| 104 | - $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler); |
|
| 105 | - |
|
| 106 | - $response = $middleware->afterController($this->controller, $method, new Response()); |
|
| 107 | - $headers = $response->getHeaders(); |
|
| 108 | - $this->assertFalse(array_key_exists('Access-Control-Allow-Origin', $headers)); |
|
| 109 | - } |
|
| 110 | - |
|
| 111 | - public static function dataCorsIgnoredIfWithCredentialsHeaderPresent(): array { |
|
| 112 | - return [ |
|
| 113 | - ['testCorsIgnoredIfWithCredentialsHeaderPresent'], |
|
| 114 | - ['testCorsAttributeIgnoredIfWithCredentialsHeaderPresent'], |
|
| 115 | - ]; |
|
| 116 | - } |
|
| 117 | - |
|
| 118 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataCorsIgnoredIfWithCredentialsHeaderPresent')] |
|
| 119 | - public function testCorsIgnoredIfWithCredentialsHeaderPresent(string $method): void { |
|
| 120 | - $this->expectException(SecurityException::class); |
|
| 121 | - |
|
| 122 | - $request = new Request( |
|
| 123 | - [ |
|
| 124 | - 'server' => [ |
|
| 125 | - 'HTTP_ORIGIN' => 'test' |
|
| 126 | - ] |
|
| 127 | - ], |
|
| 128 | - $this->createMock(IRequestId::class), |
|
| 129 | - $this->createMock(IConfig::class) |
|
| 130 | - ); |
|
| 131 | - $this->reflector->reflect($this->controller, $method); |
|
| 132 | - $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler, $this->logger); |
|
| 133 | - |
|
| 134 | - $response = new Response(); |
|
| 135 | - $response->addHeader('AcCess-control-Allow-Credentials ', 'TRUE'); |
|
| 136 | - $middleware->afterController($this->controller, $method, $response); |
|
| 137 | - } |
|
| 138 | - |
|
| 139 | - public static function dataNoCORSOnAnonymousPublicPage(): array { |
|
| 140 | - return [ |
|
| 141 | - ['testNoCORSOnAnonymousPublicPage'], |
|
| 142 | - ['testNoCORSOnAnonymousPublicPageAttribute'], |
|
| 143 | - ['testNoCORSAttributeOnAnonymousPublicPage'], |
|
| 144 | - ['testNoCORSAttributeOnAnonymousPublicPageAttribute'], |
|
| 145 | - ]; |
|
| 146 | - } |
|
| 147 | - |
|
| 148 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCORSOnAnonymousPublicPage')] |
|
| 149 | - public function testNoCORSOnAnonymousPublicPage(string $method): void { |
|
| 150 | - $request = new Request( |
|
| 151 | - [], |
|
| 152 | - $this->createMock(IRequestId::class), |
|
| 153 | - $this->createMock(IConfig::class) |
|
| 154 | - ); |
|
| 155 | - $this->reflector->reflect($this->controller, $method); |
|
| 156 | - $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler, $this->logger); |
|
| 157 | - $this->session->expects($this->once()) |
|
| 158 | - ->method('isLoggedIn') |
|
| 159 | - ->willReturn(false); |
|
| 160 | - $this->session->expects($this->never()) |
|
| 161 | - ->method('logout'); |
|
| 162 | - $this->session->expects($this->never()) |
|
| 163 | - ->method('logClientIn') |
|
| 164 | - ->with($this->equalTo('user'), $this->equalTo('pass')) |
|
| 165 | - ->willReturn(true); |
|
| 166 | - $this->reflector->reflect($this->controller, $method); |
|
| 167 | - |
|
| 168 | - $middleware->beforeController($this->controller, $method); |
|
| 169 | - } |
|
| 170 | - |
|
| 171 | - public static function dataCORSShouldNeverAllowCookieAuth(): array { |
|
| 172 | - return [ |
|
| 173 | - ['testCORSShouldNeverAllowCookieAuth'], |
|
| 174 | - ['testCORSShouldNeverAllowCookieAuthAttribute'], |
|
| 175 | - ['testCORSAttributeShouldNeverAllowCookieAuth'], |
|
| 176 | - ['testCORSAttributeShouldNeverAllowCookieAuthAttribute'], |
|
| 177 | - ]; |
|
| 178 | - } |
|
| 179 | - |
|
| 180 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataCORSShouldNeverAllowCookieAuth')] |
|
| 181 | - public function testCORSShouldNeverAllowCookieAuth(string $method): void { |
|
| 182 | - $request = new Request( |
|
| 183 | - [], |
|
| 184 | - $this->createMock(IRequestId::class), |
|
| 185 | - $this->createMock(IConfig::class) |
|
| 186 | - ); |
|
| 187 | - $this->reflector->reflect($this->controller, $method); |
|
| 188 | - $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler); |
|
| 189 | - $this->session->expects($this->once()) |
|
| 190 | - ->method('isLoggedIn') |
|
| 191 | - ->willReturn(true); |
|
| 192 | - $this->session->expects($this->once()) |
|
| 193 | - ->method('logout'); |
|
| 194 | - $this->session->expects($this->never()) |
|
| 195 | - ->method('logClientIn') |
|
| 196 | - ->with($this->equalTo('user'), $this->equalTo('pass')) |
|
| 197 | - ->willReturn(true); |
|
| 198 | - |
|
| 199 | - $this->expectException(SecurityException::class); |
|
| 200 | - $middleware->beforeController($this->controller, $method); |
|
| 201 | - } |
|
| 202 | - |
|
| 203 | - public static function dataCORSShouldRelogin(): array { |
|
| 204 | - return [ |
|
| 205 | - ['testCORSShouldRelogin'], |
|
| 206 | - ['testCORSAttributeShouldRelogin'], |
|
| 207 | - ]; |
|
| 208 | - } |
|
| 209 | - |
|
| 210 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataCORSShouldRelogin')] |
|
| 211 | - public function testCORSShouldRelogin(string $method): void { |
|
| 212 | - $request = new Request( |
|
| 213 | - ['server' => [ |
|
| 214 | - 'PHP_AUTH_USER' => 'user', |
|
| 215 | - 'PHP_AUTH_PW' => 'pass' |
|
| 216 | - ]], |
|
| 217 | - $this->createMock(IRequestId::class), |
|
| 218 | - $this->createMock(IConfig::class) |
|
| 219 | - ); |
|
| 220 | - $this->session->expects($this->once()) |
|
| 221 | - ->method('logout'); |
|
| 222 | - $this->session->expects($this->once()) |
|
| 223 | - ->method('logClientIn') |
|
| 224 | - ->with($this->equalTo('user'), $this->equalTo('pass')) |
|
| 225 | - ->willReturn(true); |
|
| 226 | - $this->reflector->reflect($this->controller, $method); |
|
| 227 | - $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler); |
|
| 228 | - |
|
| 229 | - $middleware->beforeController($this->controller, $method); |
|
| 230 | - } |
|
| 231 | - |
|
| 232 | - public static function dataCORSShouldFailIfPasswordLoginIsForbidden(): array { |
|
| 233 | - return [ |
|
| 234 | - ['testCORSShouldFailIfPasswordLoginIsForbidden'], |
|
| 235 | - ['testCORSAttributeShouldFailIfPasswordLoginIsForbidden'], |
|
| 236 | - ]; |
|
| 237 | - } |
|
| 238 | - |
|
| 239 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataCORSShouldFailIfPasswordLoginIsForbidden')] |
|
| 240 | - public function testCORSShouldFailIfPasswordLoginIsForbidden(string $method): void { |
|
| 241 | - $this->expectException(SecurityException::class); |
|
| 242 | - |
|
| 243 | - $request = new Request( |
|
| 244 | - ['server' => [ |
|
| 245 | - 'PHP_AUTH_USER' => 'user', |
|
| 246 | - 'PHP_AUTH_PW' => 'pass' |
|
| 247 | - ]], |
|
| 248 | - $this->createMock(IRequestId::class), |
|
| 249 | - $this->createMock(IConfig::class) |
|
| 250 | - ); |
|
| 251 | - $this->session->expects($this->once()) |
|
| 252 | - ->method('logout'); |
|
| 253 | - $this->session->expects($this->once()) |
|
| 254 | - ->method('logClientIn') |
|
| 255 | - ->with($this->equalTo('user'), $this->equalTo('pass')) |
|
| 256 | - ->willThrowException(new PasswordLoginForbiddenException); |
|
| 257 | - $this->reflector->reflect($this->controller, $method); |
|
| 258 | - $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler); |
|
| 259 | - |
|
| 260 | - $middleware->beforeController($this->controller, $method); |
|
| 261 | - } |
|
| 262 | - |
|
| 263 | - public static function dataCORSShouldNotAllowCookieAuth(): array { |
|
| 264 | - return [ |
|
| 265 | - ['testCORSShouldNotAllowCookieAuth'], |
|
| 266 | - ['testCORSAttributeShouldNotAllowCookieAuth'], |
|
| 267 | - ]; |
|
| 268 | - } |
|
| 269 | - |
|
| 270 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataCORSShouldNotAllowCookieAuth')] |
|
| 271 | - public function testCORSShouldNotAllowCookieAuth(string $method): void { |
|
| 272 | - $this->expectException(SecurityException::class); |
|
| 273 | - |
|
| 274 | - $request = new Request( |
|
| 275 | - ['server' => [ |
|
| 276 | - 'PHP_AUTH_USER' => 'user', |
|
| 277 | - 'PHP_AUTH_PW' => 'pass' |
|
| 278 | - ]], |
|
| 279 | - $this->createMock(IRequestId::class), |
|
| 280 | - $this->createMock(IConfig::class) |
|
| 281 | - ); |
|
| 282 | - $this->session->expects($this->once()) |
|
| 283 | - ->method('logout'); |
|
| 284 | - $this->session->expects($this->once()) |
|
| 285 | - ->method('logClientIn') |
|
| 286 | - ->with($this->equalTo('user'), $this->equalTo('pass')) |
|
| 287 | - ->willReturn(false); |
|
| 288 | - $this->reflector->reflect($this->controller, $method); |
|
| 289 | - $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler); |
|
| 290 | - |
|
| 291 | - $middleware->beforeController($this->controller, $method); |
|
| 292 | - } |
|
| 293 | - |
|
| 294 | - public function testAfterExceptionWithSecurityExceptionNoStatus(): void { |
|
| 295 | - $request = new Request( |
|
| 296 | - ['server' => [ |
|
| 297 | - 'PHP_AUTH_USER' => 'user', |
|
| 298 | - 'PHP_AUTH_PW' => 'pass' |
|
| 299 | - ]], |
|
| 300 | - $this->createMock(IRequestId::class), |
|
| 301 | - $this->createMock(IConfig::class) |
|
| 302 | - ); |
|
| 303 | - $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler); |
|
| 304 | - $response = $middleware->afterException($this->controller, __FUNCTION__, new SecurityException('A security exception')); |
|
| 305 | - |
|
| 306 | - $expected = new JSONResponse(['message' => 'A security exception'], 500); |
|
| 307 | - $this->assertEquals($expected, $response); |
|
| 308 | - } |
|
| 309 | - |
|
| 310 | - public function testAfterExceptionWithSecurityExceptionWithStatus(): void { |
|
| 311 | - $request = new Request( |
|
| 312 | - ['server' => [ |
|
| 313 | - 'PHP_AUTH_USER' => 'user', |
|
| 314 | - 'PHP_AUTH_PW' => 'pass' |
|
| 315 | - ]], |
|
| 316 | - $this->createMock(IRequestId::class), |
|
| 317 | - $this->createMock(IConfig::class) |
|
| 318 | - ); |
|
| 319 | - $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler); |
|
| 320 | - $response = $middleware->afterException($this->controller, __FUNCTION__, new SecurityException('A security exception', 501)); |
|
| 321 | - |
|
| 322 | - $expected = new JSONResponse(['message' => 'A security exception'], 501); |
|
| 323 | - $this->assertEquals($expected, $response); |
|
| 324 | - } |
|
| 325 | - |
|
| 326 | - public function testAfterExceptionWithRegularException(): void { |
|
| 327 | - $this->expectException(\Exception::class); |
|
| 328 | - $this->expectExceptionMessage('A regular exception'); |
|
| 329 | - |
|
| 330 | - $request = new Request( |
|
| 331 | - ['server' => [ |
|
| 332 | - 'PHP_AUTH_USER' => 'user', |
|
| 333 | - 'PHP_AUTH_PW' => 'pass' |
|
| 334 | - ]], |
|
| 335 | - $this->createMock(IRequestId::class), |
|
| 336 | - $this->createMock(IConfig::class) |
|
| 337 | - ); |
|
| 338 | - $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler); |
|
| 339 | - $middleware->afterException($this->controller, __FUNCTION__, new \Exception('A regular exception')); |
|
| 340 | - } |
|
| 28 | + private ControllerMethodReflector $reflector; |
|
| 29 | + private Session&MockObject $session; |
|
| 30 | + private IThrottler&MockObject $throttler; |
|
| 31 | + private CORSMiddlewareController $controller; |
|
| 32 | + private LoggerInterface $logger; |
|
| 33 | + |
|
| 34 | + protected function setUp(): void { |
|
| 35 | + parent::setUp(); |
|
| 36 | + $this->reflector = new ControllerMethodReflector(); |
|
| 37 | + $this->session = $this->createMock(Session::class); |
|
| 38 | + $this->throttler = $this->createMock(IThrottler::class); |
|
| 39 | + $this->logger = $this->createMock(LoggerInterface::class); |
|
| 40 | + $this->controller = new CORSMiddlewareController( |
|
| 41 | + 'test', |
|
| 42 | + $this->createMock(IRequest::class) |
|
| 43 | + ); |
|
| 44 | + } |
|
| 45 | + |
|
| 46 | + public static function dataSetCORSAPIHeader(): array { |
|
| 47 | + return [ |
|
| 48 | + ['testSetCORSAPIHeader'], |
|
| 49 | + ['testSetCORSAPIHeaderAttribute'], |
|
| 50 | + ]; |
|
| 51 | + } |
|
| 52 | + |
|
| 53 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataSetCORSAPIHeader')] |
|
| 54 | + public function testSetCORSAPIHeader(string $method): void { |
|
| 55 | + $request = new Request( |
|
| 56 | + [ |
|
| 57 | + 'server' => [ |
|
| 58 | + 'HTTP_ORIGIN' => 'test' |
|
| 59 | + ] |
|
| 60 | + ], |
|
| 61 | + $this->createMock(IRequestId::class), |
|
| 62 | + $this->createMock(IConfig::class) |
|
| 63 | + ); |
|
| 64 | + $this->reflector->reflect($this->controller, $method); |
|
| 65 | + $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler); |
|
| 66 | + |
|
| 67 | + $response = $middleware->afterController($this->controller, $method, new Response()); |
|
| 68 | + $headers = $response->getHeaders(); |
|
| 69 | + $this->assertEquals('test', $headers['Access-Control-Allow-Origin']); |
|
| 70 | + } |
|
| 71 | + |
|
| 72 | + public function testNoAnnotationNoCORSHEADER(): void { |
|
| 73 | + $request = new Request( |
|
| 74 | + [ |
|
| 75 | + 'server' => [ |
|
| 76 | + 'HTTP_ORIGIN' => 'test' |
|
| 77 | + ] |
|
| 78 | + ], |
|
| 79 | + $this->createMock(IRequestId::class), |
|
| 80 | + $this->createMock(IConfig::class) |
|
| 81 | + ); |
|
| 82 | + $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler); |
|
| 83 | + |
|
| 84 | + $response = $middleware->afterController($this->controller, __FUNCTION__, new Response()); |
|
| 85 | + $headers = $response->getHeaders(); |
|
| 86 | + $this->assertFalse(array_key_exists('Access-Control-Allow-Origin', $headers)); |
|
| 87 | + } |
|
| 88 | + |
|
| 89 | + public static function dataNoOriginHeaderNoCORSHEADER(): array { |
|
| 90 | + return [ |
|
| 91 | + ['testNoOriginHeaderNoCORSHEADER'], |
|
| 92 | + ['testNoOriginHeaderNoCORSHEADERAttribute'], |
|
| 93 | + ]; |
|
| 94 | + } |
|
| 95 | + |
|
| 96 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoOriginHeaderNoCORSHEADER')] |
|
| 97 | + public function testNoOriginHeaderNoCORSHEADER(string $method): void { |
|
| 98 | + $request = new Request( |
|
| 99 | + [], |
|
| 100 | + $this->createMock(IRequestId::class), |
|
| 101 | + $this->createMock(IConfig::class) |
|
| 102 | + ); |
|
| 103 | + $this->reflector->reflect($this->controller, $method); |
|
| 104 | + $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler); |
|
| 105 | + |
|
| 106 | + $response = $middleware->afterController($this->controller, $method, new Response()); |
|
| 107 | + $headers = $response->getHeaders(); |
|
| 108 | + $this->assertFalse(array_key_exists('Access-Control-Allow-Origin', $headers)); |
|
| 109 | + } |
|
| 110 | + |
|
| 111 | + public static function dataCorsIgnoredIfWithCredentialsHeaderPresent(): array { |
|
| 112 | + return [ |
|
| 113 | + ['testCorsIgnoredIfWithCredentialsHeaderPresent'], |
|
| 114 | + ['testCorsAttributeIgnoredIfWithCredentialsHeaderPresent'], |
|
| 115 | + ]; |
|
| 116 | + } |
|
| 117 | + |
|
| 118 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataCorsIgnoredIfWithCredentialsHeaderPresent')] |
|
| 119 | + public function testCorsIgnoredIfWithCredentialsHeaderPresent(string $method): void { |
|
| 120 | + $this->expectException(SecurityException::class); |
|
| 121 | + |
|
| 122 | + $request = new Request( |
|
| 123 | + [ |
|
| 124 | + 'server' => [ |
|
| 125 | + 'HTTP_ORIGIN' => 'test' |
|
| 126 | + ] |
|
| 127 | + ], |
|
| 128 | + $this->createMock(IRequestId::class), |
|
| 129 | + $this->createMock(IConfig::class) |
|
| 130 | + ); |
|
| 131 | + $this->reflector->reflect($this->controller, $method); |
|
| 132 | + $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler, $this->logger); |
|
| 133 | + |
|
| 134 | + $response = new Response(); |
|
| 135 | + $response->addHeader('AcCess-control-Allow-Credentials ', 'TRUE'); |
|
| 136 | + $middleware->afterController($this->controller, $method, $response); |
|
| 137 | + } |
|
| 138 | + |
|
| 139 | + public static function dataNoCORSOnAnonymousPublicPage(): array { |
|
| 140 | + return [ |
|
| 141 | + ['testNoCORSOnAnonymousPublicPage'], |
|
| 142 | + ['testNoCORSOnAnonymousPublicPageAttribute'], |
|
| 143 | + ['testNoCORSAttributeOnAnonymousPublicPage'], |
|
| 144 | + ['testNoCORSAttributeOnAnonymousPublicPageAttribute'], |
|
| 145 | + ]; |
|
| 146 | + } |
|
| 147 | + |
|
| 148 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCORSOnAnonymousPublicPage')] |
|
| 149 | + public function testNoCORSOnAnonymousPublicPage(string $method): void { |
|
| 150 | + $request = new Request( |
|
| 151 | + [], |
|
| 152 | + $this->createMock(IRequestId::class), |
|
| 153 | + $this->createMock(IConfig::class) |
|
| 154 | + ); |
|
| 155 | + $this->reflector->reflect($this->controller, $method); |
|
| 156 | + $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler, $this->logger); |
|
| 157 | + $this->session->expects($this->once()) |
|
| 158 | + ->method('isLoggedIn') |
|
| 159 | + ->willReturn(false); |
|
| 160 | + $this->session->expects($this->never()) |
|
| 161 | + ->method('logout'); |
|
| 162 | + $this->session->expects($this->never()) |
|
| 163 | + ->method('logClientIn') |
|
| 164 | + ->with($this->equalTo('user'), $this->equalTo('pass')) |
|
| 165 | + ->willReturn(true); |
|
| 166 | + $this->reflector->reflect($this->controller, $method); |
|
| 167 | + |
|
| 168 | + $middleware->beforeController($this->controller, $method); |
|
| 169 | + } |
|
| 170 | + |
|
| 171 | + public static function dataCORSShouldNeverAllowCookieAuth(): array { |
|
| 172 | + return [ |
|
| 173 | + ['testCORSShouldNeverAllowCookieAuth'], |
|
| 174 | + ['testCORSShouldNeverAllowCookieAuthAttribute'], |
|
| 175 | + ['testCORSAttributeShouldNeverAllowCookieAuth'], |
|
| 176 | + ['testCORSAttributeShouldNeverAllowCookieAuthAttribute'], |
|
| 177 | + ]; |
|
| 178 | + } |
|
| 179 | + |
|
| 180 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataCORSShouldNeverAllowCookieAuth')] |
|
| 181 | + public function testCORSShouldNeverAllowCookieAuth(string $method): void { |
|
| 182 | + $request = new Request( |
|
| 183 | + [], |
|
| 184 | + $this->createMock(IRequestId::class), |
|
| 185 | + $this->createMock(IConfig::class) |
|
| 186 | + ); |
|
| 187 | + $this->reflector->reflect($this->controller, $method); |
|
| 188 | + $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler); |
|
| 189 | + $this->session->expects($this->once()) |
|
| 190 | + ->method('isLoggedIn') |
|
| 191 | + ->willReturn(true); |
|
| 192 | + $this->session->expects($this->once()) |
|
| 193 | + ->method('logout'); |
|
| 194 | + $this->session->expects($this->never()) |
|
| 195 | + ->method('logClientIn') |
|
| 196 | + ->with($this->equalTo('user'), $this->equalTo('pass')) |
|
| 197 | + ->willReturn(true); |
|
| 198 | + |
|
| 199 | + $this->expectException(SecurityException::class); |
|
| 200 | + $middleware->beforeController($this->controller, $method); |
|
| 201 | + } |
|
| 202 | + |
|
| 203 | + public static function dataCORSShouldRelogin(): array { |
|
| 204 | + return [ |
|
| 205 | + ['testCORSShouldRelogin'], |
|
| 206 | + ['testCORSAttributeShouldRelogin'], |
|
| 207 | + ]; |
|
| 208 | + } |
|
| 209 | + |
|
| 210 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataCORSShouldRelogin')] |
|
| 211 | + public function testCORSShouldRelogin(string $method): void { |
|
| 212 | + $request = new Request( |
|
| 213 | + ['server' => [ |
|
| 214 | + 'PHP_AUTH_USER' => 'user', |
|
| 215 | + 'PHP_AUTH_PW' => 'pass' |
|
| 216 | + ]], |
|
| 217 | + $this->createMock(IRequestId::class), |
|
| 218 | + $this->createMock(IConfig::class) |
|
| 219 | + ); |
|
| 220 | + $this->session->expects($this->once()) |
|
| 221 | + ->method('logout'); |
|
| 222 | + $this->session->expects($this->once()) |
|
| 223 | + ->method('logClientIn') |
|
| 224 | + ->with($this->equalTo('user'), $this->equalTo('pass')) |
|
| 225 | + ->willReturn(true); |
|
| 226 | + $this->reflector->reflect($this->controller, $method); |
|
| 227 | + $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler); |
|
| 228 | + |
|
| 229 | + $middleware->beforeController($this->controller, $method); |
|
| 230 | + } |
|
| 231 | + |
|
| 232 | + public static function dataCORSShouldFailIfPasswordLoginIsForbidden(): array { |
|
| 233 | + return [ |
|
| 234 | + ['testCORSShouldFailIfPasswordLoginIsForbidden'], |
|
| 235 | + ['testCORSAttributeShouldFailIfPasswordLoginIsForbidden'], |
|
| 236 | + ]; |
|
| 237 | + } |
|
| 238 | + |
|
| 239 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataCORSShouldFailIfPasswordLoginIsForbidden')] |
|
| 240 | + public function testCORSShouldFailIfPasswordLoginIsForbidden(string $method): void { |
|
| 241 | + $this->expectException(SecurityException::class); |
|
| 242 | + |
|
| 243 | + $request = new Request( |
|
| 244 | + ['server' => [ |
|
| 245 | + 'PHP_AUTH_USER' => 'user', |
|
| 246 | + 'PHP_AUTH_PW' => 'pass' |
|
| 247 | + ]], |
|
| 248 | + $this->createMock(IRequestId::class), |
|
| 249 | + $this->createMock(IConfig::class) |
|
| 250 | + ); |
|
| 251 | + $this->session->expects($this->once()) |
|
| 252 | + ->method('logout'); |
|
| 253 | + $this->session->expects($this->once()) |
|
| 254 | + ->method('logClientIn') |
|
| 255 | + ->with($this->equalTo('user'), $this->equalTo('pass')) |
|
| 256 | + ->willThrowException(new PasswordLoginForbiddenException); |
|
| 257 | + $this->reflector->reflect($this->controller, $method); |
|
| 258 | + $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler); |
|
| 259 | + |
|
| 260 | + $middleware->beforeController($this->controller, $method); |
|
| 261 | + } |
|
| 262 | + |
|
| 263 | + public static function dataCORSShouldNotAllowCookieAuth(): array { |
|
| 264 | + return [ |
|
| 265 | + ['testCORSShouldNotAllowCookieAuth'], |
|
| 266 | + ['testCORSAttributeShouldNotAllowCookieAuth'], |
|
| 267 | + ]; |
|
| 268 | + } |
|
| 269 | + |
|
| 270 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataCORSShouldNotAllowCookieAuth')] |
|
| 271 | + public function testCORSShouldNotAllowCookieAuth(string $method): void { |
|
| 272 | + $this->expectException(SecurityException::class); |
|
| 273 | + |
|
| 274 | + $request = new Request( |
|
| 275 | + ['server' => [ |
|
| 276 | + 'PHP_AUTH_USER' => 'user', |
|
| 277 | + 'PHP_AUTH_PW' => 'pass' |
|
| 278 | + ]], |
|
| 279 | + $this->createMock(IRequestId::class), |
|
| 280 | + $this->createMock(IConfig::class) |
|
| 281 | + ); |
|
| 282 | + $this->session->expects($this->once()) |
|
| 283 | + ->method('logout'); |
|
| 284 | + $this->session->expects($this->once()) |
|
| 285 | + ->method('logClientIn') |
|
| 286 | + ->with($this->equalTo('user'), $this->equalTo('pass')) |
|
| 287 | + ->willReturn(false); |
|
| 288 | + $this->reflector->reflect($this->controller, $method); |
|
| 289 | + $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler); |
|
| 290 | + |
|
| 291 | + $middleware->beforeController($this->controller, $method); |
|
| 292 | + } |
|
| 293 | + |
|
| 294 | + public function testAfterExceptionWithSecurityExceptionNoStatus(): void { |
|
| 295 | + $request = new Request( |
|
| 296 | + ['server' => [ |
|
| 297 | + 'PHP_AUTH_USER' => 'user', |
|
| 298 | + 'PHP_AUTH_PW' => 'pass' |
|
| 299 | + ]], |
|
| 300 | + $this->createMock(IRequestId::class), |
|
| 301 | + $this->createMock(IConfig::class) |
|
| 302 | + ); |
|
| 303 | + $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler); |
|
| 304 | + $response = $middleware->afterException($this->controller, __FUNCTION__, new SecurityException('A security exception')); |
|
| 305 | + |
|
| 306 | + $expected = new JSONResponse(['message' => 'A security exception'], 500); |
|
| 307 | + $this->assertEquals($expected, $response); |
|
| 308 | + } |
|
| 309 | + |
|
| 310 | + public function testAfterExceptionWithSecurityExceptionWithStatus(): void { |
|
| 311 | + $request = new Request( |
|
| 312 | + ['server' => [ |
|
| 313 | + 'PHP_AUTH_USER' => 'user', |
|
| 314 | + 'PHP_AUTH_PW' => 'pass' |
|
| 315 | + ]], |
|
| 316 | + $this->createMock(IRequestId::class), |
|
| 317 | + $this->createMock(IConfig::class) |
|
| 318 | + ); |
|
| 319 | + $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler); |
|
| 320 | + $response = $middleware->afterException($this->controller, __FUNCTION__, new SecurityException('A security exception', 501)); |
|
| 321 | + |
|
| 322 | + $expected = new JSONResponse(['message' => 'A security exception'], 501); |
|
| 323 | + $this->assertEquals($expected, $response); |
|
| 324 | + } |
|
| 325 | + |
|
| 326 | + public function testAfterExceptionWithRegularException(): void { |
|
| 327 | + $this->expectException(\Exception::class); |
|
| 328 | + $this->expectExceptionMessage('A regular exception'); |
|
| 329 | + |
|
| 330 | + $request = new Request( |
|
| 331 | + ['server' => [ |
|
| 332 | + 'PHP_AUTH_USER' => 'user', |
|
| 333 | + 'PHP_AUTH_PW' => 'pass' |
|
| 334 | + ]], |
|
| 335 | + $this->createMock(IRequestId::class), |
|
| 336 | + $this->createMock(IConfig::class) |
|
| 337 | + ); |
|
| 338 | + $middleware = new CORSMiddleware($request, new MiddlewareUtils($this->reflector, $this->logger), $this->session, $this->throttler); |
|
| 339 | + $middleware->afterException($this->controller, __FUNCTION__, new \Exception('A regular exception')); |
|
| 340 | + } |
|
| 341 | 341 | } |
@@ -45,646 +45,646 @@ |
||
| 45 | 45 | use Test\AppFramework\Middleware\Security\Mock\SecurityMiddlewareController; |
| 46 | 46 | |
| 47 | 47 | class SecurityMiddlewareTest extends \Test\TestCase { |
| 48 | - private SecurityMiddleware $middleware; |
|
| 49 | - private ControllerMethodReflector $reader; |
|
| 50 | - private SecurityMiddlewareController $controller; |
|
| 51 | - private SecurityException $secAjaxException; |
|
| 52 | - private IRequest|MockObject $request; |
|
| 53 | - private MiddlewareUtils $middlewareUtils; |
|
| 54 | - private LoggerInterface&MockObject $logger; |
|
| 55 | - private INavigationManager&MockObject $navigationManager; |
|
| 56 | - private IURLGenerator&MockObject $urlGenerator; |
|
| 57 | - private IAppManager&MockObject $appManager; |
|
| 58 | - private IL10N&MockObject $l10n; |
|
| 59 | - private IUserSession&MockObject $userSession; |
|
| 60 | - private AuthorizedGroupMapper&MockObject $authorizedGroupMapper; |
|
| 61 | - |
|
| 62 | - protected function setUp(): void { |
|
| 63 | - parent::setUp(); |
|
| 64 | - |
|
| 65 | - $this->authorizedGroupMapper = $this->createMock(AuthorizedGroupMapper::class); |
|
| 66 | - $this->userSession = $this->createMock(Session::class); |
|
| 67 | - $user = $this->createMock(IUser::class); |
|
| 68 | - $user->method('getUID')->willReturn('test'); |
|
| 69 | - $this->userSession->method('getUser')->willReturn($user); |
|
| 70 | - $this->request = $this->createMock(IRequest::class); |
|
| 71 | - $this->controller = new SecurityMiddlewareController( |
|
| 72 | - 'test', |
|
| 73 | - $this->request |
|
| 74 | - ); |
|
| 75 | - $this->reader = new ControllerMethodReflector(); |
|
| 76 | - $this->logger = $this->createMock(LoggerInterface::class); |
|
| 77 | - $this->navigationManager = $this->createMock(INavigationManager::class); |
|
| 78 | - $this->urlGenerator = $this->createMock(IURLGenerator::class); |
|
| 79 | - $this->l10n = $this->createMock(IL10N::class); |
|
| 80 | - $this->middlewareUtils = new MiddlewareUtils($this->reader, $this->logger); |
|
| 81 | - $this->middleware = $this->getMiddleware(true, true, false); |
|
| 82 | - $this->secAjaxException = new SecurityException('hey', true); |
|
| 83 | - } |
|
| 84 | - |
|
| 85 | - private function getMiddleware(bool $isLoggedIn, bool $isAdminUser, bool $isSubAdmin, bool $isAppEnabledForUser = true): SecurityMiddleware { |
|
| 86 | - $this->appManager = $this->createMock(IAppManager::class); |
|
| 87 | - $this->appManager->expects($this->any()) |
|
| 88 | - ->method('isEnabledForUser') |
|
| 89 | - ->willReturn($isAppEnabledForUser); |
|
| 90 | - $remoteIpAddress = $this->createMock(IRemoteAddress::class); |
|
| 91 | - $remoteIpAddress->method('allowsAdminActions')->willReturn(true); |
|
| 92 | - |
|
| 93 | - $groupManager = $this->createMock(IGroupManager::class); |
|
| 94 | - $groupManager->method('isAdmin') |
|
| 95 | - ->willReturn($isAdminUser); |
|
| 96 | - $subAdminManager = $this->createMock(ISubAdmin::class); |
|
| 97 | - $subAdminManager->method('isSubAdmin') |
|
| 98 | - ->willReturn($isSubAdmin); |
|
| 99 | - |
|
| 100 | - return new SecurityMiddleware( |
|
| 101 | - $this->request, |
|
| 102 | - $this->middlewareUtils, |
|
| 103 | - $this->navigationManager, |
|
| 104 | - $this->urlGenerator, |
|
| 105 | - $this->logger, |
|
| 106 | - 'files', |
|
| 107 | - $isLoggedIn, |
|
| 108 | - $groupManager, |
|
| 109 | - $subAdminManager, |
|
| 110 | - $this->appManager, |
|
| 111 | - $this->l10n, |
|
| 112 | - $this->authorizedGroupMapper, |
|
| 113 | - $this->userSession, |
|
| 114 | - $remoteIpAddress |
|
| 115 | - ); |
|
| 116 | - } |
|
| 117 | - |
|
| 118 | - public static function dataNoCSRFRequiredPublicPage(): array { |
|
| 119 | - return [ |
|
| 120 | - ['testAnnotationNoCSRFRequiredPublicPage'], |
|
| 121 | - ['testAnnotationNoCSRFRequiredAttributePublicPage'], |
|
| 122 | - ['testAnnotationPublicPageAttributeNoCSRFRequired'], |
|
| 123 | - ['testAttributeNoCSRFRequiredPublicPage'], |
|
| 124 | - ]; |
|
| 125 | - } |
|
| 126 | - |
|
| 127 | - public static function dataPublicPage(): array { |
|
| 128 | - return [ |
|
| 129 | - ['testAnnotationPublicPage'], |
|
| 130 | - ['testAttributePublicPage'], |
|
| 131 | - ]; |
|
| 132 | - } |
|
| 133 | - |
|
| 134 | - public static function dataNoCSRFRequired(): array { |
|
| 135 | - return [ |
|
| 136 | - ['testAnnotationNoCSRFRequired'], |
|
| 137 | - ['testAttributeNoCSRFRequired'], |
|
| 138 | - ]; |
|
| 139 | - } |
|
| 140 | - |
|
| 141 | - public static function dataPublicPageStrictCookieRequired(): array { |
|
| 142 | - return [ |
|
| 143 | - ['testAnnotationPublicPageStrictCookieRequired'], |
|
| 144 | - ['testAnnotationStrictCookieRequiredAttributePublicPage'], |
|
| 145 | - ['testAnnotationPublicPageAttributeStrictCookiesRequired'], |
|
| 146 | - ['testAttributePublicPageStrictCookiesRequired'], |
|
| 147 | - ]; |
|
| 148 | - } |
|
| 149 | - |
|
| 150 | - public static function dataNoCSRFRequiredPublicPageStrictCookieRequired(): array { |
|
| 151 | - return [ |
|
| 152 | - ['testAnnotationNoCSRFRequiredPublicPageStrictCookieRequired'], |
|
| 153 | - ['testAttributeNoCSRFRequiredPublicPageStrictCookiesRequired'], |
|
| 154 | - ]; |
|
| 155 | - } |
|
| 156 | - |
|
| 157 | - public static function dataNoAdminRequiredNoCSRFRequired(): array { |
|
| 158 | - return [ |
|
| 159 | - ['testAnnotationNoAdminRequiredNoCSRFRequired'], |
|
| 160 | - ['testAttributeNoAdminRequiredNoCSRFRequired'], |
|
| 161 | - ]; |
|
| 162 | - } |
|
| 163 | - |
|
| 164 | - public static function dataNoAdminRequiredNoCSRFRequiredPublicPage(): array { |
|
| 165 | - return [ |
|
| 166 | - ['testAnnotationNoAdminRequiredNoCSRFRequiredPublicPage'], |
|
| 167 | - ['testAttributeNoAdminRequiredNoCSRFRequiredPublicPage'], |
|
| 168 | - ]; |
|
| 169 | - } |
|
| 170 | - |
|
| 171 | - public static function dataNoCSRFRequiredSubAdminRequired(): array { |
|
| 172 | - return [ |
|
| 173 | - ['testAnnotationNoCSRFRequiredSubAdminRequired'], |
|
| 174 | - ['testAnnotationNoCSRFRequiredAttributeSubAdminRequired'], |
|
| 175 | - ['testAnnotationSubAdminRequiredAttributeNoCSRFRequired'], |
|
| 176 | - ['testAttributeNoCSRFRequiredSubAdminRequired'], |
|
| 177 | - ]; |
|
| 178 | - } |
|
| 179 | - |
|
| 180 | - public static function dataExAppRequired(): array { |
|
| 181 | - return [ |
|
| 182 | - ['testAnnotationExAppRequired'], |
|
| 183 | - ['testAttributeExAppRequired'], |
|
| 184 | - ]; |
|
| 185 | - } |
|
| 186 | - |
|
| 187 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredPublicPage')] |
|
| 188 | - public function testSetNavigationEntry(string $method): void { |
|
| 189 | - $this->navigationManager->expects($this->once()) |
|
| 190 | - ->method('setActiveEntry') |
|
| 191 | - ->with($this->equalTo('files')); |
|
| 192 | - |
|
| 193 | - $this->reader->reflect($this->controller, $method); |
|
| 194 | - $this->middleware->beforeController($this->controller, $method); |
|
| 195 | - } |
|
| 196 | - |
|
| 197 | - |
|
| 198 | - /** |
|
| 199 | - * @param string $method |
|
| 200 | - * @param string $test |
|
| 201 | - */ |
|
| 202 | - private function ajaxExceptionStatus($method, $test, $status) { |
|
| 203 | - $isLoggedIn = false; |
|
| 204 | - $isAdminUser = false; |
|
| 205 | - |
|
| 206 | - // isAdminUser requires isLoggedIn call to return true |
|
| 207 | - if ($test === 'isAdminUser') { |
|
| 208 | - $isLoggedIn = true; |
|
| 209 | - } |
|
| 210 | - |
|
| 211 | - $sec = $this->getMiddleware($isLoggedIn, $isAdminUser, false); |
|
| 212 | - |
|
| 213 | - try { |
|
| 214 | - $this->reader->reflect($this->controller, $method); |
|
| 215 | - $sec->beforeController($this->controller, $method); |
|
| 216 | - } catch (SecurityException $ex) { |
|
| 217 | - $this->assertEquals($status, $ex->getCode()); |
|
| 218 | - } |
|
| 219 | - |
|
| 220 | - // add assertion if everything should work fine otherwise phpunit will |
|
| 221 | - // complain |
|
| 222 | - if ($status === 0) { |
|
| 223 | - $this->addToAssertionCount(1); |
|
| 224 | - } |
|
| 225 | - } |
|
| 226 | - |
|
| 227 | - public function testAjaxStatusLoggedInCheck(): void { |
|
| 228 | - $this->ajaxExceptionStatus( |
|
| 229 | - 'testNoAnnotationNorAttribute', |
|
| 230 | - 'isLoggedIn', |
|
| 231 | - Http::STATUS_UNAUTHORIZED |
|
| 232 | - ); |
|
| 233 | - } |
|
| 234 | - |
|
| 235 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequired')] |
|
| 236 | - public function testAjaxNotAdminCheck(string $method): void { |
|
| 237 | - $this->ajaxExceptionStatus( |
|
| 238 | - $method, |
|
| 239 | - 'isAdminUser', |
|
| 240 | - Http::STATUS_FORBIDDEN |
|
| 241 | - ); |
|
| 242 | - } |
|
| 243 | - |
|
| 244 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataPublicPage')] |
|
| 245 | - public function testAjaxStatusCSRFCheck(string $method): void { |
|
| 246 | - $this->ajaxExceptionStatus( |
|
| 247 | - $method, |
|
| 248 | - 'passesCSRFCheck', |
|
| 249 | - Http::STATUS_PRECONDITION_FAILED |
|
| 250 | - ); |
|
| 251 | - } |
|
| 252 | - |
|
| 253 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredPublicPage')] |
|
| 254 | - public function testAjaxStatusAllGood(string $method): void { |
|
| 255 | - $this->ajaxExceptionStatus( |
|
| 256 | - $method, |
|
| 257 | - 'isLoggedIn', |
|
| 258 | - 0 |
|
| 259 | - ); |
|
| 260 | - $this->ajaxExceptionStatus( |
|
| 261 | - $method, |
|
| 262 | - 'isAdminUser', |
|
| 263 | - 0 |
|
| 264 | - ); |
|
| 265 | - $this->ajaxExceptionStatus( |
|
| 266 | - $method, |
|
| 267 | - 'passesCSRFCheck', |
|
| 268 | - 0 |
|
| 269 | - ); |
|
| 270 | - } |
|
| 271 | - |
|
| 272 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredPublicPage')] |
|
| 273 | - public function testNoChecks(string $method): void { |
|
| 274 | - $this->request->expects($this->never()) |
|
| 275 | - ->method('passesCSRFCheck') |
|
| 276 | - ->willReturn(false); |
|
| 277 | - |
|
| 278 | - $sec = $this->getMiddleware(false, false, false); |
|
| 279 | - |
|
| 280 | - $this->reader->reflect($this->controller, $method); |
|
| 281 | - $sec->beforeController($this->controller, $method); |
|
| 282 | - } |
|
| 283 | - |
|
| 284 | - /** |
|
| 285 | - * @param string $method |
|
| 286 | - * @param string $expects |
|
| 287 | - */ |
|
| 288 | - private function securityCheck($method, $expects, $shouldFail = false) { |
|
| 289 | - // admin check requires login |
|
| 290 | - if ($expects === 'isAdminUser') { |
|
| 291 | - $isLoggedIn = true; |
|
| 292 | - $isAdminUser = !$shouldFail; |
|
| 293 | - } else { |
|
| 294 | - $isLoggedIn = !$shouldFail; |
|
| 295 | - $isAdminUser = false; |
|
| 296 | - } |
|
| 297 | - |
|
| 298 | - $sec = $this->getMiddleware($isLoggedIn, $isAdminUser, false); |
|
| 299 | - |
|
| 300 | - if ($shouldFail) { |
|
| 301 | - $this->expectException(SecurityException::class); |
|
| 302 | - } else { |
|
| 303 | - $this->addToAssertionCount(1); |
|
| 304 | - } |
|
| 305 | - |
|
| 306 | - $this->reader->reflect($this->controller, $method); |
|
| 307 | - $sec->beforeController($this->controller, $method); |
|
| 308 | - } |
|
| 309 | - |
|
| 310 | - |
|
| 311 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataPublicPage')] |
|
| 312 | - public function testCsrfCheck(string $method): void { |
|
| 313 | - $this->expectException(CrossSiteRequestForgeryException::class); |
|
| 314 | - |
|
| 315 | - $this->request->expects($this->once()) |
|
| 316 | - ->method('passesCSRFCheck') |
|
| 317 | - ->willReturn(false); |
|
| 318 | - $this->request->expects($this->once()) |
|
| 319 | - ->method('passesStrictCookieCheck') |
|
| 320 | - ->willReturn(true); |
|
| 321 | - $this->reader->reflect($this->controller, $method); |
|
| 322 | - $this->middleware->beforeController($this->controller, $method); |
|
| 323 | - } |
|
| 324 | - |
|
| 325 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredPublicPage')] |
|
| 326 | - public function testNoCsrfCheck(string $method): void { |
|
| 327 | - $this->request->expects($this->never()) |
|
| 328 | - ->method('passesCSRFCheck') |
|
| 329 | - ->willReturn(false); |
|
| 330 | - |
|
| 331 | - $this->reader->reflect($this->controller, $method); |
|
| 332 | - $this->middleware->beforeController($this->controller, $method); |
|
| 333 | - } |
|
| 334 | - |
|
| 335 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataPublicPage')] |
|
| 336 | - public function testPassesCsrfCheck(string $method): void { |
|
| 337 | - $this->request->expects($this->once()) |
|
| 338 | - ->method('passesCSRFCheck') |
|
| 339 | - ->willReturn(true); |
|
| 340 | - $this->request->expects($this->once()) |
|
| 341 | - ->method('passesStrictCookieCheck') |
|
| 342 | - ->willReturn(true); |
|
| 343 | - |
|
| 344 | - $this->reader->reflect($this->controller, $method); |
|
| 345 | - $this->middleware->beforeController($this->controller, $method); |
|
| 346 | - } |
|
| 347 | - |
|
| 348 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataPublicPage')] |
|
| 349 | - public function testFailCsrfCheck(string $method): void { |
|
| 350 | - $this->expectException(CrossSiteRequestForgeryException::class); |
|
| 351 | - |
|
| 352 | - $this->request->expects($this->once()) |
|
| 353 | - ->method('passesCSRFCheck') |
|
| 354 | - ->willReturn(false); |
|
| 355 | - $this->request->expects($this->once()) |
|
| 356 | - ->method('passesStrictCookieCheck') |
|
| 357 | - ->willReturn(true); |
|
| 358 | - |
|
| 359 | - $this->reader->reflect($this->controller, $method); |
|
| 360 | - $this->middleware->beforeController($this->controller, $method); |
|
| 361 | - } |
|
| 362 | - |
|
| 363 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataPublicPageStrictCookieRequired')] |
|
| 364 | - public function testStrictCookieRequiredCheck(string $method): void { |
|
| 365 | - $this->expectException(\OC\AppFramework\Middleware\Security\Exceptions\StrictCookieMissingException::class); |
|
| 366 | - |
|
| 367 | - $this->request->expects($this->never()) |
|
| 368 | - ->method('passesCSRFCheck'); |
|
| 369 | - $this->request->expects($this->once()) |
|
| 370 | - ->method('passesStrictCookieCheck') |
|
| 371 | - ->willReturn(false); |
|
| 372 | - |
|
| 373 | - $this->reader->reflect($this->controller, $method); |
|
| 374 | - $this->middleware->beforeController($this->controller, $method); |
|
| 375 | - } |
|
| 376 | - |
|
| 377 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredPublicPage')] |
|
| 378 | - public function testNoStrictCookieRequiredCheck(string $method): void { |
|
| 379 | - $this->request->expects($this->never()) |
|
| 380 | - ->method('passesStrictCookieCheck') |
|
| 381 | - ->willReturn(false); |
|
| 382 | - |
|
| 383 | - $this->reader->reflect($this->controller, $method); |
|
| 384 | - $this->middleware->beforeController($this->controller, $method); |
|
| 385 | - } |
|
| 386 | - |
|
| 387 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredPublicPageStrictCookieRequired')] |
|
| 388 | - public function testPassesStrictCookieRequiredCheck(string $method): void { |
|
| 389 | - $this->request |
|
| 390 | - ->expects($this->once()) |
|
| 391 | - ->method('passesStrictCookieCheck') |
|
| 392 | - ->willReturn(true); |
|
| 393 | - |
|
| 394 | - $this->reader->reflect($this->controller, $method); |
|
| 395 | - $this->middleware->beforeController($this->controller, $method); |
|
| 396 | - } |
|
| 397 | - |
|
| 398 | - public static function dataCsrfOcsController(): array { |
|
| 399 | - return [ |
|
| 400 | - [NormalController::class, false, false, true], |
|
| 401 | - [NormalController::class, false, true, true], |
|
| 402 | - [NormalController::class, true, false, true], |
|
| 403 | - [NormalController::class, true, true, true], |
|
| 404 | - |
|
| 405 | - [OCSController::class, false, false, true], |
|
| 406 | - [OCSController::class, false, true, false], |
|
| 407 | - [OCSController::class, true, false, false], |
|
| 408 | - [OCSController::class, true, true, false], |
|
| 409 | - ]; |
|
| 410 | - } |
|
| 411 | - |
|
| 412 | - /** |
|
| 413 | - * @param string $controllerClass |
|
| 414 | - * @param bool $hasOcsApiHeader |
|
| 415 | - * @param bool $hasBearerAuth |
|
| 416 | - * @param bool $exception |
|
| 417 | - */ |
|
| 418 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataCsrfOcsController')] |
|
| 419 | - public function testCsrfOcsController(string $controllerClass, bool $hasOcsApiHeader, bool $hasBearerAuth, bool $exception): void { |
|
| 420 | - $this->request |
|
| 421 | - ->method('getHeader') |
|
| 422 | - ->willReturnCallback(function ($header) use ($hasOcsApiHeader, $hasBearerAuth) { |
|
| 423 | - if ($header === 'OCS-APIREQUEST' && $hasOcsApiHeader) { |
|
| 424 | - return 'true'; |
|
| 425 | - } |
|
| 426 | - if ($header === 'Authorization' && $hasBearerAuth) { |
|
| 427 | - return 'Bearer TOKEN!'; |
|
| 428 | - } |
|
| 429 | - return ''; |
|
| 430 | - }); |
|
| 431 | - $this->request->expects($this->once()) |
|
| 432 | - ->method('passesStrictCookieCheck') |
|
| 433 | - ->willReturn(true); |
|
| 434 | - |
|
| 435 | - $controller = new $controllerClass('test', $this->request); |
|
| 436 | - |
|
| 437 | - try { |
|
| 438 | - $this->middleware->beforeController($controller, 'foo'); |
|
| 439 | - $this->assertFalse($exception); |
|
| 440 | - } catch (CrossSiteRequestForgeryException $e) { |
|
| 441 | - $this->assertTrue($exception); |
|
| 442 | - } |
|
| 443 | - } |
|
| 444 | - |
|
| 445 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataNoAdminRequiredNoCSRFRequired')] |
|
| 446 | - public function testLoggedInCheck(string $method): void { |
|
| 447 | - $this->securityCheck($method, 'isLoggedIn'); |
|
| 448 | - } |
|
| 449 | - |
|
| 450 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataNoAdminRequiredNoCSRFRequired')] |
|
| 451 | - public function testFailLoggedInCheck(string $method): void { |
|
| 452 | - $this->securityCheck($method, 'isLoggedIn', true); |
|
| 453 | - } |
|
| 454 | - |
|
| 455 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequired')] |
|
| 456 | - public function testIsAdminCheck(string $method): void { |
|
| 457 | - $this->securityCheck($method, 'isAdminUser'); |
|
| 458 | - } |
|
| 459 | - |
|
| 460 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredSubAdminRequired')] |
|
| 461 | - public function testIsNotSubAdminCheck(string $method): void { |
|
| 462 | - $this->reader->reflect($this->controller, $method); |
|
| 463 | - $sec = $this->getMiddleware(true, false, false); |
|
| 464 | - |
|
| 465 | - $this->expectException(SecurityException::class); |
|
| 466 | - $sec->beforeController($this->controller, $method); |
|
| 467 | - } |
|
| 468 | - |
|
| 469 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredSubAdminRequired')] |
|
| 470 | - public function testIsSubAdminCheck(string $method): void { |
|
| 471 | - $this->reader->reflect($this->controller, $method); |
|
| 472 | - $sec = $this->getMiddleware(true, false, true); |
|
| 473 | - |
|
| 474 | - $sec->beforeController($this->controller, $method); |
|
| 475 | - $this->addToAssertionCount(1); |
|
| 476 | - } |
|
| 477 | - |
|
| 478 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredSubAdminRequired')] |
|
| 479 | - public function testIsSubAdminAndAdminCheck(string $method): void { |
|
| 480 | - $this->reader->reflect($this->controller, $method); |
|
| 481 | - $sec = $this->getMiddleware(true, true, true); |
|
| 482 | - |
|
| 483 | - $sec->beforeController($this->controller, $method); |
|
| 484 | - $this->addToAssertionCount(1); |
|
| 485 | - } |
|
| 486 | - |
|
| 487 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequired')] |
|
| 488 | - public function testFailIsAdminCheck(string $method): void { |
|
| 489 | - $this->securityCheck($method, 'isAdminUser', true); |
|
| 490 | - } |
|
| 491 | - |
|
| 492 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataNoAdminRequiredNoCSRFRequiredPublicPage')] |
|
| 493 | - public function testRestrictedAppLoggedInPublicPage(string $method): void { |
|
| 494 | - $middleware = $this->getMiddleware(true, false, false); |
|
| 495 | - $this->reader->reflect($this->controller, $method); |
|
| 496 | - |
|
| 497 | - $this->appManager->method('getAppPath') |
|
| 498 | - ->with('files') |
|
| 499 | - ->willReturn('foo'); |
|
| 500 | - |
|
| 501 | - $this->appManager->method('isEnabledForUser') |
|
| 502 | - ->with('files') |
|
| 503 | - ->willReturn(false); |
|
| 504 | - |
|
| 505 | - $middleware->beforeController($this->controller, $method); |
|
| 506 | - $this->addToAssertionCount(1); |
|
| 507 | - } |
|
| 508 | - |
|
| 509 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataNoAdminRequiredNoCSRFRequiredPublicPage')] |
|
| 510 | - public function testRestrictedAppNotLoggedInPublicPage(string $method): void { |
|
| 511 | - $middleware = $this->getMiddleware(false, false, false); |
|
| 512 | - $this->reader->reflect($this->controller, $method); |
|
| 513 | - |
|
| 514 | - $this->appManager->method('getAppPath') |
|
| 515 | - ->with('files') |
|
| 516 | - ->willReturn('foo'); |
|
| 517 | - |
|
| 518 | - $this->appManager->method('isEnabledForUser') |
|
| 519 | - ->with('files') |
|
| 520 | - ->willReturn(false); |
|
| 521 | - |
|
| 522 | - $middleware->beforeController($this->controller, $method); |
|
| 523 | - $this->addToAssertionCount(1); |
|
| 524 | - } |
|
| 525 | - |
|
| 526 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataNoAdminRequiredNoCSRFRequired')] |
|
| 527 | - public function testRestrictedAppLoggedIn(string $method): void { |
|
| 528 | - $middleware = $this->getMiddleware(true, false, false, false); |
|
| 529 | - $this->reader->reflect($this->controller, $method); |
|
| 530 | - |
|
| 531 | - $this->appManager->method('getAppPath') |
|
| 532 | - ->with('files') |
|
| 533 | - ->willReturn('foo'); |
|
| 534 | - |
|
| 535 | - $this->expectException(AppNotEnabledException::class); |
|
| 536 | - $middleware->beforeController($this->controller, $method); |
|
| 537 | - } |
|
| 538 | - |
|
| 539 | - |
|
| 540 | - public function testAfterExceptionNotCaughtThrowsItAgain(): void { |
|
| 541 | - $ex = new \Exception(); |
|
| 542 | - $this->expectException(\Exception::class); |
|
| 543 | - $this->middleware->afterException($this->controller, 'test', $ex); |
|
| 544 | - } |
|
| 545 | - |
|
| 546 | - public function testAfterExceptionReturnsRedirectForNotLoggedInUser(): void { |
|
| 547 | - $this->request = new Request( |
|
| 548 | - [ |
|
| 549 | - 'server' |
|
| 550 | - => [ |
|
| 551 | - 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', |
|
| 552 | - 'REQUEST_URI' => 'nextcloud/index.php/apps/specialapp' |
|
| 553 | - ] |
|
| 554 | - ], |
|
| 555 | - $this->createMock(IRequestId::class), |
|
| 556 | - $this->createMock(IConfig::class) |
|
| 557 | - ); |
|
| 558 | - $this->middleware = $this->getMiddleware(false, false, false); |
|
| 559 | - $this->urlGenerator |
|
| 560 | - ->expects($this->once()) |
|
| 561 | - ->method('linkToRoute') |
|
| 562 | - ->with( |
|
| 563 | - 'core.login.showLoginForm', |
|
| 564 | - [ |
|
| 565 | - 'redirect_url' => 'nextcloud/index.php/apps/specialapp', |
|
| 566 | - ] |
|
| 567 | - ) |
|
| 568 | - ->willReturn('http://localhost/nextcloud/index.php/login?redirect_url=nextcloud/index.php/apps/specialapp'); |
|
| 569 | - $this->logger |
|
| 570 | - ->expects($this->once()) |
|
| 571 | - ->method('debug'); |
|
| 572 | - $response = $this->middleware->afterException( |
|
| 573 | - $this->controller, |
|
| 574 | - 'test', |
|
| 575 | - new NotLoggedInException() |
|
| 576 | - ); |
|
| 577 | - $expected = new RedirectResponse('http://localhost/nextcloud/index.php/login?redirect_url=nextcloud/index.php/apps/specialapp'); |
|
| 578 | - $this->assertEquals($expected, $response); |
|
| 579 | - } |
|
| 580 | - |
|
| 581 | - public function testAfterExceptionRedirectsToWebRootAfterStrictCookieFail(): void { |
|
| 582 | - $this->request = new Request( |
|
| 583 | - [ |
|
| 584 | - 'server' => [ |
|
| 585 | - 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', |
|
| 586 | - 'REQUEST_URI' => 'nextcloud/index.php/apps/specialapp', |
|
| 587 | - ], |
|
| 588 | - ], |
|
| 589 | - $this->createMock(IRequestId::class), |
|
| 590 | - $this->createMock(IConfig::class) |
|
| 591 | - ); |
|
| 592 | - |
|
| 593 | - $this->middleware = $this->getMiddleware(false, false, false); |
|
| 594 | - $response = $this->middleware->afterException( |
|
| 595 | - $this->controller, |
|
| 596 | - 'test', |
|
| 597 | - new StrictCookieMissingException() |
|
| 598 | - ); |
|
| 599 | - |
|
| 600 | - $expected = new RedirectResponse(\OC::$WEBROOT . '/'); |
|
| 601 | - $this->assertEquals($expected, $response); |
|
| 602 | - } |
|
| 603 | - |
|
| 604 | - |
|
| 605 | - /** |
|
| 606 | - * @return array |
|
| 607 | - */ |
|
| 608 | - public static function exceptionProvider(): array { |
|
| 609 | - return [ |
|
| 610 | - [ |
|
| 611 | - new AppNotEnabledException(), |
|
| 612 | - ], |
|
| 613 | - [ |
|
| 614 | - new CrossSiteRequestForgeryException(), |
|
| 615 | - ], |
|
| 616 | - [ |
|
| 617 | - new NotAdminException(''), |
|
| 618 | - ], |
|
| 619 | - ]; |
|
| 620 | - } |
|
| 621 | - |
|
| 622 | - /** |
|
| 623 | - * @param SecurityException $exception |
|
| 624 | - */ |
|
| 625 | - #[\PHPUnit\Framework\Attributes\DataProvider('exceptionProvider')] |
|
| 626 | - public function testAfterExceptionReturnsTemplateResponse(SecurityException $exception): void { |
|
| 627 | - $this->request = new Request( |
|
| 628 | - [ |
|
| 629 | - 'server' |
|
| 630 | - => [ |
|
| 631 | - 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', |
|
| 632 | - 'REQUEST_URI' => 'nextcloud/index.php/apps/specialapp' |
|
| 633 | - ] |
|
| 634 | - ], |
|
| 635 | - $this->createMock(IRequestId::class), |
|
| 636 | - $this->createMock(IConfig::class) |
|
| 637 | - ); |
|
| 638 | - $this->middleware = $this->getMiddleware(false, false, false); |
|
| 639 | - $this->logger |
|
| 640 | - ->expects($this->once()) |
|
| 641 | - ->method('debug'); |
|
| 642 | - $response = $this->middleware->afterException( |
|
| 643 | - $this->controller, |
|
| 644 | - 'test', |
|
| 645 | - $exception |
|
| 646 | - ); |
|
| 647 | - $expected = new TemplateResponse('core', '403', ['message' => $exception->getMessage()], 'guest'); |
|
| 648 | - $expected->setStatus($exception->getCode()); |
|
| 649 | - $this->assertEquals($expected, $response); |
|
| 650 | - } |
|
| 651 | - |
|
| 652 | - public function testAfterAjaxExceptionReturnsJSONError(): void { |
|
| 653 | - $response = $this->middleware->afterException($this->controller, 'test', |
|
| 654 | - $this->secAjaxException); |
|
| 655 | - |
|
| 656 | - $this->assertTrue($response instanceof JSONResponse); |
|
| 657 | - } |
|
| 658 | - |
|
| 659 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataExAppRequired')] |
|
| 660 | - public function testExAppRequired(string $method): void { |
|
| 661 | - $middleware = $this->getMiddleware(true, false, false); |
|
| 662 | - $this->reader->reflect($this->controller, $method); |
|
| 663 | - |
|
| 664 | - $session = $this->createMock(ISession::class); |
|
| 665 | - $session->method('get')->with('app_api')->willReturn(true); |
|
| 666 | - $this->userSession->method('getSession')->willReturn($session); |
|
| 667 | - |
|
| 668 | - $this->request->expects($this->once()) |
|
| 669 | - ->method('passesStrictCookieCheck') |
|
| 670 | - ->willReturn(true); |
|
| 671 | - $this->request->expects($this->once()) |
|
| 672 | - ->method('passesCSRFCheck') |
|
| 673 | - ->willReturn(true); |
|
| 674 | - |
|
| 675 | - $middleware->beforeController($this->controller, $method); |
|
| 676 | - } |
|
| 677 | - |
|
| 678 | - #[\PHPUnit\Framework\Attributes\DataProvider('dataExAppRequired')] |
|
| 679 | - public function testExAppRequiredError(string $method): void { |
|
| 680 | - $middleware = $this->getMiddleware(true, false, false, false); |
|
| 681 | - $this->reader->reflect($this->controller, $method); |
|
| 682 | - |
|
| 683 | - $session = $this->createMock(ISession::class); |
|
| 684 | - $session->method('get')->with('app_api')->willReturn(false); |
|
| 685 | - $this->userSession->method('getSession')->willReturn($session); |
|
| 686 | - |
|
| 687 | - $this->expectException(ExAppRequiredException::class); |
|
| 688 | - $middleware->beforeController($this->controller, $method); |
|
| 689 | - } |
|
| 48 | + private SecurityMiddleware $middleware; |
|
| 49 | + private ControllerMethodReflector $reader; |
|
| 50 | + private SecurityMiddlewareController $controller; |
|
| 51 | + private SecurityException $secAjaxException; |
|
| 52 | + private IRequest|MockObject $request; |
|
| 53 | + private MiddlewareUtils $middlewareUtils; |
|
| 54 | + private LoggerInterface&MockObject $logger; |
|
| 55 | + private INavigationManager&MockObject $navigationManager; |
|
| 56 | + private IURLGenerator&MockObject $urlGenerator; |
|
| 57 | + private IAppManager&MockObject $appManager; |
|
| 58 | + private IL10N&MockObject $l10n; |
|
| 59 | + private IUserSession&MockObject $userSession; |
|
| 60 | + private AuthorizedGroupMapper&MockObject $authorizedGroupMapper; |
|
| 61 | + |
|
| 62 | + protected function setUp(): void { |
|
| 63 | + parent::setUp(); |
|
| 64 | + |
|
| 65 | + $this->authorizedGroupMapper = $this->createMock(AuthorizedGroupMapper::class); |
|
| 66 | + $this->userSession = $this->createMock(Session::class); |
|
| 67 | + $user = $this->createMock(IUser::class); |
|
| 68 | + $user->method('getUID')->willReturn('test'); |
|
| 69 | + $this->userSession->method('getUser')->willReturn($user); |
|
| 70 | + $this->request = $this->createMock(IRequest::class); |
|
| 71 | + $this->controller = new SecurityMiddlewareController( |
|
| 72 | + 'test', |
|
| 73 | + $this->request |
|
| 74 | + ); |
|
| 75 | + $this->reader = new ControllerMethodReflector(); |
|
| 76 | + $this->logger = $this->createMock(LoggerInterface::class); |
|
| 77 | + $this->navigationManager = $this->createMock(INavigationManager::class); |
|
| 78 | + $this->urlGenerator = $this->createMock(IURLGenerator::class); |
|
| 79 | + $this->l10n = $this->createMock(IL10N::class); |
|
| 80 | + $this->middlewareUtils = new MiddlewareUtils($this->reader, $this->logger); |
|
| 81 | + $this->middleware = $this->getMiddleware(true, true, false); |
|
| 82 | + $this->secAjaxException = new SecurityException('hey', true); |
|
| 83 | + } |
|
| 84 | + |
|
| 85 | + private function getMiddleware(bool $isLoggedIn, bool $isAdminUser, bool $isSubAdmin, bool $isAppEnabledForUser = true): SecurityMiddleware { |
|
| 86 | + $this->appManager = $this->createMock(IAppManager::class); |
|
| 87 | + $this->appManager->expects($this->any()) |
|
| 88 | + ->method('isEnabledForUser') |
|
| 89 | + ->willReturn($isAppEnabledForUser); |
|
| 90 | + $remoteIpAddress = $this->createMock(IRemoteAddress::class); |
|
| 91 | + $remoteIpAddress->method('allowsAdminActions')->willReturn(true); |
|
| 92 | + |
|
| 93 | + $groupManager = $this->createMock(IGroupManager::class); |
|
| 94 | + $groupManager->method('isAdmin') |
|
| 95 | + ->willReturn($isAdminUser); |
|
| 96 | + $subAdminManager = $this->createMock(ISubAdmin::class); |
|
| 97 | + $subAdminManager->method('isSubAdmin') |
|
| 98 | + ->willReturn($isSubAdmin); |
|
| 99 | + |
|
| 100 | + return new SecurityMiddleware( |
|
| 101 | + $this->request, |
|
| 102 | + $this->middlewareUtils, |
|
| 103 | + $this->navigationManager, |
|
| 104 | + $this->urlGenerator, |
|
| 105 | + $this->logger, |
|
| 106 | + 'files', |
|
| 107 | + $isLoggedIn, |
|
| 108 | + $groupManager, |
|
| 109 | + $subAdminManager, |
|
| 110 | + $this->appManager, |
|
| 111 | + $this->l10n, |
|
| 112 | + $this->authorizedGroupMapper, |
|
| 113 | + $this->userSession, |
|
| 114 | + $remoteIpAddress |
|
| 115 | + ); |
|
| 116 | + } |
|
| 117 | + |
|
| 118 | + public static function dataNoCSRFRequiredPublicPage(): array { |
|
| 119 | + return [ |
|
| 120 | + ['testAnnotationNoCSRFRequiredPublicPage'], |
|
| 121 | + ['testAnnotationNoCSRFRequiredAttributePublicPage'], |
|
| 122 | + ['testAnnotationPublicPageAttributeNoCSRFRequired'], |
|
| 123 | + ['testAttributeNoCSRFRequiredPublicPage'], |
|
| 124 | + ]; |
|
| 125 | + } |
|
| 126 | + |
|
| 127 | + public static function dataPublicPage(): array { |
|
| 128 | + return [ |
|
| 129 | + ['testAnnotationPublicPage'], |
|
| 130 | + ['testAttributePublicPage'], |
|
| 131 | + ]; |
|
| 132 | + } |
|
| 133 | + |
|
| 134 | + public static function dataNoCSRFRequired(): array { |
|
| 135 | + return [ |
|
| 136 | + ['testAnnotationNoCSRFRequired'], |
|
| 137 | + ['testAttributeNoCSRFRequired'], |
|
| 138 | + ]; |
|
| 139 | + } |
|
| 140 | + |
|
| 141 | + public static function dataPublicPageStrictCookieRequired(): array { |
|
| 142 | + return [ |
|
| 143 | + ['testAnnotationPublicPageStrictCookieRequired'], |
|
| 144 | + ['testAnnotationStrictCookieRequiredAttributePublicPage'], |
|
| 145 | + ['testAnnotationPublicPageAttributeStrictCookiesRequired'], |
|
| 146 | + ['testAttributePublicPageStrictCookiesRequired'], |
|
| 147 | + ]; |
|
| 148 | + } |
|
| 149 | + |
|
| 150 | + public static function dataNoCSRFRequiredPublicPageStrictCookieRequired(): array { |
|
| 151 | + return [ |
|
| 152 | + ['testAnnotationNoCSRFRequiredPublicPageStrictCookieRequired'], |
|
| 153 | + ['testAttributeNoCSRFRequiredPublicPageStrictCookiesRequired'], |
|
| 154 | + ]; |
|
| 155 | + } |
|
| 156 | + |
|
| 157 | + public static function dataNoAdminRequiredNoCSRFRequired(): array { |
|
| 158 | + return [ |
|
| 159 | + ['testAnnotationNoAdminRequiredNoCSRFRequired'], |
|
| 160 | + ['testAttributeNoAdminRequiredNoCSRFRequired'], |
|
| 161 | + ]; |
|
| 162 | + } |
|
| 163 | + |
|
| 164 | + public static function dataNoAdminRequiredNoCSRFRequiredPublicPage(): array { |
|
| 165 | + return [ |
|
| 166 | + ['testAnnotationNoAdminRequiredNoCSRFRequiredPublicPage'], |
|
| 167 | + ['testAttributeNoAdminRequiredNoCSRFRequiredPublicPage'], |
|
| 168 | + ]; |
|
| 169 | + } |
|
| 170 | + |
|
| 171 | + public static function dataNoCSRFRequiredSubAdminRequired(): array { |
|
| 172 | + return [ |
|
| 173 | + ['testAnnotationNoCSRFRequiredSubAdminRequired'], |
|
| 174 | + ['testAnnotationNoCSRFRequiredAttributeSubAdminRequired'], |
|
| 175 | + ['testAnnotationSubAdminRequiredAttributeNoCSRFRequired'], |
|
| 176 | + ['testAttributeNoCSRFRequiredSubAdminRequired'], |
|
| 177 | + ]; |
|
| 178 | + } |
|
| 179 | + |
|
| 180 | + public static function dataExAppRequired(): array { |
|
| 181 | + return [ |
|
| 182 | + ['testAnnotationExAppRequired'], |
|
| 183 | + ['testAttributeExAppRequired'], |
|
| 184 | + ]; |
|
| 185 | + } |
|
| 186 | + |
|
| 187 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredPublicPage')] |
|
| 188 | + public function testSetNavigationEntry(string $method): void { |
|
| 189 | + $this->navigationManager->expects($this->once()) |
|
| 190 | + ->method('setActiveEntry') |
|
| 191 | + ->with($this->equalTo('files')); |
|
| 192 | + |
|
| 193 | + $this->reader->reflect($this->controller, $method); |
|
| 194 | + $this->middleware->beforeController($this->controller, $method); |
|
| 195 | + } |
|
| 196 | + |
|
| 197 | + |
|
| 198 | + /** |
|
| 199 | + * @param string $method |
|
| 200 | + * @param string $test |
|
| 201 | + */ |
|
| 202 | + private function ajaxExceptionStatus($method, $test, $status) { |
|
| 203 | + $isLoggedIn = false; |
|
| 204 | + $isAdminUser = false; |
|
| 205 | + |
|
| 206 | + // isAdminUser requires isLoggedIn call to return true |
|
| 207 | + if ($test === 'isAdminUser') { |
|
| 208 | + $isLoggedIn = true; |
|
| 209 | + } |
|
| 210 | + |
|
| 211 | + $sec = $this->getMiddleware($isLoggedIn, $isAdminUser, false); |
|
| 212 | + |
|
| 213 | + try { |
|
| 214 | + $this->reader->reflect($this->controller, $method); |
|
| 215 | + $sec->beforeController($this->controller, $method); |
|
| 216 | + } catch (SecurityException $ex) { |
|
| 217 | + $this->assertEquals($status, $ex->getCode()); |
|
| 218 | + } |
|
| 219 | + |
|
| 220 | + // add assertion if everything should work fine otherwise phpunit will |
|
| 221 | + // complain |
|
| 222 | + if ($status === 0) { |
|
| 223 | + $this->addToAssertionCount(1); |
|
| 224 | + } |
|
| 225 | + } |
|
| 226 | + |
|
| 227 | + public function testAjaxStatusLoggedInCheck(): void { |
|
| 228 | + $this->ajaxExceptionStatus( |
|
| 229 | + 'testNoAnnotationNorAttribute', |
|
| 230 | + 'isLoggedIn', |
|
| 231 | + Http::STATUS_UNAUTHORIZED |
|
| 232 | + ); |
|
| 233 | + } |
|
| 234 | + |
|
| 235 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequired')] |
|
| 236 | + public function testAjaxNotAdminCheck(string $method): void { |
|
| 237 | + $this->ajaxExceptionStatus( |
|
| 238 | + $method, |
|
| 239 | + 'isAdminUser', |
|
| 240 | + Http::STATUS_FORBIDDEN |
|
| 241 | + ); |
|
| 242 | + } |
|
| 243 | + |
|
| 244 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataPublicPage')] |
|
| 245 | + public function testAjaxStatusCSRFCheck(string $method): void { |
|
| 246 | + $this->ajaxExceptionStatus( |
|
| 247 | + $method, |
|
| 248 | + 'passesCSRFCheck', |
|
| 249 | + Http::STATUS_PRECONDITION_FAILED |
|
| 250 | + ); |
|
| 251 | + } |
|
| 252 | + |
|
| 253 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredPublicPage')] |
|
| 254 | + public function testAjaxStatusAllGood(string $method): void { |
|
| 255 | + $this->ajaxExceptionStatus( |
|
| 256 | + $method, |
|
| 257 | + 'isLoggedIn', |
|
| 258 | + 0 |
|
| 259 | + ); |
|
| 260 | + $this->ajaxExceptionStatus( |
|
| 261 | + $method, |
|
| 262 | + 'isAdminUser', |
|
| 263 | + 0 |
|
| 264 | + ); |
|
| 265 | + $this->ajaxExceptionStatus( |
|
| 266 | + $method, |
|
| 267 | + 'passesCSRFCheck', |
|
| 268 | + 0 |
|
| 269 | + ); |
|
| 270 | + } |
|
| 271 | + |
|
| 272 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredPublicPage')] |
|
| 273 | + public function testNoChecks(string $method): void { |
|
| 274 | + $this->request->expects($this->never()) |
|
| 275 | + ->method('passesCSRFCheck') |
|
| 276 | + ->willReturn(false); |
|
| 277 | + |
|
| 278 | + $sec = $this->getMiddleware(false, false, false); |
|
| 279 | + |
|
| 280 | + $this->reader->reflect($this->controller, $method); |
|
| 281 | + $sec->beforeController($this->controller, $method); |
|
| 282 | + } |
|
| 283 | + |
|
| 284 | + /** |
|
| 285 | + * @param string $method |
|
| 286 | + * @param string $expects |
|
| 287 | + */ |
|
| 288 | + private function securityCheck($method, $expects, $shouldFail = false) { |
|
| 289 | + // admin check requires login |
|
| 290 | + if ($expects === 'isAdminUser') { |
|
| 291 | + $isLoggedIn = true; |
|
| 292 | + $isAdminUser = !$shouldFail; |
|
| 293 | + } else { |
|
| 294 | + $isLoggedIn = !$shouldFail; |
|
| 295 | + $isAdminUser = false; |
|
| 296 | + } |
|
| 297 | + |
|
| 298 | + $sec = $this->getMiddleware($isLoggedIn, $isAdminUser, false); |
|
| 299 | + |
|
| 300 | + if ($shouldFail) { |
|
| 301 | + $this->expectException(SecurityException::class); |
|
| 302 | + } else { |
|
| 303 | + $this->addToAssertionCount(1); |
|
| 304 | + } |
|
| 305 | + |
|
| 306 | + $this->reader->reflect($this->controller, $method); |
|
| 307 | + $sec->beforeController($this->controller, $method); |
|
| 308 | + } |
|
| 309 | + |
|
| 310 | + |
|
| 311 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataPublicPage')] |
|
| 312 | + public function testCsrfCheck(string $method): void { |
|
| 313 | + $this->expectException(CrossSiteRequestForgeryException::class); |
|
| 314 | + |
|
| 315 | + $this->request->expects($this->once()) |
|
| 316 | + ->method('passesCSRFCheck') |
|
| 317 | + ->willReturn(false); |
|
| 318 | + $this->request->expects($this->once()) |
|
| 319 | + ->method('passesStrictCookieCheck') |
|
| 320 | + ->willReturn(true); |
|
| 321 | + $this->reader->reflect($this->controller, $method); |
|
| 322 | + $this->middleware->beforeController($this->controller, $method); |
|
| 323 | + } |
|
| 324 | + |
|
| 325 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredPublicPage')] |
|
| 326 | + public function testNoCsrfCheck(string $method): void { |
|
| 327 | + $this->request->expects($this->never()) |
|
| 328 | + ->method('passesCSRFCheck') |
|
| 329 | + ->willReturn(false); |
|
| 330 | + |
|
| 331 | + $this->reader->reflect($this->controller, $method); |
|
| 332 | + $this->middleware->beforeController($this->controller, $method); |
|
| 333 | + } |
|
| 334 | + |
|
| 335 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataPublicPage')] |
|
| 336 | + public function testPassesCsrfCheck(string $method): void { |
|
| 337 | + $this->request->expects($this->once()) |
|
| 338 | + ->method('passesCSRFCheck') |
|
| 339 | + ->willReturn(true); |
|
| 340 | + $this->request->expects($this->once()) |
|
| 341 | + ->method('passesStrictCookieCheck') |
|
| 342 | + ->willReturn(true); |
|
| 343 | + |
|
| 344 | + $this->reader->reflect($this->controller, $method); |
|
| 345 | + $this->middleware->beforeController($this->controller, $method); |
|
| 346 | + } |
|
| 347 | + |
|
| 348 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataPublicPage')] |
|
| 349 | + public function testFailCsrfCheck(string $method): void { |
|
| 350 | + $this->expectException(CrossSiteRequestForgeryException::class); |
|
| 351 | + |
|
| 352 | + $this->request->expects($this->once()) |
|
| 353 | + ->method('passesCSRFCheck') |
|
| 354 | + ->willReturn(false); |
|
| 355 | + $this->request->expects($this->once()) |
|
| 356 | + ->method('passesStrictCookieCheck') |
|
| 357 | + ->willReturn(true); |
|
| 358 | + |
|
| 359 | + $this->reader->reflect($this->controller, $method); |
|
| 360 | + $this->middleware->beforeController($this->controller, $method); |
|
| 361 | + } |
|
| 362 | + |
|
| 363 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataPublicPageStrictCookieRequired')] |
|
| 364 | + public function testStrictCookieRequiredCheck(string $method): void { |
|
| 365 | + $this->expectException(\OC\AppFramework\Middleware\Security\Exceptions\StrictCookieMissingException::class); |
|
| 366 | + |
|
| 367 | + $this->request->expects($this->never()) |
|
| 368 | + ->method('passesCSRFCheck'); |
|
| 369 | + $this->request->expects($this->once()) |
|
| 370 | + ->method('passesStrictCookieCheck') |
|
| 371 | + ->willReturn(false); |
|
| 372 | + |
|
| 373 | + $this->reader->reflect($this->controller, $method); |
|
| 374 | + $this->middleware->beforeController($this->controller, $method); |
|
| 375 | + } |
|
| 376 | + |
|
| 377 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredPublicPage')] |
|
| 378 | + public function testNoStrictCookieRequiredCheck(string $method): void { |
|
| 379 | + $this->request->expects($this->never()) |
|
| 380 | + ->method('passesStrictCookieCheck') |
|
| 381 | + ->willReturn(false); |
|
| 382 | + |
|
| 383 | + $this->reader->reflect($this->controller, $method); |
|
| 384 | + $this->middleware->beforeController($this->controller, $method); |
|
| 385 | + } |
|
| 386 | + |
|
| 387 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredPublicPageStrictCookieRequired')] |
|
| 388 | + public function testPassesStrictCookieRequiredCheck(string $method): void { |
|
| 389 | + $this->request |
|
| 390 | + ->expects($this->once()) |
|
| 391 | + ->method('passesStrictCookieCheck') |
|
| 392 | + ->willReturn(true); |
|
| 393 | + |
|
| 394 | + $this->reader->reflect($this->controller, $method); |
|
| 395 | + $this->middleware->beforeController($this->controller, $method); |
|
| 396 | + } |
|
| 397 | + |
|
| 398 | + public static function dataCsrfOcsController(): array { |
|
| 399 | + return [ |
|
| 400 | + [NormalController::class, false, false, true], |
|
| 401 | + [NormalController::class, false, true, true], |
|
| 402 | + [NormalController::class, true, false, true], |
|
| 403 | + [NormalController::class, true, true, true], |
|
| 404 | + |
|
| 405 | + [OCSController::class, false, false, true], |
|
| 406 | + [OCSController::class, false, true, false], |
|
| 407 | + [OCSController::class, true, false, false], |
|
| 408 | + [OCSController::class, true, true, false], |
|
| 409 | + ]; |
|
| 410 | + } |
|
| 411 | + |
|
| 412 | + /** |
|
| 413 | + * @param string $controllerClass |
|
| 414 | + * @param bool $hasOcsApiHeader |
|
| 415 | + * @param bool $hasBearerAuth |
|
| 416 | + * @param bool $exception |
|
| 417 | + */ |
|
| 418 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataCsrfOcsController')] |
|
| 419 | + public function testCsrfOcsController(string $controllerClass, bool $hasOcsApiHeader, bool $hasBearerAuth, bool $exception): void { |
|
| 420 | + $this->request |
|
| 421 | + ->method('getHeader') |
|
| 422 | + ->willReturnCallback(function ($header) use ($hasOcsApiHeader, $hasBearerAuth) { |
|
| 423 | + if ($header === 'OCS-APIREQUEST' && $hasOcsApiHeader) { |
|
| 424 | + return 'true'; |
|
| 425 | + } |
|
| 426 | + if ($header === 'Authorization' && $hasBearerAuth) { |
|
| 427 | + return 'Bearer TOKEN!'; |
|
| 428 | + } |
|
| 429 | + return ''; |
|
| 430 | + }); |
|
| 431 | + $this->request->expects($this->once()) |
|
| 432 | + ->method('passesStrictCookieCheck') |
|
| 433 | + ->willReturn(true); |
|
| 434 | + |
|
| 435 | + $controller = new $controllerClass('test', $this->request); |
|
| 436 | + |
|
| 437 | + try { |
|
| 438 | + $this->middleware->beforeController($controller, 'foo'); |
|
| 439 | + $this->assertFalse($exception); |
|
| 440 | + } catch (CrossSiteRequestForgeryException $e) { |
|
| 441 | + $this->assertTrue($exception); |
|
| 442 | + } |
|
| 443 | + } |
|
| 444 | + |
|
| 445 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoAdminRequiredNoCSRFRequired')] |
|
| 446 | + public function testLoggedInCheck(string $method): void { |
|
| 447 | + $this->securityCheck($method, 'isLoggedIn'); |
|
| 448 | + } |
|
| 449 | + |
|
| 450 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoAdminRequiredNoCSRFRequired')] |
|
| 451 | + public function testFailLoggedInCheck(string $method): void { |
|
| 452 | + $this->securityCheck($method, 'isLoggedIn', true); |
|
| 453 | + } |
|
| 454 | + |
|
| 455 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequired')] |
|
| 456 | + public function testIsAdminCheck(string $method): void { |
|
| 457 | + $this->securityCheck($method, 'isAdminUser'); |
|
| 458 | + } |
|
| 459 | + |
|
| 460 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredSubAdminRequired')] |
|
| 461 | + public function testIsNotSubAdminCheck(string $method): void { |
|
| 462 | + $this->reader->reflect($this->controller, $method); |
|
| 463 | + $sec = $this->getMiddleware(true, false, false); |
|
| 464 | + |
|
| 465 | + $this->expectException(SecurityException::class); |
|
| 466 | + $sec->beforeController($this->controller, $method); |
|
| 467 | + } |
|
| 468 | + |
|
| 469 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredSubAdminRequired')] |
|
| 470 | + public function testIsSubAdminCheck(string $method): void { |
|
| 471 | + $this->reader->reflect($this->controller, $method); |
|
| 472 | + $sec = $this->getMiddleware(true, false, true); |
|
| 473 | + |
|
| 474 | + $sec->beforeController($this->controller, $method); |
|
| 475 | + $this->addToAssertionCount(1); |
|
| 476 | + } |
|
| 477 | + |
|
| 478 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequiredSubAdminRequired')] |
|
| 479 | + public function testIsSubAdminAndAdminCheck(string $method): void { |
|
| 480 | + $this->reader->reflect($this->controller, $method); |
|
| 481 | + $sec = $this->getMiddleware(true, true, true); |
|
| 482 | + |
|
| 483 | + $sec->beforeController($this->controller, $method); |
|
| 484 | + $this->addToAssertionCount(1); |
|
| 485 | + } |
|
| 486 | + |
|
| 487 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoCSRFRequired')] |
|
| 488 | + public function testFailIsAdminCheck(string $method): void { |
|
| 489 | + $this->securityCheck($method, 'isAdminUser', true); |
|
| 490 | + } |
|
| 491 | + |
|
| 492 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoAdminRequiredNoCSRFRequiredPublicPage')] |
|
| 493 | + public function testRestrictedAppLoggedInPublicPage(string $method): void { |
|
| 494 | + $middleware = $this->getMiddleware(true, false, false); |
|
| 495 | + $this->reader->reflect($this->controller, $method); |
|
| 496 | + |
|
| 497 | + $this->appManager->method('getAppPath') |
|
| 498 | + ->with('files') |
|
| 499 | + ->willReturn('foo'); |
|
| 500 | + |
|
| 501 | + $this->appManager->method('isEnabledForUser') |
|
| 502 | + ->with('files') |
|
| 503 | + ->willReturn(false); |
|
| 504 | + |
|
| 505 | + $middleware->beforeController($this->controller, $method); |
|
| 506 | + $this->addToAssertionCount(1); |
|
| 507 | + } |
|
| 508 | + |
|
| 509 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoAdminRequiredNoCSRFRequiredPublicPage')] |
|
| 510 | + public function testRestrictedAppNotLoggedInPublicPage(string $method): void { |
|
| 511 | + $middleware = $this->getMiddleware(false, false, false); |
|
| 512 | + $this->reader->reflect($this->controller, $method); |
|
| 513 | + |
|
| 514 | + $this->appManager->method('getAppPath') |
|
| 515 | + ->with('files') |
|
| 516 | + ->willReturn('foo'); |
|
| 517 | + |
|
| 518 | + $this->appManager->method('isEnabledForUser') |
|
| 519 | + ->with('files') |
|
| 520 | + ->willReturn(false); |
|
| 521 | + |
|
| 522 | + $middleware->beforeController($this->controller, $method); |
|
| 523 | + $this->addToAssertionCount(1); |
|
| 524 | + } |
|
| 525 | + |
|
| 526 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataNoAdminRequiredNoCSRFRequired')] |
|
| 527 | + public function testRestrictedAppLoggedIn(string $method): void { |
|
| 528 | + $middleware = $this->getMiddleware(true, false, false, false); |
|
| 529 | + $this->reader->reflect($this->controller, $method); |
|
| 530 | + |
|
| 531 | + $this->appManager->method('getAppPath') |
|
| 532 | + ->with('files') |
|
| 533 | + ->willReturn('foo'); |
|
| 534 | + |
|
| 535 | + $this->expectException(AppNotEnabledException::class); |
|
| 536 | + $middleware->beforeController($this->controller, $method); |
|
| 537 | + } |
|
| 538 | + |
|
| 539 | + |
|
| 540 | + public function testAfterExceptionNotCaughtThrowsItAgain(): void { |
|
| 541 | + $ex = new \Exception(); |
|
| 542 | + $this->expectException(\Exception::class); |
|
| 543 | + $this->middleware->afterException($this->controller, 'test', $ex); |
|
| 544 | + } |
|
| 545 | + |
|
| 546 | + public function testAfterExceptionReturnsRedirectForNotLoggedInUser(): void { |
|
| 547 | + $this->request = new Request( |
|
| 548 | + [ |
|
| 549 | + 'server' |
|
| 550 | + => [ |
|
| 551 | + 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', |
|
| 552 | + 'REQUEST_URI' => 'nextcloud/index.php/apps/specialapp' |
|
| 553 | + ] |
|
| 554 | + ], |
|
| 555 | + $this->createMock(IRequestId::class), |
|
| 556 | + $this->createMock(IConfig::class) |
|
| 557 | + ); |
|
| 558 | + $this->middleware = $this->getMiddleware(false, false, false); |
|
| 559 | + $this->urlGenerator |
|
| 560 | + ->expects($this->once()) |
|
| 561 | + ->method('linkToRoute') |
|
| 562 | + ->with( |
|
| 563 | + 'core.login.showLoginForm', |
|
| 564 | + [ |
|
| 565 | + 'redirect_url' => 'nextcloud/index.php/apps/specialapp', |
|
| 566 | + ] |
|
| 567 | + ) |
|
| 568 | + ->willReturn('http://localhost/nextcloud/index.php/login?redirect_url=nextcloud/index.php/apps/specialapp'); |
|
| 569 | + $this->logger |
|
| 570 | + ->expects($this->once()) |
|
| 571 | + ->method('debug'); |
|
| 572 | + $response = $this->middleware->afterException( |
|
| 573 | + $this->controller, |
|
| 574 | + 'test', |
|
| 575 | + new NotLoggedInException() |
|
| 576 | + ); |
|
| 577 | + $expected = new RedirectResponse('http://localhost/nextcloud/index.php/login?redirect_url=nextcloud/index.php/apps/specialapp'); |
|
| 578 | + $this->assertEquals($expected, $response); |
|
| 579 | + } |
|
| 580 | + |
|
| 581 | + public function testAfterExceptionRedirectsToWebRootAfterStrictCookieFail(): void { |
|
| 582 | + $this->request = new Request( |
|
| 583 | + [ |
|
| 584 | + 'server' => [ |
|
| 585 | + 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', |
|
| 586 | + 'REQUEST_URI' => 'nextcloud/index.php/apps/specialapp', |
|
| 587 | + ], |
|
| 588 | + ], |
|
| 589 | + $this->createMock(IRequestId::class), |
|
| 590 | + $this->createMock(IConfig::class) |
|
| 591 | + ); |
|
| 592 | + |
|
| 593 | + $this->middleware = $this->getMiddleware(false, false, false); |
|
| 594 | + $response = $this->middleware->afterException( |
|
| 595 | + $this->controller, |
|
| 596 | + 'test', |
|
| 597 | + new StrictCookieMissingException() |
|
| 598 | + ); |
|
| 599 | + |
|
| 600 | + $expected = new RedirectResponse(\OC::$WEBROOT . '/'); |
|
| 601 | + $this->assertEquals($expected, $response); |
|
| 602 | + } |
|
| 603 | + |
|
| 604 | + |
|
| 605 | + /** |
|
| 606 | + * @return array |
|
| 607 | + */ |
|
| 608 | + public static function exceptionProvider(): array { |
|
| 609 | + return [ |
|
| 610 | + [ |
|
| 611 | + new AppNotEnabledException(), |
|
| 612 | + ], |
|
| 613 | + [ |
|
| 614 | + new CrossSiteRequestForgeryException(), |
|
| 615 | + ], |
|
| 616 | + [ |
|
| 617 | + new NotAdminException(''), |
|
| 618 | + ], |
|
| 619 | + ]; |
|
| 620 | + } |
|
| 621 | + |
|
| 622 | + /** |
|
| 623 | + * @param SecurityException $exception |
|
| 624 | + */ |
|
| 625 | + #[\PHPUnit\Framework\Attributes\DataProvider('exceptionProvider')] |
|
| 626 | + public function testAfterExceptionReturnsTemplateResponse(SecurityException $exception): void { |
|
| 627 | + $this->request = new Request( |
|
| 628 | + [ |
|
| 629 | + 'server' |
|
| 630 | + => [ |
|
| 631 | + 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', |
|
| 632 | + 'REQUEST_URI' => 'nextcloud/index.php/apps/specialapp' |
|
| 633 | + ] |
|
| 634 | + ], |
|
| 635 | + $this->createMock(IRequestId::class), |
|
| 636 | + $this->createMock(IConfig::class) |
|
| 637 | + ); |
|
| 638 | + $this->middleware = $this->getMiddleware(false, false, false); |
|
| 639 | + $this->logger |
|
| 640 | + ->expects($this->once()) |
|
| 641 | + ->method('debug'); |
|
| 642 | + $response = $this->middleware->afterException( |
|
| 643 | + $this->controller, |
|
| 644 | + 'test', |
|
| 645 | + $exception |
|
| 646 | + ); |
|
| 647 | + $expected = new TemplateResponse('core', '403', ['message' => $exception->getMessage()], 'guest'); |
|
| 648 | + $expected->setStatus($exception->getCode()); |
|
| 649 | + $this->assertEquals($expected, $response); |
|
| 650 | + } |
|
| 651 | + |
|
| 652 | + public function testAfterAjaxExceptionReturnsJSONError(): void { |
|
| 653 | + $response = $this->middleware->afterException($this->controller, 'test', |
|
| 654 | + $this->secAjaxException); |
|
| 655 | + |
|
| 656 | + $this->assertTrue($response instanceof JSONResponse); |
|
| 657 | + } |
|
| 658 | + |
|
| 659 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataExAppRequired')] |
|
| 660 | + public function testExAppRequired(string $method): void { |
|
| 661 | + $middleware = $this->getMiddleware(true, false, false); |
|
| 662 | + $this->reader->reflect($this->controller, $method); |
|
| 663 | + |
|
| 664 | + $session = $this->createMock(ISession::class); |
|
| 665 | + $session->method('get')->with('app_api')->willReturn(true); |
|
| 666 | + $this->userSession->method('getSession')->willReturn($session); |
|
| 667 | + |
|
| 668 | + $this->request->expects($this->once()) |
|
| 669 | + ->method('passesStrictCookieCheck') |
|
| 670 | + ->willReturn(true); |
|
| 671 | + $this->request->expects($this->once()) |
|
| 672 | + ->method('passesCSRFCheck') |
|
| 673 | + ->willReturn(true); |
|
| 674 | + |
|
| 675 | + $middleware->beforeController($this->controller, $method); |
|
| 676 | + } |
|
| 677 | + |
|
| 678 | + #[\PHPUnit\Framework\Attributes\DataProvider('dataExAppRequired')] |
|
| 679 | + public function testExAppRequiredError(string $method): void { |
|
| 680 | + $middleware = $this->getMiddleware(true, false, false, false); |
|
| 681 | + $this->reader->reflect($this->controller, $method); |
|
| 682 | + |
|
| 683 | + $session = $this->createMock(ISession::class); |
|
| 684 | + $session->method('get')->with('app_api')->willReturn(false); |
|
| 685 | + $this->userSession->method('getSession')->willReturn($session); |
|
| 686 | + |
|
| 687 | + $this->expectException(ExAppRequiredException::class); |
|
| 688 | + $middleware->beforeController($this->controller, $method); |
|
| 689 | + } |
|
| 690 | 690 | } |
@@ -49,7 +49,7 @@ discard block |
||
| 49 | 49 | private ControllerMethodReflector $reader; |
| 50 | 50 | private SecurityMiddlewareController $controller; |
| 51 | 51 | private SecurityException $secAjaxException; |
| 52 | - private IRequest|MockObject $request; |
|
| 52 | + private IRequest | MockObject $request; |
|
| 53 | 53 | private MiddlewareUtils $middlewareUtils; |
| 54 | 54 | private LoggerInterface&MockObject $logger; |
| 55 | 55 | private INavigationManager&MockObject $navigationManager; |
@@ -398,14 +398,14 @@ discard block |
||
| 398 | 398 | public static function dataCsrfOcsController(): array { |
| 399 | 399 | return [ |
| 400 | 400 | [NormalController::class, false, false, true], |
| 401 | - [NormalController::class, false, true, true], |
|
| 402 | - [NormalController::class, true, false, true], |
|
| 403 | - [NormalController::class, true, true, true], |
|
| 404 | - |
|
| 405 | - [OCSController::class, false, false, true], |
|
| 406 | - [OCSController::class, false, true, false], |
|
| 407 | - [OCSController::class, true, false, false], |
|
| 408 | - [OCSController::class, true, true, false], |
|
| 401 | + [NormalController::class, false, true, true], |
|
| 402 | + [NormalController::class, true, false, true], |
|
| 403 | + [NormalController::class, true, true, true], |
|
| 404 | + |
|
| 405 | + [OCSController::class, false, false, true], |
|
| 406 | + [OCSController::class, false, true, false], |
|
| 407 | + [OCSController::class, true, false, false], |
|
| 408 | + [OCSController::class, true, true, false], |
|
| 409 | 409 | ]; |
| 410 | 410 | } |
| 411 | 411 | |
@@ -419,7 +419,7 @@ discard block |
||
| 419 | 419 | public function testCsrfOcsController(string $controllerClass, bool $hasOcsApiHeader, bool $hasBearerAuth, bool $exception): void { |
| 420 | 420 | $this->request |
| 421 | 421 | ->method('getHeader') |
| 422 | - ->willReturnCallback(function ($header) use ($hasOcsApiHeader, $hasBearerAuth) { |
|
| 422 | + ->willReturnCallback(function($header) use ($hasOcsApiHeader, $hasBearerAuth) { |
|
| 423 | 423 | if ($header === 'OCS-APIREQUEST' && $hasOcsApiHeader) { |
| 424 | 424 | return 'true'; |
| 425 | 425 | } |
@@ -597,7 +597,7 @@ discard block |
||
| 597 | 597 | new StrictCookieMissingException() |
| 598 | 598 | ); |
| 599 | 599 | |
| 600 | - $expected = new RedirectResponse(\OC::$WEBROOT . '/'); |
|
| 600 | + $expected = new RedirectResponse(\OC::$WEBROOT.'/'); |
|
| 601 | 601 | $this->assertEquals($expected, $response); |
| 602 | 602 | } |
| 603 | 603 | |
@@ -22,114 +22,114 @@ |
||
| 22 | 22 | use Test\TestCase; |
| 23 | 23 | |
| 24 | 24 | class HasAnnotationController extends Controller { |
| 25 | - #[NoSameSiteCookieRequired] |
|
| 26 | - public function foo(): Response { |
|
| 27 | - return new Response(); |
|
| 28 | - } |
|
| 25 | + #[NoSameSiteCookieRequired] |
|
| 26 | + public function foo(): Response { |
|
| 27 | + return new Response(); |
|
| 28 | + } |
|
| 29 | 29 | } |
| 30 | 30 | |
| 31 | 31 | class NoAnnotationController extends Controller { |
| 32 | - public function foo(): Response { |
|
| 33 | - return new Response(); |
|
| 34 | - } |
|
| 32 | + public function foo(): Response { |
|
| 33 | + return new Response(); |
|
| 34 | + } |
|
| 35 | 35 | } |
| 36 | 36 | |
| 37 | 37 | class SameSiteCookieMiddlewareTest extends TestCase { |
| 38 | - private SameSiteCookieMiddleware $middleware; |
|
| 39 | - private Request&MockObject $request; |
|
| 40 | - private ControllerMethodReflector&MockObject $reflector; |
|
| 41 | - private LoggerInterface&MockObject $logger; |
|
| 38 | + private SameSiteCookieMiddleware $middleware; |
|
| 39 | + private Request&MockObject $request; |
|
| 40 | + private ControllerMethodReflector&MockObject $reflector; |
|
| 41 | + private LoggerInterface&MockObject $logger; |
|
| 42 | 42 | |
| 43 | - protected function setUp(): void { |
|
| 44 | - parent::setUp(); |
|
| 43 | + protected function setUp(): void { |
|
| 44 | + parent::setUp(); |
|
| 45 | 45 | |
| 46 | - $this->request = $this->createMock(Request::class); |
|
| 47 | - $this->logger = $this->createMock(LoggerInterface::class); |
|
| 48 | - $this->reflector = $this->createMock(ControllerMethodReflector::class); |
|
| 49 | - $this->middleware = new SameSiteCookieMiddleware($this->request, new MiddlewareUtils($this->reflector, $this->logger)); |
|
| 50 | - } |
|
| 46 | + $this->request = $this->createMock(Request::class); |
|
| 47 | + $this->logger = $this->createMock(LoggerInterface::class); |
|
| 48 | + $this->reflector = $this->createMock(ControllerMethodReflector::class); |
|
| 49 | + $this->middleware = new SameSiteCookieMiddleware($this->request, new MiddlewareUtils($this->reflector, $this->logger)); |
|
| 50 | + } |
|
| 51 | 51 | |
| 52 | - public function testBeforeControllerNoIndex(): void { |
|
| 53 | - $this->request->method('getScriptName') |
|
| 54 | - ->willReturn('/ocs/v2.php'); |
|
| 52 | + public function testBeforeControllerNoIndex(): void { |
|
| 53 | + $this->request->method('getScriptName') |
|
| 54 | + ->willReturn('/ocs/v2.php'); |
|
| 55 | 55 | |
| 56 | - $this->middleware->beforeController(new NoAnnotationController('foo', $this->request), 'foo'); |
|
| 57 | - $this->addToAssertionCount(1); |
|
| 58 | - } |
|
| 56 | + $this->middleware->beforeController(new NoAnnotationController('foo', $this->request), 'foo'); |
|
| 57 | + $this->addToAssertionCount(1); |
|
| 58 | + } |
|
| 59 | 59 | |
| 60 | - public function testBeforeControllerIndexHasAnnotation(): void { |
|
| 61 | - $this->request->method('getScriptName') |
|
| 62 | - ->willReturn('/index.php'); |
|
| 60 | + public function testBeforeControllerIndexHasAnnotation(): void { |
|
| 61 | + $this->request->method('getScriptName') |
|
| 62 | + ->willReturn('/index.php'); |
|
| 63 | 63 | |
| 64 | - $this->reflector->method('hasAnnotation') |
|
| 65 | - ->with('NoSameSiteCookieRequired') |
|
| 66 | - ->willReturn(true); |
|
| 64 | + $this->reflector->method('hasAnnotation') |
|
| 65 | + ->with('NoSameSiteCookieRequired') |
|
| 66 | + ->willReturn(true); |
|
| 67 | 67 | |
| 68 | - $this->middleware->beforeController(new HasAnnotationController('foo', $this->request), 'foo'); |
|
| 69 | - $this->addToAssertionCount(1); |
|
| 70 | - } |
|
| 68 | + $this->middleware->beforeController(new HasAnnotationController('foo', $this->request), 'foo'); |
|
| 69 | + $this->addToAssertionCount(1); |
|
| 70 | + } |
|
| 71 | 71 | |
| 72 | - public function testBeforeControllerIndexNoAnnotationPassingCheck(): void { |
|
| 73 | - $this->request->method('getScriptName') |
|
| 74 | - ->willReturn('/index.php'); |
|
| 72 | + public function testBeforeControllerIndexNoAnnotationPassingCheck(): void { |
|
| 73 | + $this->request->method('getScriptName') |
|
| 74 | + ->willReturn('/index.php'); |
|
| 75 | 75 | |
| 76 | - $this->reflector->method('hasAnnotation') |
|
| 77 | - ->with('NoSameSiteCookieRequired') |
|
| 78 | - ->willReturn(false); |
|
| 76 | + $this->reflector->method('hasAnnotation') |
|
| 77 | + ->with('NoSameSiteCookieRequired') |
|
| 78 | + ->willReturn(false); |
|
| 79 | 79 | |
| 80 | - $this->request->method('passesLaxCookieCheck') |
|
| 81 | - ->willReturn(true); |
|
| 80 | + $this->request->method('passesLaxCookieCheck') |
|
| 81 | + ->willReturn(true); |
|
| 82 | 82 | |
| 83 | - $this->middleware->beforeController(new NoAnnotationController('foo', $this->request), 'foo'); |
|
| 84 | - $this->addToAssertionCount(1); |
|
| 85 | - } |
|
| 83 | + $this->middleware->beforeController(new NoAnnotationController('foo', $this->request), 'foo'); |
|
| 84 | + $this->addToAssertionCount(1); |
|
| 85 | + } |
|
| 86 | 86 | |
| 87 | - public function testBeforeControllerIndexNoAnnotationFailingCheck(): void { |
|
| 88 | - $this->expectException(LaxSameSiteCookieFailedException::class); |
|
| 87 | + public function testBeforeControllerIndexNoAnnotationFailingCheck(): void { |
|
| 88 | + $this->expectException(LaxSameSiteCookieFailedException::class); |
|
| 89 | 89 | |
| 90 | - $this->request->method('getScriptName') |
|
| 91 | - ->willReturn('/index.php'); |
|
| 90 | + $this->request->method('getScriptName') |
|
| 91 | + ->willReturn('/index.php'); |
|
| 92 | 92 | |
| 93 | - $this->reflector->method('hasAnnotation') |
|
| 94 | - ->with('NoSameSiteCookieRequired') |
|
| 95 | - ->willReturn(false); |
|
| 93 | + $this->reflector->method('hasAnnotation') |
|
| 94 | + ->with('NoSameSiteCookieRequired') |
|
| 95 | + ->willReturn(false); |
|
| 96 | 96 | |
| 97 | - $this->request->method('passesLaxCookieCheck') |
|
| 98 | - ->willReturn(false); |
|
| 97 | + $this->request->method('passesLaxCookieCheck') |
|
| 98 | + ->willReturn(false); |
|
| 99 | 99 | |
| 100 | - $this->middleware->beforeController(new NoAnnotationController('foo', $this->request), 'foo'); |
|
| 101 | - } |
|
| 100 | + $this->middleware->beforeController(new NoAnnotationController('foo', $this->request), 'foo'); |
|
| 101 | + } |
|
| 102 | 102 | |
| 103 | - public function testAfterExceptionNoLaxCookie(): void { |
|
| 104 | - $ex = new SecurityException(); |
|
| 103 | + public function testAfterExceptionNoLaxCookie(): void { |
|
| 104 | + $ex = new SecurityException(); |
|
| 105 | 105 | |
| 106 | - try { |
|
| 107 | - $this->middleware->afterException(new NoAnnotationController('foo', $this->request), 'foo', $ex); |
|
| 108 | - $this->fail(); |
|
| 109 | - } catch (\Exception $e) { |
|
| 110 | - $this->assertSame($ex, $e); |
|
| 111 | - } |
|
| 112 | - } |
|
| 106 | + try { |
|
| 107 | + $this->middleware->afterException(new NoAnnotationController('foo', $this->request), 'foo', $ex); |
|
| 108 | + $this->fail(); |
|
| 109 | + } catch (\Exception $e) { |
|
| 110 | + $this->assertSame($ex, $e); |
|
| 111 | + } |
|
| 112 | + } |
|
| 113 | 113 | |
| 114 | - public function testAfterExceptionLaxCookie(): void { |
|
| 115 | - $ex = new LaxSameSiteCookieFailedException(); |
|
| 114 | + public function testAfterExceptionLaxCookie(): void { |
|
| 115 | + $ex = new LaxSameSiteCookieFailedException(); |
|
| 116 | 116 | |
| 117 | - $this->request->method('getRequestUri') |
|
| 118 | - ->willReturn('/myrequri'); |
|
| 117 | + $this->request->method('getRequestUri') |
|
| 118 | + ->willReturn('/myrequri'); |
|
| 119 | 119 | |
| 120 | - $middleware = $this->getMockBuilder(SameSiteCookieMiddleware::class) |
|
| 121 | - ->setConstructorArgs([$this->request, new MiddlewareUtils($this->reflector, $this->logger)]) |
|
| 122 | - ->onlyMethods(['setSameSiteCookie']) |
|
| 123 | - ->getMock(); |
|
| 120 | + $middleware = $this->getMockBuilder(SameSiteCookieMiddleware::class) |
|
| 121 | + ->setConstructorArgs([$this->request, new MiddlewareUtils($this->reflector, $this->logger)]) |
|
| 122 | + ->onlyMethods(['setSameSiteCookie']) |
|
| 123 | + ->getMock(); |
|
| 124 | 124 | |
| 125 | - $middleware->expects($this->once()) |
|
| 126 | - ->method('setSameSiteCookie'); |
|
| 125 | + $middleware->expects($this->once()) |
|
| 126 | + ->method('setSameSiteCookie'); |
|
| 127 | 127 | |
| 128 | - $resp = $middleware->afterException(new NoAnnotationController('foo', $this->request), 'foo', $ex); |
|
| 128 | + $resp = $middleware->afterException(new NoAnnotationController('foo', $this->request), 'foo', $ex); |
|
| 129 | 129 | |
| 130 | - $this->assertSame(Http::STATUS_FOUND, $resp->getStatus()); |
|
| 130 | + $this->assertSame(Http::STATUS_FOUND, $resp->getStatus()); |
|
| 131 | 131 | |
| 132 | - $headers = $resp->getHeaders(); |
|
| 133 | - $this->assertSame('/myrequri', $headers['Location']); |
|
| 134 | - } |
|
| 132 | + $headers = $resp->getHeaders(); |
|
| 133 | + $this->assertSame('/myrequri', $headers['Location']); |
|
| 134 | + } |
|
| 135 | 135 | } |
@@ -44,466 +44,466 @@ |
||
| 44 | 44 | * @package OCA\Theming\Controller |
| 45 | 45 | */ |
| 46 | 46 | class ThemingController extends Controller { |
| 47 | - public const VALID_UPLOAD_KEYS = ['header', 'logo', 'logoheader', 'background', 'favicon']; |
|
| 47 | + public const VALID_UPLOAD_KEYS = ['header', 'logo', 'logoheader', 'background', 'favicon']; |
|
| 48 | 48 | |
| 49 | - public function __construct( |
|
| 50 | - string $appName, |
|
| 51 | - IRequest $request, |
|
| 52 | - private IConfig $config, |
|
| 53 | - private IAppConfig $appConfig, |
|
| 54 | - private ThemingDefaults $themingDefaults, |
|
| 55 | - private IL10N $l10n, |
|
| 56 | - private IURLGenerator $urlGenerator, |
|
| 57 | - private IAppManager $appManager, |
|
| 58 | - private ImageManager $imageManager, |
|
| 59 | - private ThemesService $themesService, |
|
| 60 | - private INavigationManager $navigationManager, |
|
| 61 | - ) { |
|
| 62 | - parent::__construct($appName, $request); |
|
| 63 | - } |
|
| 49 | + public function __construct( |
|
| 50 | + string $appName, |
|
| 51 | + IRequest $request, |
|
| 52 | + private IConfig $config, |
|
| 53 | + private IAppConfig $appConfig, |
|
| 54 | + private ThemingDefaults $themingDefaults, |
|
| 55 | + private IL10N $l10n, |
|
| 56 | + private IURLGenerator $urlGenerator, |
|
| 57 | + private IAppManager $appManager, |
|
| 58 | + private ImageManager $imageManager, |
|
| 59 | + private ThemesService $themesService, |
|
| 60 | + private INavigationManager $navigationManager, |
|
| 61 | + ) { |
|
| 62 | + parent::__construct($appName, $request); |
|
| 63 | + } |
|
| 64 | 64 | |
| 65 | - /** |
|
| 66 | - * @throws NotPermittedException |
|
| 67 | - */ |
|
| 68 | - #[AuthorizedAdminSetting(settings: Admin::class)] |
|
| 69 | - public function updateStylesheet(string $setting, string $value): DataResponse { |
|
| 70 | - $value = trim($value); |
|
| 71 | - $error = null; |
|
| 72 | - $saved = false; |
|
| 73 | - switch ($setting) { |
|
| 74 | - case 'name': |
|
| 75 | - if (strlen($value) > 250) { |
|
| 76 | - $error = $this->l10n->t('The given name is too long'); |
|
| 77 | - } |
|
| 78 | - break; |
|
| 79 | - case 'url': |
|
| 80 | - if (strlen($value) > 500) { |
|
| 81 | - $error = $this->l10n->t('The given web address is too long'); |
|
| 82 | - } |
|
| 83 | - if ($value !== '' && !$this->isValidUrl($value)) { |
|
| 84 | - $error = $this->l10n->t('The given web address is not a valid URL'); |
|
| 85 | - } |
|
| 86 | - break; |
|
| 87 | - case 'legalNoticeUrl': |
|
| 88 | - $setting = 'imprintUrl'; |
|
| 89 | - // no break |
|
| 90 | - case 'imprintUrl': |
|
| 91 | - if (strlen($value) > 500) { |
|
| 92 | - $error = $this->l10n->t('The given legal notice address is too long'); |
|
| 93 | - } |
|
| 94 | - if ($value !== '' && !$this->isValidUrl($value)) { |
|
| 95 | - $error = $this->l10n->t('The given legal notice address is not a valid URL'); |
|
| 96 | - } |
|
| 97 | - break; |
|
| 98 | - case 'privacyPolicyUrl': |
|
| 99 | - $setting = 'privacyUrl'; |
|
| 100 | - // no break |
|
| 101 | - case 'privacyUrl': |
|
| 102 | - if (strlen($value) > 500) { |
|
| 103 | - $error = $this->l10n->t('The given privacy policy address is too long'); |
|
| 104 | - } |
|
| 105 | - if ($value !== '' && !$this->isValidUrl($value)) { |
|
| 106 | - $error = $this->l10n->t('The given privacy policy address is not a valid URL'); |
|
| 107 | - } |
|
| 108 | - break; |
|
| 109 | - case 'slogan': |
|
| 110 | - if (strlen($value) > 500) { |
|
| 111 | - $error = $this->l10n->t('The given slogan is too long'); |
|
| 112 | - } |
|
| 113 | - break; |
|
| 114 | - case 'primaryColor': |
|
| 115 | - $setting = 'primary_color'; |
|
| 116 | - // no break |
|
| 117 | - case 'primary_color': |
|
| 118 | - if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) { |
|
| 119 | - $error = $this->l10n->t('The given color is invalid'); |
|
| 120 | - } |
|
| 121 | - break; |
|
| 122 | - case 'backgroundColor': |
|
| 123 | - $setting = 'background_color'; |
|
| 124 | - // no break |
|
| 125 | - case 'background_color': |
|
| 126 | - if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) { |
|
| 127 | - $error = $this->l10n->t('The given color is invalid'); |
|
| 128 | - } |
|
| 129 | - break; |
|
| 130 | - case 'disableUserTheming': |
|
| 131 | - case 'disable-user-theming': |
|
| 132 | - if (!in_array($value, ['yes', 'true', 'no', 'false'])) { |
|
| 133 | - $error = $this->l10n->t('%1$s should be true or false', ['disable-user-theming']); |
|
| 134 | - } else { |
|
| 135 | - $this->appConfig->setAppValueBool('disable-user-theming', $value === 'yes' || $value === 'true'); |
|
| 136 | - $saved = true; |
|
| 137 | - } |
|
| 138 | - break; |
|
| 139 | - case 'backgroundMime': |
|
| 140 | - if ($value !== 'backgroundColor') { |
|
| 141 | - $error = $this->l10n->t('%1$s can only be set to %2$s through the API', ['backgroundMime', 'backgroundColor']); |
|
| 142 | - } |
|
| 143 | - break; |
|
| 144 | - default: |
|
| 145 | - $error = $this->l10n->t('Invalid setting key'); |
|
| 146 | - } |
|
| 147 | - if ($error !== null) { |
|
| 148 | - return new DataResponse([ |
|
| 149 | - 'data' => [ |
|
| 150 | - 'message' => $error, |
|
| 151 | - ], |
|
| 152 | - 'status' => 'error' |
|
| 153 | - ], Http::STATUS_BAD_REQUEST); |
|
| 154 | - } |
|
| 65 | + /** |
|
| 66 | + * @throws NotPermittedException |
|
| 67 | + */ |
|
| 68 | + #[AuthorizedAdminSetting(settings: Admin::class)] |
|
| 69 | + public function updateStylesheet(string $setting, string $value): DataResponse { |
|
| 70 | + $value = trim($value); |
|
| 71 | + $error = null; |
|
| 72 | + $saved = false; |
|
| 73 | + switch ($setting) { |
|
| 74 | + case 'name': |
|
| 75 | + if (strlen($value) > 250) { |
|
| 76 | + $error = $this->l10n->t('The given name is too long'); |
|
| 77 | + } |
|
| 78 | + break; |
|
| 79 | + case 'url': |
|
| 80 | + if (strlen($value) > 500) { |
|
| 81 | + $error = $this->l10n->t('The given web address is too long'); |
|
| 82 | + } |
|
| 83 | + if ($value !== '' && !$this->isValidUrl($value)) { |
|
| 84 | + $error = $this->l10n->t('The given web address is not a valid URL'); |
|
| 85 | + } |
|
| 86 | + break; |
|
| 87 | + case 'legalNoticeUrl': |
|
| 88 | + $setting = 'imprintUrl'; |
|
| 89 | + // no break |
|
| 90 | + case 'imprintUrl': |
|
| 91 | + if (strlen($value) > 500) { |
|
| 92 | + $error = $this->l10n->t('The given legal notice address is too long'); |
|
| 93 | + } |
|
| 94 | + if ($value !== '' && !$this->isValidUrl($value)) { |
|
| 95 | + $error = $this->l10n->t('The given legal notice address is not a valid URL'); |
|
| 96 | + } |
|
| 97 | + break; |
|
| 98 | + case 'privacyPolicyUrl': |
|
| 99 | + $setting = 'privacyUrl'; |
|
| 100 | + // no break |
|
| 101 | + case 'privacyUrl': |
|
| 102 | + if (strlen($value) > 500) { |
|
| 103 | + $error = $this->l10n->t('The given privacy policy address is too long'); |
|
| 104 | + } |
|
| 105 | + if ($value !== '' && !$this->isValidUrl($value)) { |
|
| 106 | + $error = $this->l10n->t('The given privacy policy address is not a valid URL'); |
|
| 107 | + } |
|
| 108 | + break; |
|
| 109 | + case 'slogan': |
|
| 110 | + if (strlen($value) > 500) { |
|
| 111 | + $error = $this->l10n->t('The given slogan is too long'); |
|
| 112 | + } |
|
| 113 | + break; |
|
| 114 | + case 'primaryColor': |
|
| 115 | + $setting = 'primary_color'; |
|
| 116 | + // no break |
|
| 117 | + case 'primary_color': |
|
| 118 | + if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) { |
|
| 119 | + $error = $this->l10n->t('The given color is invalid'); |
|
| 120 | + } |
|
| 121 | + break; |
|
| 122 | + case 'backgroundColor': |
|
| 123 | + $setting = 'background_color'; |
|
| 124 | + // no break |
|
| 125 | + case 'background_color': |
|
| 126 | + if (!preg_match('/^\#([0-9a-f]{3}|[0-9a-f]{6})$/i', $value)) { |
|
| 127 | + $error = $this->l10n->t('The given color is invalid'); |
|
| 128 | + } |
|
| 129 | + break; |
|
| 130 | + case 'disableUserTheming': |
|
| 131 | + case 'disable-user-theming': |
|
| 132 | + if (!in_array($value, ['yes', 'true', 'no', 'false'])) { |
|
| 133 | + $error = $this->l10n->t('%1$s should be true or false', ['disable-user-theming']); |
|
| 134 | + } else { |
|
| 135 | + $this->appConfig->setAppValueBool('disable-user-theming', $value === 'yes' || $value === 'true'); |
|
| 136 | + $saved = true; |
|
| 137 | + } |
|
| 138 | + break; |
|
| 139 | + case 'backgroundMime': |
|
| 140 | + if ($value !== 'backgroundColor') { |
|
| 141 | + $error = $this->l10n->t('%1$s can only be set to %2$s through the API', ['backgroundMime', 'backgroundColor']); |
|
| 142 | + } |
|
| 143 | + break; |
|
| 144 | + default: |
|
| 145 | + $error = $this->l10n->t('Invalid setting key'); |
|
| 146 | + } |
|
| 147 | + if ($error !== null) { |
|
| 148 | + return new DataResponse([ |
|
| 149 | + 'data' => [ |
|
| 150 | + 'message' => $error, |
|
| 151 | + ], |
|
| 152 | + 'status' => 'error' |
|
| 153 | + ], Http::STATUS_BAD_REQUEST); |
|
| 154 | + } |
|
| 155 | 155 | |
| 156 | - if (!$saved) { |
|
| 157 | - $this->themingDefaults->set($setting, $value); |
|
| 158 | - } |
|
| 156 | + if (!$saved) { |
|
| 157 | + $this->themingDefaults->set($setting, $value); |
|
| 158 | + } |
|
| 159 | 159 | |
| 160 | - return new DataResponse([ |
|
| 161 | - 'data' => [ |
|
| 162 | - 'message' => $this->l10n->t('Saved'), |
|
| 163 | - ], |
|
| 164 | - 'status' => 'success' |
|
| 165 | - ]); |
|
| 166 | - } |
|
| 160 | + return new DataResponse([ |
|
| 161 | + 'data' => [ |
|
| 162 | + 'message' => $this->l10n->t('Saved'), |
|
| 163 | + ], |
|
| 164 | + 'status' => 'success' |
|
| 165 | + ]); |
|
| 166 | + } |
|
| 167 | 167 | |
| 168 | - /** |
|
| 169 | - * @throws NotPermittedException |
|
| 170 | - */ |
|
| 171 | - #[AuthorizedAdminSetting(settings: Admin::class)] |
|
| 172 | - public function updateAppMenu(string $setting, mixed $value): DataResponse { |
|
| 173 | - $error = null; |
|
| 174 | - switch ($setting) { |
|
| 175 | - case 'defaultApps': |
|
| 176 | - if (is_array($value)) { |
|
| 177 | - try { |
|
| 178 | - $this->navigationManager->setDefaultEntryIds($value); |
|
| 179 | - } catch (InvalidArgumentException $e) { |
|
| 180 | - $error = $this->l10n->t('Invalid app given'); |
|
| 181 | - } |
|
| 182 | - } else { |
|
| 183 | - $error = $this->l10n->t('Invalid type for setting "defaultApp" given'); |
|
| 184 | - } |
|
| 185 | - break; |
|
| 186 | - default: |
|
| 187 | - $error = $this->l10n->t('Invalid setting key'); |
|
| 188 | - } |
|
| 189 | - if ($error !== null) { |
|
| 190 | - return new DataResponse([ |
|
| 191 | - 'data' => [ |
|
| 192 | - 'message' => $error, |
|
| 193 | - ], |
|
| 194 | - 'status' => 'error' |
|
| 195 | - ], Http::STATUS_BAD_REQUEST); |
|
| 196 | - } |
|
| 168 | + /** |
|
| 169 | + * @throws NotPermittedException |
|
| 170 | + */ |
|
| 171 | + #[AuthorizedAdminSetting(settings: Admin::class)] |
|
| 172 | + public function updateAppMenu(string $setting, mixed $value): DataResponse { |
|
| 173 | + $error = null; |
|
| 174 | + switch ($setting) { |
|
| 175 | + case 'defaultApps': |
|
| 176 | + if (is_array($value)) { |
|
| 177 | + try { |
|
| 178 | + $this->navigationManager->setDefaultEntryIds($value); |
|
| 179 | + } catch (InvalidArgumentException $e) { |
|
| 180 | + $error = $this->l10n->t('Invalid app given'); |
|
| 181 | + } |
|
| 182 | + } else { |
|
| 183 | + $error = $this->l10n->t('Invalid type for setting "defaultApp" given'); |
|
| 184 | + } |
|
| 185 | + break; |
|
| 186 | + default: |
|
| 187 | + $error = $this->l10n->t('Invalid setting key'); |
|
| 188 | + } |
|
| 189 | + if ($error !== null) { |
|
| 190 | + return new DataResponse([ |
|
| 191 | + 'data' => [ |
|
| 192 | + 'message' => $error, |
|
| 193 | + ], |
|
| 194 | + 'status' => 'error' |
|
| 195 | + ], Http::STATUS_BAD_REQUEST); |
|
| 196 | + } |
|
| 197 | 197 | |
| 198 | - return new DataResponse([ |
|
| 199 | - 'data' => [ |
|
| 200 | - 'message' => $this->l10n->t('Saved'), |
|
| 201 | - ], |
|
| 202 | - 'status' => 'success' |
|
| 203 | - ]); |
|
| 204 | - } |
|
| 198 | + return new DataResponse([ |
|
| 199 | + 'data' => [ |
|
| 200 | + 'message' => $this->l10n->t('Saved'), |
|
| 201 | + ], |
|
| 202 | + 'status' => 'success' |
|
| 203 | + ]); |
|
| 204 | + } |
|
| 205 | 205 | |
| 206 | - /** |
|
| 207 | - * Check that a string is a valid http/https url. |
|
| 208 | - * Also validates that there is no way for XSS through HTML |
|
| 209 | - */ |
|
| 210 | - private function isValidUrl(string $url): bool { |
|
| 211 | - return ((str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) |
|
| 212 | - && filter_var($url, FILTER_VALIDATE_URL) !== false) |
|
| 213 | - && !str_contains($url, '"'); |
|
| 214 | - } |
|
| 206 | + /** |
|
| 207 | + * Check that a string is a valid http/https url. |
|
| 208 | + * Also validates that there is no way for XSS through HTML |
|
| 209 | + */ |
|
| 210 | + private function isValidUrl(string $url): bool { |
|
| 211 | + return ((str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) |
|
| 212 | + && filter_var($url, FILTER_VALIDATE_URL) !== false) |
|
| 213 | + && !str_contains($url, '"'); |
|
| 214 | + } |
|
| 215 | 215 | |
| 216 | - /** |
|
| 217 | - * @throws NotPermittedException |
|
| 218 | - */ |
|
| 219 | - #[AuthorizedAdminSetting(settings: Admin::class)] |
|
| 220 | - public function uploadImage(): DataResponse { |
|
| 221 | - $key = $this->request->getParam('key'); |
|
| 222 | - if (!in_array($key, self::VALID_UPLOAD_KEYS, true)) { |
|
| 223 | - return new DataResponse( |
|
| 224 | - [ |
|
| 225 | - 'data' => [ |
|
| 226 | - 'message' => 'Invalid key' |
|
| 227 | - ], |
|
| 228 | - 'status' => 'failure', |
|
| 229 | - ], |
|
| 230 | - Http::STATUS_BAD_REQUEST |
|
| 231 | - ); |
|
| 232 | - } |
|
| 233 | - $image = $this->request->getUploadedFile('image'); |
|
| 234 | - $error = null; |
|
| 235 | - $phpFileUploadErrors = [ |
|
| 236 | - UPLOAD_ERR_OK => $this->l10n->t('The file was uploaded'), |
|
| 237 | - UPLOAD_ERR_INI_SIZE => $this->l10n->t('The uploaded file exceeds the upload_max_filesize directive in php.ini'), |
|
| 238 | - UPLOAD_ERR_FORM_SIZE => $this->l10n->t('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'), |
|
| 239 | - UPLOAD_ERR_PARTIAL => $this->l10n->t('The file was only partially uploaded'), |
|
| 240 | - UPLOAD_ERR_NO_FILE => $this->l10n->t('No file was uploaded'), |
|
| 241 | - UPLOAD_ERR_NO_TMP_DIR => $this->l10n->t('Missing a temporary folder'), |
|
| 242 | - UPLOAD_ERR_CANT_WRITE => $this->l10n->t('Could not write file to disk'), |
|
| 243 | - UPLOAD_ERR_EXTENSION => $this->l10n->t('A PHP extension stopped the file upload'), |
|
| 244 | - ]; |
|
| 245 | - if (empty($image)) { |
|
| 246 | - $error = $this->l10n->t('No file uploaded'); |
|
| 247 | - } |
|
| 248 | - if (!empty($image) && array_key_exists('error', $image) && $image['error'] !== UPLOAD_ERR_OK) { |
|
| 249 | - $error = $phpFileUploadErrors[$image['error']]; |
|
| 250 | - } |
|
| 216 | + /** |
|
| 217 | + * @throws NotPermittedException |
|
| 218 | + */ |
|
| 219 | + #[AuthorizedAdminSetting(settings: Admin::class)] |
|
| 220 | + public function uploadImage(): DataResponse { |
|
| 221 | + $key = $this->request->getParam('key'); |
|
| 222 | + if (!in_array($key, self::VALID_UPLOAD_KEYS, true)) { |
|
| 223 | + return new DataResponse( |
|
| 224 | + [ |
|
| 225 | + 'data' => [ |
|
| 226 | + 'message' => 'Invalid key' |
|
| 227 | + ], |
|
| 228 | + 'status' => 'failure', |
|
| 229 | + ], |
|
| 230 | + Http::STATUS_BAD_REQUEST |
|
| 231 | + ); |
|
| 232 | + } |
|
| 233 | + $image = $this->request->getUploadedFile('image'); |
|
| 234 | + $error = null; |
|
| 235 | + $phpFileUploadErrors = [ |
|
| 236 | + UPLOAD_ERR_OK => $this->l10n->t('The file was uploaded'), |
|
| 237 | + UPLOAD_ERR_INI_SIZE => $this->l10n->t('The uploaded file exceeds the upload_max_filesize directive in php.ini'), |
|
| 238 | + UPLOAD_ERR_FORM_SIZE => $this->l10n->t('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'), |
|
| 239 | + UPLOAD_ERR_PARTIAL => $this->l10n->t('The file was only partially uploaded'), |
|
| 240 | + UPLOAD_ERR_NO_FILE => $this->l10n->t('No file was uploaded'), |
|
| 241 | + UPLOAD_ERR_NO_TMP_DIR => $this->l10n->t('Missing a temporary folder'), |
|
| 242 | + UPLOAD_ERR_CANT_WRITE => $this->l10n->t('Could not write file to disk'), |
|
| 243 | + UPLOAD_ERR_EXTENSION => $this->l10n->t('A PHP extension stopped the file upload'), |
|
| 244 | + ]; |
|
| 245 | + if (empty($image)) { |
|
| 246 | + $error = $this->l10n->t('No file uploaded'); |
|
| 247 | + } |
|
| 248 | + if (!empty($image) && array_key_exists('error', $image) && $image['error'] !== UPLOAD_ERR_OK) { |
|
| 249 | + $error = $phpFileUploadErrors[$image['error']]; |
|
| 250 | + } |
|
| 251 | 251 | |
| 252 | - if ($error !== null) { |
|
| 253 | - return new DataResponse( |
|
| 254 | - [ |
|
| 255 | - 'data' => [ |
|
| 256 | - 'message' => $error |
|
| 257 | - ], |
|
| 258 | - 'status' => 'failure', |
|
| 259 | - ], |
|
| 260 | - Http::STATUS_UNPROCESSABLE_ENTITY |
|
| 261 | - ); |
|
| 262 | - } |
|
| 252 | + if ($error !== null) { |
|
| 253 | + return new DataResponse( |
|
| 254 | + [ |
|
| 255 | + 'data' => [ |
|
| 256 | + 'message' => $error |
|
| 257 | + ], |
|
| 258 | + 'status' => 'failure', |
|
| 259 | + ], |
|
| 260 | + Http::STATUS_UNPROCESSABLE_ENTITY |
|
| 261 | + ); |
|
| 262 | + } |
|
| 263 | 263 | |
| 264 | - try { |
|
| 265 | - $mime = $this->imageManager->updateImage($key, $image['tmp_name']); |
|
| 266 | - $this->themingDefaults->set($key . 'Mime', $mime); |
|
| 267 | - } catch (\Exception $e) { |
|
| 268 | - return new DataResponse( |
|
| 269 | - [ |
|
| 270 | - 'data' => [ |
|
| 271 | - 'message' => $e->getMessage() |
|
| 272 | - ], |
|
| 273 | - 'status' => 'failure', |
|
| 274 | - ], |
|
| 275 | - Http::STATUS_UNPROCESSABLE_ENTITY |
|
| 276 | - ); |
|
| 277 | - } |
|
| 264 | + try { |
|
| 265 | + $mime = $this->imageManager->updateImage($key, $image['tmp_name']); |
|
| 266 | + $this->themingDefaults->set($key . 'Mime', $mime); |
|
| 267 | + } catch (\Exception $e) { |
|
| 268 | + return new DataResponse( |
|
| 269 | + [ |
|
| 270 | + 'data' => [ |
|
| 271 | + 'message' => $e->getMessage() |
|
| 272 | + ], |
|
| 273 | + 'status' => 'failure', |
|
| 274 | + ], |
|
| 275 | + Http::STATUS_UNPROCESSABLE_ENTITY |
|
| 276 | + ); |
|
| 277 | + } |
|
| 278 | 278 | |
| 279 | - $name = $image['name']; |
|
| 279 | + $name = $image['name']; |
|
| 280 | 280 | |
| 281 | - return new DataResponse( |
|
| 282 | - [ |
|
| 283 | - 'data' |
|
| 284 | - => [ |
|
| 285 | - 'name' => $name, |
|
| 286 | - 'url' => $this->imageManager->getImageUrl($key), |
|
| 287 | - 'message' => $this->l10n->t('Saved'), |
|
| 288 | - ], |
|
| 289 | - 'status' => 'success' |
|
| 290 | - ] |
|
| 291 | - ); |
|
| 292 | - } |
|
| 281 | + return new DataResponse( |
|
| 282 | + [ |
|
| 283 | + 'data' |
|
| 284 | + => [ |
|
| 285 | + 'name' => $name, |
|
| 286 | + 'url' => $this->imageManager->getImageUrl($key), |
|
| 287 | + 'message' => $this->l10n->t('Saved'), |
|
| 288 | + ], |
|
| 289 | + 'status' => 'success' |
|
| 290 | + ] |
|
| 291 | + ); |
|
| 292 | + } |
|
| 293 | 293 | |
| 294 | - /** |
|
| 295 | - * Revert setting to default value |
|
| 296 | - * |
|
| 297 | - * @param string $setting setting which should be reverted |
|
| 298 | - * @return DataResponse |
|
| 299 | - * @throws NotPermittedException |
|
| 300 | - */ |
|
| 301 | - #[AuthorizedAdminSetting(settings: Admin::class)] |
|
| 302 | - public function undo(string $setting): DataResponse { |
|
| 303 | - $setting = match ($setting) { |
|
| 304 | - 'primaryColor' => 'primary_color', |
|
| 305 | - 'backgroundColor' => 'background_color', |
|
| 306 | - 'legalNoticeUrl' => 'imprintUrl', |
|
| 307 | - 'privacyPolicyUrl' => 'privacyUrl', |
|
| 308 | - default => $setting, |
|
| 309 | - }; |
|
| 310 | - $value = $this->themingDefaults->undo($setting); |
|
| 294 | + /** |
|
| 295 | + * Revert setting to default value |
|
| 296 | + * |
|
| 297 | + * @param string $setting setting which should be reverted |
|
| 298 | + * @return DataResponse |
|
| 299 | + * @throws NotPermittedException |
|
| 300 | + */ |
|
| 301 | + #[AuthorizedAdminSetting(settings: Admin::class)] |
|
| 302 | + public function undo(string $setting): DataResponse { |
|
| 303 | + $setting = match ($setting) { |
|
| 304 | + 'primaryColor' => 'primary_color', |
|
| 305 | + 'backgroundColor' => 'background_color', |
|
| 306 | + 'legalNoticeUrl' => 'imprintUrl', |
|
| 307 | + 'privacyPolicyUrl' => 'privacyUrl', |
|
| 308 | + default => $setting, |
|
| 309 | + }; |
|
| 310 | + $value = $this->themingDefaults->undo($setting); |
|
| 311 | 311 | |
| 312 | - return new DataResponse( |
|
| 313 | - [ |
|
| 314 | - 'data' |
|
| 315 | - => [ |
|
| 316 | - 'value' => $value, |
|
| 317 | - 'message' => $this->l10n->t('Saved'), |
|
| 318 | - ], |
|
| 319 | - 'status' => 'success' |
|
| 320 | - ] |
|
| 321 | - ); |
|
| 322 | - } |
|
| 312 | + return new DataResponse( |
|
| 313 | + [ |
|
| 314 | + 'data' |
|
| 315 | + => [ |
|
| 316 | + 'value' => $value, |
|
| 317 | + 'message' => $this->l10n->t('Saved'), |
|
| 318 | + ], |
|
| 319 | + 'status' => 'success' |
|
| 320 | + ] |
|
| 321 | + ); |
|
| 322 | + } |
|
| 323 | 323 | |
| 324 | - /** |
|
| 325 | - * Revert all theming settings to their default values |
|
| 326 | - * |
|
| 327 | - * @return DataResponse |
|
| 328 | - * @throws NotPermittedException |
|
| 329 | - */ |
|
| 330 | - #[AuthorizedAdminSetting(settings: Admin::class)] |
|
| 331 | - public function undoAll(): DataResponse { |
|
| 332 | - $this->themingDefaults->undoAll(); |
|
| 333 | - $this->navigationManager->setDefaultEntryIds([]); |
|
| 324 | + /** |
|
| 325 | + * Revert all theming settings to their default values |
|
| 326 | + * |
|
| 327 | + * @return DataResponse |
|
| 328 | + * @throws NotPermittedException |
|
| 329 | + */ |
|
| 330 | + #[AuthorizedAdminSetting(settings: Admin::class)] |
|
| 331 | + public function undoAll(): DataResponse { |
|
| 332 | + $this->themingDefaults->undoAll(); |
|
| 333 | + $this->navigationManager->setDefaultEntryIds([]); |
|
| 334 | 334 | |
| 335 | - return new DataResponse( |
|
| 336 | - [ |
|
| 337 | - 'data' |
|
| 338 | - => [ |
|
| 339 | - 'message' => $this->l10n->t('Saved'), |
|
| 340 | - ], |
|
| 341 | - 'status' => 'success' |
|
| 342 | - ] |
|
| 343 | - ); |
|
| 344 | - } |
|
| 335 | + return new DataResponse( |
|
| 336 | + [ |
|
| 337 | + 'data' |
|
| 338 | + => [ |
|
| 339 | + 'message' => $this->l10n->t('Saved'), |
|
| 340 | + ], |
|
| 341 | + 'status' => 'success' |
|
| 342 | + ] |
|
| 343 | + ); |
|
| 344 | + } |
|
| 345 | 345 | |
| 346 | - /** |
|
| 347 | - * @NoSameSiteCookieRequired |
|
| 348 | - * |
|
| 349 | - * Get an image |
|
| 350 | - * |
|
| 351 | - * @param string $key Key of the image |
|
| 352 | - * @param bool $useSvg Return image as SVG |
|
| 353 | - * @return FileDisplayResponse<Http::STATUS_OK, array{}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}> |
|
| 354 | - * @throws NotPermittedException |
|
| 355 | - * |
|
| 356 | - * 200: Image returned |
|
| 357 | - * 404: Image not found |
|
| 358 | - */ |
|
| 359 | - #[PublicPage] |
|
| 360 | - #[NoCSRFRequired] |
|
| 361 | - #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] |
|
| 362 | - public function getImage(string $key, bool $useSvg = true) { |
|
| 363 | - try { |
|
| 364 | - $useSvg = $useSvg && $this->imageManager->canConvert('SVG'); |
|
| 365 | - $file = $this->imageManager->getImage($key, $useSvg); |
|
| 366 | - } catch (NotFoundException $e) { |
|
| 367 | - return new NotFoundResponse(); |
|
| 368 | - } |
|
| 346 | + /** |
|
| 347 | + * @NoSameSiteCookieRequired |
|
| 348 | + * |
|
| 349 | + * Get an image |
|
| 350 | + * |
|
| 351 | + * @param string $key Key of the image |
|
| 352 | + * @param bool $useSvg Return image as SVG |
|
| 353 | + * @return FileDisplayResponse<Http::STATUS_OK, array{}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}> |
|
| 354 | + * @throws NotPermittedException |
|
| 355 | + * |
|
| 356 | + * 200: Image returned |
|
| 357 | + * 404: Image not found |
|
| 358 | + */ |
|
| 359 | + #[PublicPage] |
|
| 360 | + #[NoCSRFRequired] |
|
| 361 | + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] |
|
| 362 | + public function getImage(string $key, bool $useSvg = true) { |
|
| 363 | + try { |
|
| 364 | + $useSvg = $useSvg && $this->imageManager->canConvert('SVG'); |
|
| 365 | + $file = $this->imageManager->getImage($key, $useSvg); |
|
| 366 | + } catch (NotFoundException $e) { |
|
| 367 | + return new NotFoundResponse(); |
|
| 368 | + } |
|
| 369 | 369 | |
| 370 | - $response = new FileDisplayResponse($file); |
|
| 371 | - $csp = new ContentSecurityPolicy(); |
|
| 372 | - $csp->allowInlineStyle(); |
|
| 373 | - $response->setContentSecurityPolicy($csp); |
|
| 374 | - $response->cacheFor(3600); |
|
| 375 | - $response->addHeader('Content-Type', $file->getMimeType()); |
|
| 376 | - $response->addHeader('Content-Disposition', 'attachment; filename="' . $key . '"'); |
|
| 377 | - return $response; |
|
| 378 | - } |
|
| 370 | + $response = new FileDisplayResponse($file); |
|
| 371 | + $csp = new ContentSecurityPolicy(); |
|
| 372 | + $csp->allowInlineStyle(); |
|
| 373 | + $response->setContentSecurityPolicy($csp); |
|
| 374 | + $response->cacheFor(3600); |
|
| 375 | + $response->addHeader('Content-Type', $file->getMimeType()); |
|
| 376 | + $response->addHeader('Content-Disposition', 'attachment; filename="' . $key . '"'); |
|
| 377 | + return $response; |
|
| 378 | + } |
|
| 379 | 379 | |
| 380 | - /** |
|
| 381 | - * Get the CSS stylesheet for a theme |
|
| 382 | - * |
|
| 383 | - * @param string $themeId ID of the theme |
|
| 384 | - * @param bool $plain Let the browser decide the CSS priority |
|
| 385 | - * @param bool $withCustomCss Include custom CSS |
|
| 386 | - * @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'text/css'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}> |
|
| 387 | - * |
|
| 388 | - * 200: Stylesheet returned |
|
| 389 | - * 404: Theme not found |
|
| 390 | - */ |
|
| 391 | - #[PublicPage] |
|
| 392 | - #[NoCSRFRequired] |
|
| 393 | - #[NoTwoFactorRequired] |
|
| 394 | - #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] |
|
| 395 | - #[NoSameSiteCookieRequired] |
|
| 396 | - public function getThemeStylesheet(string $themeId, bool $plain = false, bool $withCustomCss = false) { |
|
| 397 | - $themes = $this->themesService->getThemes(); |
|
| 398 | - if (!in_array($themeId, array_keys($themes))) { |
|
| 399 | - return new NotFoundResponse(); |
|
| 400 | - } |
|
| 380 | + /** |
|
| 381 | + * Get the CSS stylesheet for a theme |
|
| 382 | + * |
|
| 383 | + * @param string $themeId ID of the theme |
|
| 384 | + * @param bool $plain Let the browser decide the CSS priority |
|
| 385 | + * @param bool $withCustomCss Include custom CSS |
|
| 386 | + * @return DataDisplayResponse<Http::STATUS_OK, array{Content-Type: 'text/css'}>|NotFoundResponse<Http::STATUS_NOT_FOUND, array{}> |
|
| 387 | + * |
|
| 388 | + * 200: Stylesheet returned |
|
| 389 | + * 404: Theme not found |
|
| 390 | + */ |
|
| 391 | + #[PublicPage] |
|
| 392 | + #[NoCSRFRequired] |
|
| 393 | + #[NoTwoFactorRequired] |
|
| 394 | + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] |
|
| 395 | + #[NoSameSiteCookieRequired] |
|
| 396 | + public function getThemeStylesheet(string $themeId, bool $plain = false, bool $withCustomCss = false) { |
|
| 397 | + $themes = $this->themesService->getThemes(); |
|
| 398 | + if (!in_array($themeId, array_keys($themes))) { |
|
| 399 | + return new NotFoundResponse(); |
|
| 400 | + } |
|
| 401 | 401 | |
| 402 | - $theme = $themes[$themeId]; |
|
| 403 | - $customCss = $theme->getCustomCss(); |
|
| 402 | + $theme = $themes[$themeId]; |
|
| 403 | + $customCss = $theme->getCustomCss(); |
|
| 404 | 404 | |
| 405 | - // Generate variables |
|
| 406 | - $variables = ''; |
|
| 407 | - foreach ($theme->getCSSVariables() as $variable => $value) { |
|
| 408 | - $variables .= "$variable:$value; "; |
|
| 409 | - }; |
|
| 405 | + // Generate variables |
|
| 406 | + $variables = ''; |
|
| 407 | + foreach ($theme->getCSSVariables() as $variable => $value) { |
|
| 408 | + $variables .= "$variable:$value; "; |
|
| 409 | + }; |
|
| 410 | 410 | |
| 411 | - // If plain is set, the browser decides of the css priority |
|
| 412 | - if ($plain) { |
|
| 413 | - $css = ":root { $variables } " . $customCss; |
|
| 414 | - } else { |
|
| 415 | - // If not set, we'll rely on the body class |
|
| 416 | - // We need to separate @-rules from normal selectors, as they can't be nested |
|
| 417 | - // This is a replacement for the SCSS compiler that did this automatically before f1448fcf0777db7d4254cb0a3ef94d63be9f7a24 |
|
| 418 | - // We need a better way to handle this, but for now we just remove comments and split the at-rules |
|
| 419 | - // from the rest of the CSS. |
|
| 420 | - $customCssWithoutComments = preg_replace('!/\*.*?\*/!s', '', $customCss); |
|
| 421 | - $customCssWithoutComments = preg_replace('!//.*!', '', $customCssWithoutComments); |
|
| 422 | - preg_match_all('/(@[^{]+{(?:[^{}]*|(?R))*})/', $customCssWithoutComments, $atRules); |
|
| 423 | - $atRulesCss = implode('', $atRules[0]); |
|
| 424 | - $scopedCss = preg_replace('/(@[^{]+{(?:[^{}]*|(?R))*})/', '', $customCssWithoutComments); |
|
| 411 | + // If plain is set, the browser decides of the css priority |
|
| 412 | + if ($plain) { |
|
| 413 | + $css = ":root { $variables } " . $customCss; |
|
| 414 | + } else { |
|
| 415 | + // If not set, we'll rely on the body class |
|
| 416 | + // We need to separate @-rules from normal selectors, as they can't be nested |
|
| 417 | + // This is a replacement for the SCSS compiler that did this automatically before f1448fcf0777db7d4254cb0a3ef94d63be9f7a24 |
|
| 418 | + // We need a better way to handle this, but for now we just remove comments and split the at-rules |
|
| 419 | + // from the rest of the CSS. |
|
| 420 | + $customCssWithoutComments = preg_replace('!/\*.*?\*/!s', '', $customCss); |
|
| 421 | + $customCssWithoutComments = preg_replace('!//.*!', '', $customCssWithoutComments); |
|
| 422 | + preg_match_all('/(@[^{]+{(?:[^{}]*|(?R))*})/', $customCssWithoutComments, $atRules); |
|
| 423 | + $atRulesCss = implode('', $atRules[0]); |
|
| 424 | + $scopedCss = preg_replace('/(@[^{]+{(?:[^{}]*|(?R))*})/', '', $customCssWithoutComments); |
|
| 425 | 425 | |
| 426 | - $css = "$atRulesCss [data-theme-$themeId] { $variables $scopedCss }"; |
|
| 427 | - } |
|
| 426 | + $css = "$atRulesCss [data-theme-$themeId] { $variables $scopedCss }"; |
|
| 427 | + } |
|
| 428 | 428 | |
| 429 | - try { |
|
| 430 | - $response = new DataDisplayResponse($css, Http::STATUS_OK, ['Content-Type' => 'text/css']); |
|
| 431 | - $response->cacheFor(86400); |
|
| 432 | - return $response; |
|
| 433 | - } catch (NotFoundException $e) { |
|
| 434 | - return new NotFoundResponse(); |
|
| 435 | - } |
|
| 436 | - } |
|
| 429 | + try { |
|
| 430 | + $response = new DataDisplayResponse($css, Http::STATUS_OK, ['Content-Type' => 'text/css']); |
|
| 431 | + $response->cacheFor(86400); |
|
| 432 | + return $response; |
|
| 433 | + } catch (NotFoundException $e) { |
|
| 434 | + return new NotFoundResponse(); |
|
| 435 | + } |
|
| 436 | + } |
|
| 437 | 437 | |
| 438 | - /** |
|
| 439 | - * Get the manifest for an app |
|
| 440 | - * |
|
| 441 | - * @param string $app ID of the app |
|
| 442 | - * @psalm-suppress LessSpecificReturnStatement The content of the Manifest doesn't need to be described in the return type |
|
| 443 | - * @return JSONResponse<Http::STATUS_OK, array{name: string, short_name: string, start_url: string, theme_color: string, background_color: string, description: string, icons: list<array{src: non-empty-string, type: string, sizes: string}>, display_override: list<string>, display: string}, array{}>|JSONResponse<Http::STATUS_NOT_FOUND, list<empty>, array{}> |
|
| 444 | - * |
|
| 445 | - * 200: Manifest returned |
|
| 446 | - * 404: App not found |
|
| 447 | - */ |
|
| 448 | - #[PublicPage] |
|
| 449 | - #[NoCSRFRequired] |
|
| 450 | - #[BruteForceProtection(action: 'manifest')] |
|
| 451 | - #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] |
|
| 452 | - public function getManifest(string $app): JSONResponse { |
|
| 453 | - $cacheBusterValue = $this->config->getAppValue('theming', 'cachebuster', '0'); |
|
| 454 | - if ($app === 'core' || $app === 'settings') { |
|
| 455 | - $name = $this->themingDefaults->getName(); |
|
| 456 | - $shortName = $this->themingDefaults->getName(); |
|
| 457 | - $startUrl = $this->urlGenerator->getBaseUrl(); |
|
| 458 | - $description = $this->themingDefaults->getSlogan(); |
|
| 459 | - } else { |
|
| 460 | - if (!$this->appManager->isEnabledForUser($app)) { |
|
| 461 | - $response = new JSONResponse([], Http::STATUS_NOT_FOUND); |
|
| 462 | - $response->throttle(['action' => 'manifest', 'app' => $app]); |
|
| 463 | - return $response; |
|
| 464 | - } |
|
| 438 | + /** |
|
| 439 | + * Get the manifest for an app |
|
| 440 | + * |
|
| 441 | + * @param string $app ID of the app |
|
| 442 | + * @psalm-suppress LessSpecificReturnStatement The content of the Manifest doesn't need to be described in the return type |
|
| 443 | + * @return JSONResponse<Http::STATUS_OK, array{name: string, short_name: string, start_url: string, theme_color: string, background_color: string, description: string, icons: list<array{src: non-empty-string, type: string, sizes: string}>, display_override: list<string>, display: string}, array{}>|JSONResponse<Http::STATUS_NOT_FOUND, list<empty>, array{}> |
|
| 444 | + * |
|
| 445 | + * 200: Manifest returned |
|
| 446 | + * 404: App not found |
|
| 447 | + */ |
|
| 448 | + #[PublicPage] |
|
| 449 | + #[NoCSRFRequired] |
|
| 450 | + #[BruteForceProtection(action: 'manifest')] |
|
| 451 | + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] |
|
| 452 | + public function getManifest(string $app): JSONResponse { |
|
| 453 | + $cacheBusterValue = $this->config->getAppValue('theming', 'cachebuster', '0'); |
|
| 454 | + if ($app === 'core' || $app === 'settings') { |
|
| 455 | + $name = $this->themingDefaults->getName(); |
|
| 456 | + $shortName = $this->themingDefaults->getName(); |
|
| 457 | + $startUrl = $this->urlGenerator->getBaseUrl(); |
|
| 458 | + $description = $this->themingDefaults->getSlogan(); |
|
| 459 | + } else { |
|
| 460 | + if (!$this->appManager->isEnabledForUser($app)) { |
|
| 461 | + $response = new JSONResponse([], Http::STATUS_NOT_FOUND); |
|
| 462 | + $response->throttle(['action' => 'manifest', 'app' => $app]); |
|
| 463 | + return $response; |
|
| 464 | + } |
|
| 465 | 465 | |
| 466 | - $info = $this->appManager->getAppInfo($app, false, $this->l10n->getLanguageCode()); |
|
| 467 | - $name = $info['name'] . ' - ' . $this->themingDefaults->getName(); |
|
| 468 | - $shortName = $info['name']; |
|
| 469 | - if (str_contains($this->request->getRequestUri(), '/index.php/')) { |
|
| 470 | - $startUrl = $this->urlGenerator->getBaseUrl() . '/index.php/apps/' . $app . '/'; |
|
| 471 | - } else { |
|
| 472 | - $startUrl = $this->urlGenerator->getBaseUrl() . '/apps/' . $app . '/'; |
|
| 473 | - } |
|
| 474 | - $description = $info['summary'] ?? ''; |
|
| 475 | - } |
|
| 476 | - /** |
|
| 477 | - * @var string $description |
|
| 478 | - * @var string $shortName |
|
| 479 | - */ |
|
| 480 | - $responseJS = [ |
|
| 481 | - 'name' => $name, |
|
| 482 | - 'short_name' => $shortName, |
|
| 483 | - 'start_url' => $startUrl, |
|
| 484 | - 'theme_color' => $this->themingDefaults->getColorPrimary(), |
|
| 485 | - 'background_color' => $this->themingDefaults->getColorPrimary(), |
|
| 486 | - 'description' => $description, |
|
| 487 | - 'icons' |
|
| 488 | - => [ |
|
| 489 | - [ |
|
| 490 | - 'src' => $this->urlGenerator->linkToRoute('theming.Icon.getTouchIcon', |
|
| 491 | - ['app' => $app]) . '?v=' . $cacheBusterValue, |
|
| 492 | - 'type' => 'image/png', |
|
| 493 | - 'sizes' => '512x512' |
|
| 494 | - ], |
|
| 495 | - [ |
|
| 496 | - 'src' => $this->urlGenerator->linkToRoute('theming.Icon.getFavicon', |
|
| 497 | - ['app' => $app]) . '?v=' . $cacheBusterValue, |
|
| 498 | - 'type' => 'image/svg+xml', |
|
| 499 | - 'sizes' => '16x16' |
|
| 500 | - ] |
|
| 501 | - ], |
|
| 502 | - 'display_override' => [$this->config->getSystemValueBool('theming.standalone_window.enabled', true) ? 'minimal-ui' : ''], |
|
| 503 | - 'display' => $this->config->getSystemValueBool('theming.standalone_window.enabled', true) ? 'standalone' : 'browser' |
|
| 504 | - ]; |
|
| 505 | - $response = new JSONResponse($responseJS); |
|
| 506 | - $response->cacheFor(3600); |
|
| 507 | - return $response; |
|
| 508 | - } |
|
| 466 | + $info = $this->appManager->getAppInfo($app, false, $this->l10n->getLanguageCode()); |
|
| 467 | + $name = $info['name'] . ' - ' . $this->themingDefaults->getName(); |
|
| 468 | + $shortName = $info['name']; |
|
| 469 | + if (str_contains($this->request->getRequestUri(), '/index.php/')) { |
|
| 470 | + $startUrl = $this->urlGenerator->getBaseUrl() . '/index.php/apps/' . $app . '/'; |
|
| 471 | + } else { |
|
| 472 | + $startUrl = $this->urlGenerator->getBaseUrl() . '/apps/' . $app . '/'; |
|
| 473 | + } |
|
| 474 | + $description = $info['summary'] ?? ''; |
|
| 475 | + } |
|
| 476 | + /** |
|
| 477 | + * @var string $description |
|
| 478 | + * @var string $shortName |
|
| 479 | + */ |
|
| 480 | + $responseJS = [ |
|
| 481 | + 'name' => $name, |
|
| 482 | + 'short_name' => $shortName, |
|
| 483 | + 'start_url' => $startUrl, |
|
| 484 | + 'theme_color' => $this->themingDefaults->getColorPrimary(), |
|
| 485 | + 'background_color' => $this->themingDefaults->getColorPrimary(), |
|
| 486 | + 'description' => $description, |
|
| 487 | + 'icons' |
|
| 488 | + => [ |
|
| 489 | + [ |
|
| 490 | + 'src' => $this->urlGenerator->linkToRoute('theming.Icon.getTouchIcon', |
|
| 491 | + ['app' => $app]) . '?v=' . $cacheBusterValue, |
|
| 492 | + 'type' => 'image/png', |
|
| 493 | + 'sizes' => '512x512' |
|
| 494 | + ], |
|
| 495 | + [ |
|
| 496 | + 'src' => $this->urlGenerator->linkToRoute('theming.Icon.getFavicon', |
|
| 497 | + ['app' => $app]) . '?v=' . $cacheBusterValue, |
|
| 498 | + 'type' => 'image/svg+xml', |
|
| 499 | + 'sizes' => '16x16' |
|
| 500 | + ] |
|
| 501 | + ], |
|
| 502 | + 'display_override' => [$this->config->getSystemValueBool('theming.standalone_window.enabled', true) ? 'minimal-ui' : ''], |
|
| 503 | + 'display' => $this->config->getSystemValueBool('theming.standalone_window.enabled', true) ? 'standalone' : 'browser' |
|
| 504 | + ]; |
|
| 505 | + $response = new JSONResponse($responseJS); |
|
| 506 | + $response->cacheFor(3600); |
|
| 507 | + return $response; |
|
| 508 | + } |
|
| 509 | 509 | } |
@@ -51,353 +51,353 @@ |
||
| 51 | 51 | */ |
| 52 | 52 | #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] |
| 53 | 53 | class ShareController extends AuthPublicShareController { |
| 54 | - protected ?IShare $share = null; |
|
| 55 | - |
|
| 56 | - public const SHARE_ACCESS = 'access'; |
|
| 57 | - public const SHARE_AUTH = 'auth'; |
|
| 58 | - public const SHARE_DOWNLOAD = 'download'; |
|
| 59 | - |
|
| 60 | - public function __construct( |
|
| 61 | - string $appName, |
|
| 62 | - IRequest $request, |
|
| 63 | - protected IConfig $config, |
|
| 64 | - IURLGenerator $urlGenerator, |
|
| 65 | - protected IUserManager $userManager, |
|
| 66 | - protected \OCP\Activity\IManager $activityManager, |
|
| 67 | - protected ShareManager $shareManager, |
|
| 68 | - ISession $session, |
|
| 69 | - protected IPreview $previewManager, |
|
| 70 | - protected IRootFolder $rootFolder, |
|
| 71 | - protected FederatedShareProvider $federatedShareProvider, |
|
| 72 | - protected IAccountManager $accountManager, |
|
| 73 | - protected IEventDispatcher $eventDispatcher, |
|
| 74 | - protected IL10N $l10n, |
|
| 75 | - protected ISecureRandom $secureRandom, |
|
| 76 | - protected Defaults $defaults, |
|
| 77 | - private IPublicShareTemplateFactory $publicShareTemplateFactory, |
|
| 78 | - ) { |
|
| 79 | - parent::__construct($appName, $request, $session, $urlGenerator); |
|
| 80 | - } |
|
| 81 | - |
|
| 82 | - /** |
|
| 83 | - * Show the authentication page |
|
| 84 | - * The form has to submit to the authenticate method route |
|
| 85 | - */ |
|
| 86 | - #[PublicPage] |
|
| 87 | - #[NoCSRFRequired] |
|
| 88 | - public function showAuthenticate(): TemplateResponse { |
|
| 89 | - $templateParameters = ['share' => $this->share]; |
|
| 90 | - |
|
| 91 | - $this->eventDispatcher->dispatchTyped(new BeforeTemplateRenderedEvent($this->share, BeforeTemplateRenderedEvent::SCOPE_PUBLIC_SHARE_AUTH)); |
|
| 92 | - |
|
| 93 | - $response = new TemplateResponse('core', 'publicshareauth', $templateParameters, 'guest'); |
|
| 94 | - if ($this->share->getSendPasswordByTalk()) { |
|
| 95 | - $csp = new ContentSecurityPolicy(); |
|
| 96 | - $csp->addAllowedConnectDomain('*'); |
|
| 97 | - $csp->addAllowedMediaDomain('blob:'); |
|
| 98 | - $response->setContentSecurityPolicy($csp); |
|
| 99 | - } |
|
| 100 | - |
|
| 101 | - return $response; |
|
| 102 | - } |
|
| 103 | - |
|
| 104 | - /** |
|
| 105 | - * The template to show when authentication failed |
|
| 106 | - */ |
|
| 107 | - protected function showAuthFailed(): TemplateResponse { |
|
| 108 | - $templateParameters = ['share' => $this->share, 'wrongpw' => true]; |
|
| 109 | - |
|
| 110 | - $this->eventDispatcher->dispatchTyped(new BeforeTemplateRenderedEvent($this->share, BeforeTemplateRenderedEvent::SCOPE_PUBLIC_SHARE_AUTH)); |
|
| 111 | - |
|
| 112 | - $response = new TemplateResponse('core', 'publicshareauth', $templateParameters, 'guest'); |
|
| 113 | - if ($this->share->getSendPasswordByTalk()) { |
|
| 114 | - $csp = new ContentSecurityPolicy(); |
|
| 115 | - $csp->addAllowedConnectDomain('*'); |
|
| 116 | - $csp->addAllowedMediaDomain('blob:'); |
|
| 117 | - $response->setContentSecurityPolicy($csp); |
|
| 118 | - } |
|
| 119 | - |
|
| 120 | - return $response; |
|
| 121 | - } |
|
| 122 | - |
|
| 123 | - /** |
|
| 124 | - * The template to show after user identification |
|
| 125 | - */ |
|
| 126 | - protected function showIdentificationResult(bool $success = false): TemplateResponse { |
|
| 127 | - $templateParameters = ['share' => $this->share, 'identityOk' => $success]; |
|
| 128 | - |
|
| 129 | - $this->eventDispatcher->dispatchTyped(new BeforeTemplateRenderedEvent($this->share, BeforeTemplateRenderedEvent::SCOPE_PUBLIC_SHARE_AUTH)); |
|
| 130 | - |
|
| 131 | - $response = new TemplateResponse('core', 'publicshareauth', $templateParameters, 'guest'); |
|
| 132 | - if ($this->share->getSendPasswordByTalk()) { |
|
| 133 | - $csp = new ContentSecurityPolicy(); |
|
| 134 | - $csp->addAllowedConnectDomain('*'); |
|
| 135 | - $csp->addAllowedMediaDomain('blob:'); |
|
| 136 | - $response->setContentSecurityPolicy($csp); |
|
| 137 | - } |
|
| 138 | - |
|
| 139 | - return $response; |
|
| 140 | - } |
|
| 141 | - |
|
| 142 | - /** |
|
| 143 | - * Validate the identity token of a public share |
|
| 144 | - * |
|
| 145 | - * @param ?string $identityToken |
|
| 146 | - * @return bool |
|
| 147 | - */ |
|
| 148 | - protected function validateIdentity(?string $identityToken = null): bool { |
|
| 149 | - if ($this->share->getShareType() !== IShare::TYPE_EMAIL) { |
|
| 150 | - return false; |
|
| 151 | - } |
|
| 152 | - |
|
| 153 | - if ($identityToken === null || $this->share->getSharedWith() === null) { |
|
| 154 | - return false; |
|
| 155 | - } |
|
| 156 | - |
|
| 157 | - return $identityToken === $this->share->getSharedWith(); |
|
| 158 | - } |
|
| 159 | - |
|
| 160 | - /** |
|
| 161 | - * Generates a password for the share, respecting any password policy defined |
|
| 162 | - */ |
|
| 163 | - protected function generatePassword(): void { |
|
| 164 | - $event = new GenerateSecurePasswordEvent(PasswordContext::SHARING); |
|
| 165 | - $this->eventDispatcher->dispatchTyped($event); |
|
| 166 | - $password = $event->getPassword() ?? $this->secureRandom->generate(20); |
|
| 167 | - |
|
| 168 | - $this->share->setPassword($password); |
|
| 169 | - $this->shareManager->updateShare($this->share); |
|
| 170 | - } |
|
| 171 | - |
|
| 172 | - protected function verifyPassword(string $password): bool { |
|
| 173 | - return $this->shareManager->checkPassword($this->share, $password); |
|
| 174 | - } |
|
| 175 | - |
|
| 176 | - protected function getPasswordHash(): ?string { |
|
| 177 | - return $this->share->getPassword(); |
|
| 178 | - } |
|
| 179 | - |
|
| 180 | - public function isValidToken(): bool { |
|
| 181 | - try { |
|
| 182 | - $this->share = $this->shareManager->getShareByToken($this->getToken()); |
|
| 183 | - } catch (ShareNotFound $e) { |
|
| 184 | - return false; |
|
| 185 | - } |
|
| 186 | - |
|
| 187 | - return true; |
|
| 188 | - } |
|
| 189 | - |
|
| 190 | - protected function isPasswordProtected(): bool { |
|
| 191 | - return $this->share->getPassword() !== null; |
|
| 192 | - } |
|
| 193 | - |
|
| 194 | - protected function authSucceeded() { |
|
| 195 | - if ($this->share === null) { |
|
| 196 | - throw new NotFoundException(); |
|
| 197 | - } |
|
| 198 | - |
|
| 199 | - // For share this was always set so it is still used in other apps |
|
| 200 | - $allowedShareIds = $this->session->get(PublicAuth::DAV_AUTHENTICATED); |
|
| 201 | - if (!is_array($allowedShareIds)) { |
|
| 202 | - $allowedShareIds = []; |
|
| 203 | - } |
|
| 204 | - |
|
| 205 | - $this->session->set(PublicAuth::DAV_AUTHENTICATED, array_merge($allowedShareIds, [$this->share->getId()])); |
|
| 206 | - } |
|
| 207 | - |
|
| 208 | - protected function authFailed() { |
|
| 209 | - $this->emitAccessShareHook($this->share, 403, 'Wrong password'); |
|
| 210 | - $this->emitShareAccessEvent($this->share, self::SHARE_AUTH, 403, 'Wrong password'); |
|
| 211 | - } |
|
| 212 | - |
|
| 213 | - /** |
|
| 214 | - * throws hooks when a share is attempted to be accessed |
|
| 215 | - * |
|
| 216 | - * @param IShare|string $share the Share instance if available, |
|
| 217 | - * otherwise token |
|
| 218 | - * @param int $errorCode |
|
| 219 | - * @param string $errorMessage |
|
| 220 | - * |
|
| 221 | - * @throws HintException |
|
| 222 | - * @throws \OC\ServerNotAvailableException |
|
| 223 | - * |
|
| 224 | - * @deprecated use OCP\Files_Sharing\Event\ShareLinkAccessedEvent |
|
| 225 | - */ |
|
| 226 | - protected function emitAccessShareHook($share, int $errorCode = 200, string $errorMessage = '') { |
|
| 227 | - $itemType = $itemSource = $uidOwner = ''; |
|
| 228 | - $token = $share; |
|
| 229 | - $exception = null; |
|
| 230 | - if ($share instanceof IShare) { |
|
| 231 | - try { |
|
| 232 | - $token = $share->getToken(); |
|
| 233 | - $uidOwner = $share->getSharedBy(); |
|
| 234 | - $itemType = $share->getNodeType(); |
|
| 235 | - $itemSource = $share->getNodeId(); |
|
| 236 | - } catch (\Exception $e) { |
|
| 237 | - // we log what we know and pass on the exception afterwards |
|
| 238 | - $exception = $e; |
|
| 239 | - } |
|
| 240 | - } |
|
| 241 | - |
|
| 242 | - \OC_Hook::emit(Share::class, 'share_link_access', [ |
|
| 243 | - 'itemType' => $itemType, |
|
| 244 | - 'itemSource' => $itemSource, |
|
| 245 | - 'uidOwner' => $uidOwner, |
|
| 246 | - 'token' => $token, |
|
| 247 | - 'errorCode' => $errorCode, |
|
| 248 | - 'errorMessage' => $errorMessage |
|
| 249 | - ]); |
|
| 250 | - |
|
| 251 | - if (!is_null($exception)) { |
|
| 252 | - throw $exception; |
|
| 253 | - } |
|
| 254 | - } |
|
| 255 | - |
|
| 256 | - /** |
|
| 257 | - * Emit a ShareLinkAccessedEvent event when a share is accessed, downloaded, auth... |
|
| 258 | - */ |
|
| 259 | - protected function emitShareAccessEvent(IShare $share, string $step = '', int $errorCode = 200, string $errorMessage = ''): void { |
|
| 260 | - if ($step !== self::SHARE_ACCESS |
|
| 261 | - && $step !== self::SHARE_AUTH |
|
| 262 | - && $step !== self::SHARE_DOWNLOAD) { |
|
| 263 | - return; |
|
| 264 | - } |
|
| 265 | - $this->eventDispatcher->dispatchTyped(new ShareLinkAccessedEvent($share, $step, $errorCode, $errorMessage)); |
|
| 266 | - } |
|
| 267 | - |
|
| 268 | - /** |
|
| 269 | - * Validate the permissions of the share |
|
| 270 | - * |
|
| 271 | - * @param Share\IShare $share |
|
| 272 | - * @return bool |
|
| 273 | - */ |
|
| 274 | - private function validateShare(IShare $share) { |
|
| 275 | - // If the owner is disabled no access to the link is granted |
|
| 276 | - $owner = $this->userManager->get($share->getShareOwner()); |
|
| 277 | - if ($owner === null || !$owner->isEnabled()) { |
|
| 278 | - return false; |
|
| 279 | - } |
|
| 280 | - |
|
| 281 | - // If the initiator of the share is disabled no access is granted |
|
| 282 | - $initiator = $this->userManager->get($share->getSharedBy()); |
|
| 283 | - if ($initiator === null || !$initiator->isEnabled()) { |
|
| 284 | - return false; |
|
| 285 | - } |
|
| 286 | - |
|
| 287 | - return $share->getNode()->isReadable() && $share->getNode()->isShareable(); |
|
| 288 | - } |
|
| 289 | - |
|
| 290 | - /** |
|
| 291 | - * @param string $path |
|
| 292 | - * @return TemplateResponse |
|
| 293 | - * @throws NotFoundException |
|
| 294 | - * @throws \Exception |
|
| 295 | - */ |
|
| 296 | - #[PublicPage] |
|
| 297 | - #[NoCSRFRequired] |
|
| 298 | - public function showShare($path = ''): TemplateResponse { |
|
| 299 | - \OC_User::setIncognitoMode(true); |
|
| 300 | - |
|
| 301 | - // Check whether share exists |
|
| 302 | - try { |
|
| 303 | - $share = $this->shareManager->getShareByToken($this->getToken()); |
|
| 304 | - } catch (ShareNotFound $e) { |
|
| 305 | - // The share does not exists, we do not emit an ShareLinkAccessedEvent |
|
| 306 | - $this->emitAccessShareHook($this->getToken(), 404, 'Share not found'); |
|
| 307 | - throw new NotFoundException($this->l10n->t('This share does not exist or is no longer available')); |
|
| 308 | - } |
|
| 309 | - |
|
| 310 | - if (!$this->validateShare($share)) { |
|
| 311 | - throw new NotFoundException($this->l10n->t('This share does not exist or is no longer available')); |
|
| 312 | - } |
|
| 313 | - |
|
| 314 | - $shareNode = $share->getNode(); |
|
| 315 | - |
|
| 316 | - try { |
|
| 317 | - $templateProvider = $this->publicShareTemplateFactory->getProvider($share); |
|
| 318 | - $response = $templateProvider->renderPage($share, $this->getToken(), $path); |
|
| 319 | - } catch (NotFoundException $e) { |
|
| 320 | - $this->emitAccessShareHook($share, 404, 'Share not found'); |
|
| 321 | - $this->emitShareAccessEvent($share, ShareController::SHARE_ACCESS, 404, 'Share not found'); |
|
| 322 | - throw new NotFoundException($this->l10n->t('This share does not exist or is no longer available')); |
|
| 323 | - } |
|
| 324 | - |
|
| 325 | - // We can't get the path of a file share |
|
| 326 | - try { |
|
| 327 | - if ($shareNode instanceof File && $path !== '') { |
|
| 328 | - $this->emitAccessShareHook($share, 404, 'Share not found'); |
|
| 329 | - $this->emitShareAccessEvent($share, self::SHARE_ACCESS, 404, 'Share not found'); |
|
| 330 | - throw new NotFoundException($this->l10n->t('This share does not exist or is no longer available')); |
|
| 331 | - } |
|
| 332 | - } catch (\Exception $e) { |
|
| 333 | - $this->emitAccessShareHook($share, 404, 'Share not found'); |
|
| 334 | - $this->emitShareAccessEvent($share, self::SHARE_ACCESS, 404, 'Share not found'); |
|
| 335 | - throw $e; |
|
| 336 | - } |
|
| 337 | - |
|
| 338 | - |
|
| 339 | - $this->emitAccessShareHook($share); |
|
| 340 | - $this->emitShareAccessEvent($share, self::SHARE_ACCESS); |
|
| 341 | - |
|
| 342 | - return $response; |
|
| 343 | - } |
|
| 344 | - |
|
| 345 | - /** |
|
| 346 | - * @throws NotFoundException |
|
| 347 | - * @deprecated 31.0.0 Users are encouraged to use the DAV endpoint |
|
| 348 | - */ |
|
| 349 | - #[PublicPage] |
|
| 350 | - #[NoCSRFRequired] |
|
| 351 | - #[NoSameSiteCookieRequired] |
|
| 352 | - public function downloadShare(string $token, ?string $files = null, string $path = ''): NotFoundResponse|RedirectResponse|DataResponse { |
|
| 353 | - \OC_User::setIncognitoMode(true); |
|
| 354 | - |
|
| 355 | - $share = $this->shareManager->getShareByToken($token); |
|
| 356 | - |
|
| 357 | - if (!($share->getPermissions() & Constants::PERMISSION_READ)) { |
|
| 358 | - return new DataResponse('Share has no read permission'); |
|
| 359 | - } |
|
| 360 | - |
|
| 361 | - $attributes = $share->getAttributes(); |
|
| 362 | - if ($attributes?->getAttribute('permissions', 'download') === false) { |
|
| 363 | - return new DataResponse('Share has no download permission'); |
|
| 364 | - } |
|
| 365 | - |
|
| 366 | - if (!$this->validateShare($share)) { |
|
| 367 | - throw new NotFoundException(); |
|
| 368 | - } |
|
| 369 | - |
|
| 370 | - $node = $share->getNode(); |
|
| 371 | - if ($node instanceof Folder) { |
|
| 372 | - // Directory share |
|
| 373 | - |
|
| 374 | - // Try to get the path |
|
| 375 | - if ($path !== '') { |
|
| 376 | - try { |
|
| 377 | - $node = $node->get($path); |
|
| 378 | - } catch (NotFoundException $e) { |
|
| 379 | - $this->emitAccessShareHook($share, 404, 'Share not found'); |
|
| 380 | - $this->emitShareAccessEvent($share, self::SHARE_DOWNLOAD, 404, 'Share not found'); |
|
| 381 | - return new NotFoundResponse(); |
|
| 382 | - } |
|
| 383 | - } |
|
| 384 | - |
|
| 385 | - if ($node instanceof Folder) { |
|
| 386 | - if ($files === null || $files === '') { |
|
| 387 | - if ($share->getHideDownload()) { |
|
| 388 | - throw new NotFoundException('Downloading a folder'); |
|
| 389 | - } |
|
| 390 | - } |
|
| 391 | - } |
|
| 392 | - } |
|
| 393 | - |
|
| 394 | - $this->emitAccessShareHook($share); |
|
| 395 | - $this->emitShareAccessEvent($share, self::SHARE_DOWNLOAD); |
|
| 396 | - |
|
| 397 | - $davUrl = '/public.php/dav/files/' . $token . '/?accept=zip'; |
|
| 398 | - if ($files !== null) { |
|
| 399 | - $davUrl .= '&files=' . $files; |
|
| 400 | - } |
|
| 401 | - return new RedirectResponse($this->urlGenerator->getAbsoluteURL($davUrl)); |
|
| 402 | - } |
|
| 54 | + protected ?IShare $share = null; |
|
| 55 | + |
|
| 56 | + public const SHARE_ACCESS = 'access'; |
|
| 57 | + public const SHARE_AUTH = 'auth'; |
|
| 58 | + public const SHARE_DOWNLOAD = 'download'; |
|
| 59 | + |
|
| 60 | + public function __construct( |
|
| 61 | + string $appName, |
|
| 62 | + IRequest $request, |
|
| 63 | + protected IConfig $config, |
|
| 64 | + IURLGenerator $urlGenerator, |
|
| 65 | + protected IUserManager $userManager, |
|
| 66 | + protected \OCP\Activity\IManager $activityManager, |
|
| 67 | + protected ShareManager $shareManager, |
|
| 68 | + ISession $session, |
|
| 69 | + protected IPreview $previewManager, |
|
| 70 | + protected IRootFolder $rootFolder, |
|
| 71 | + protected FederatedShareProvider $federatedShareProvider, |
|
| 72 | + protected IAccountManager $accountManager, |
|
| 73 | + protected IEventDispatcher $eventDispatcher, |
|
| 74 | + protected IL10N $l10n, |
|
| 75 | + protected ISecureRandom $secureRandom, |
|
| 76 | + protected Defaults $defaults, |
|
| 77 | + private IPublicShareTemplateFactory $publicShareTemplateFactory, |
|
| 78 | + ) { |
|
| 79 | + parent::__construct($appName, $request, $session, $urlGenerator); |
|
| 80 | + } |
|
| 81 | + |
|
| 82 | + /** |
|
| 83 | + * Show the authentication page |
|
| 84 | + * The form has to submit to the authenticate method route |
|
| 85 | + */ |
|
| 86 | + #[PublicPage] |
|
| 87 | + #[NoCSRFRequired] |
|
| 88 | + public function showAuthenticate(): TemplateResponse { |
|
| 89 | + $templateParameters = ['share' => $this->share]; |
|
| 90 | + |
|
| 91 | + $this->eventDispatcher->dispatchTyped(new BeforeTemplateRenderedEvent($this->share, BeforeTemplateRenderedEvent::SCOPE_PUBLIC_SHARE_AUTH)); |
|
| 92 | + |
|
| 93 | + $response = new TemplateResponse('core', 'publicshareauth', $templateParameters, 'guest'); |
|
| 94 | + if ($this->share->getSendPasswordByTalk()) { |
|
| 95 | + $csp = new ContentSecurityPolicy(); |
|
| 96 | + $csp->addAllowedConnectDomain('*'); |
|
| 97 | + $csp->addAllowedMediaDomain('blob:'); |
|
| 98 | + $response->setContentSecurityPolicy($csp); |
|
| 99 | + } |
|
| 100 | + |
|
| 101 | + return $response; |
|
| 102 | + } |
|
| 103 | + |
|
| 104 | + /** |
|
| 105 | + * The template to show when authentication failed |
|
| 106 | + */ |
|
| 107 | + protected function showAuthFailed(): TemplateResponse { |
|
| 108 | + $templateParameters = ['share' => $this->share, 'wrongpw' => true]; |
|
| 109 | + |
|
| 110 | + $this->eventDispatcher->dispatchTyped(new BeforeTemplateRenderedEvent($this->share, BeforeTemplateRenderedEvent::SCOPE_PUBLIC_SHARE_AUTH)); |
|
| 111 | + |
|
| 112 | + $response = new TemplateResponse('core', 'publicshareauth', $templateParameters, 'guest'); |
|
| 113 | + if ($this->share->getSendPasswordByTalk()) { |
|
| 114 | + $csp = new ContentSecurityPolicy(); |
|
| 115 | + $csp->addAllowedConnectDomain('*'); |
|
| 116 | + $csp->addAllowedMediaDomain('blob:'); |
|
| 117 | + $response->setContentSecurityPolicy($csp); |
|
| 118 | + } |
|
| 119 | + |
|
| 120 | + return $response; |
|
| 121 | + } |
|
| 122 | + |
|
| 123 | + /** |
|
| 124 | + * The template to show after user identification |
|
| 125 | + */ |
|
| 126 | + protected function showIdentificationResult(bool $success = false): TemplateResponse { |
|
| 127 | + $templateParameters = ['share' => $this->share, 'identityOk' => $success]; |
|
| 128 | + |
|
| 129 | + $this->eventDispatcher->dispatchTyped(new BeforeTemplateRenderedEvent($this->share, BeforeTemplateRenderedEvent::SCOPE_PUBLIC_SHARE_AUTH)); |
|
| 130 | + |
|
| 131 | + $response = new TemplateResponse('core', 'publicshareauth', $templateParameters, 'guest'); |
|
| 132 | + if ($this->share->getSendPasswordByTalk()) { |
|
| 133 | + $csp = new ContentSecurityPolicy(); |
|
| 134 | + $csp->addAllowedConnectDomain('*'); |
|
| 135 | + $csp->addAllowedMediaDomain('blob:'); |
|
| 136 | + $response->setContentSecurityPolicy($csp); |
|
| 137 | + } |
|
| 138 | + |
|
| 139 | + return $response; |
|
| 140 | + } |
|
| 141 | + |
|
| 142 | + /** |
|
| 143 | + * Validate the identity token of a public share |
|
| 144 | + * |
|
| 145 | + * @param ?string $identityToken |
|
| 146 | + * @return bool |
|
| 147 | + */ |
|
| 148 | + protected function validateIdentity(?string $identityToken = null): bool { |
|
| 149 | + if ($this->share->getShareType() !== IShare::TYPE_EMAIL) { |
|
| 150 | + return false; |
|
| 151 | + } |
|
| 152 | + |
|
| 153 | + if ($identityToken === null || $this->share->getSharedWith() === null) { |
|
| 154 | + return false; |
|
| 155 | + } |
|
| 156 | + |
|
| 157 | + return $identityToken === $this->share->getSharedWith(); |
|
| 158 | + } |
|
| 159 | + |
|
| 160 | + /** |
|
| 161 | + * Generates a password for the share, respecting any password policy defined |
|
| 162 | + */ |
|
| 163 | + protected function generatePassword(): void { |
|
| 164 | + $event = new GenerateSecurePasswordEvent(PasswordContext::SHARING); |
|
| 165 | + $this->eventDispatcher->dispatchTyped($event); |
|
| 166 | + $password = $event->getPassword() ?? $this->secureRandom->generate(20); |
|
| 167 | + |
|
| 168 | + $this->share->setPassword($password); |
|
| 169 | + $this->shareManager->updateShare($this->share); |
|
| 170 | + } |
|
| 171 | + |
|
| 172 | + protected function verifyPassword(string $password): bool { |
|
| 173 | + return $this->shareManager->checkPassword($this->share, $password); |
|
| 174 | + } |
|
| 175 | + |
|
| 176 | + protected function getPasswordHash(): ?string { |
|
| 177 | + return $this->share->getPassword(); |
|
| 178 | + } |
|
| 179 | + |
|
| 180 | + public function isValidToken(): bool { |
|
| 181 | + try { |
|
| 182 | + $this->share = $this->shareManager->getShareByToken($this->getToken()); |
|
| 183 | + } catch (ShareNotFound $e) { |
|
| 184 | + return false; |
|
| 185 | + } |
|
| 186 | + |
|
| 187 | + return true; |
|
| 188 | + } |
|
| 189 | + |
|
| 190 | + protected function isPasswordProtected(): bool { |
|
| 191 | + return $this->share->getPassword() !== null; |
|
| 192 | + } |
|
| 193 | + |
|
| 194 | + protected function authSucceeded() { |
|
| 195 | + if ($this->share === null) { |
|
| 196 | + throw new NotFoundException(); |
|
| 197 | + } |
|
| 198 | + |
|
| 199 | + // For share this was always set so it is still used in other apps |
|
| 200 | + $allowedShareIds = $this->session->get(PublicAuth::DAV_AUTHENTICATED); |
|
| 201 | + if (!is_array($allowedShareIds)) { |
|
| 202 | + $allowedShareIds = []; |
|
| 203 | + } |
|
| 204 | + |
|
| 205 | + $this->session->set(PublicAuth::DAV_AUTHENTICATED, array_merge($allowedShareIds, [$this->share->getId()])); |
|
| 206 | + } |
|
| 207 | + |
|
| 208 | + protected function authFailed() { |
|
| 209 | + $this->emitAccessShareHook($this->share, 403, 'Wrong password'); |
|
| 210 | + $this->emitShareAccessEvent($this->share, self::SHARE_AUTH, 403, 'Wrong password'); |
|
| 211 | + } |
|
| 212 | + |
|
| 213 | + /** |
|
| 214 | + * throws hooks when a share is attempted to be accessed |
|
| 215 | + * |
|
| 216 | + * @param IShare|string $share the Share instance if available, |
|
| 217 | + * otherwise token |
|
| 218 | + * @param int $errorCode |
|
| 219 | + * @param string $errorMessage |
|
| 220 | + * |
|
| 221 | + * @throws HintException |
|
| 222 | + * @throws \OC\ServerNotAvailableException |
|
| 223 | + * |
|
| 224 | + * @deprecated use OCP\Files_Sharing\Event\ShareLinkAccessedEvent |
|
| 225 | + */ |
|
| 226 | + protected function emitAccessShareHook($share, int $errorCode = 200, string $errorMessage = '') { |
|
| 227 | + $itemType = $itemSource = $uidOwner = ''; |
|
| 228 | + $token = $share; |
|
| 229 | + $exception = null; |
|
| 230 | + if ($share instanceof IShare) { |
|
| 231 | + try { |
|
| 232 | + $token = $share->getToken(); |
|
| 233 | + $uidOwner = $share->getSharedBy(); |
|
| 234 | + $itemType = $share->getNodeType(); |
|
| 235 | + $itemSource = $share->getNodeId(); |
|
| 236 | + } catch (\Exception $e) { |
|
| 237 | + // we log what we know and pass on the exception afterwards |
|
| 238 | + $exception = $e; |
|
| 239 | + } |
|
| 240 | + } |
|
| 241 | + |
|
| 242 | + \OC_Hook::emit(Share::class, 'share_link_access', [ |
|
| 243 | + 'itemType' => $itemType, |
|
| 244 | + 'itemSource' => $itemSource, |
|
| 245 | + 'uidOwner' => $uidOwner, |
|
| 246 | + 'token' => $token, |
|
| 247 | + 'errorCode' => $errorCode, |
|
| 248 | + 'errorMessage' => $errorMessage |
|
| 249 | + ]); |
|
| 250 | + |
|
| 251 | + if (!is_null($exception)) { |
|
| 252 | + throw $exception; |
|
| 253 | + } |
|
| 254 | + } |
|
| 255 | + |
|
| 256 | + /** |
|
| 257 | + * Emit a ShareLinkAccessedEvent event when a share is accessed, downloaded, auth... |
|
| 258 | + */ |
|
| 259 | + protected function emitShareAccessEvent(IShare $share, string $step = '', int $errorCode = 200, string $errorMessage = ''): void { |
|
| 260 | + if ($step !== self::SHARE_ACCESS |
|
| 261 | + && $step !== self::SHARE_AUTH |
|
| 262 | + && $step !== self::SHARE_DOWNLOAD) { |
|
| 263 | + return; |
|
| 264 | + } |
|
| 265 | + $this->eventDispatcher->dispatchTyped(new ShareLinkAccessedEvent($share, $step, $errorCode, $errorMessage)); |
|
| 266 | + } |
|
| 267 | + |
|
| 268 | + /** |
|
| 269 | + * Validate the permissions of the share |
|
| 270 | + * |
|
| 271 | + * @param Share\IShare $share |
|
| 272 | + * @return bool |
|
| 273 | + */ |
|
| 274 | + private function validateShare(IShare $share) { |
|
| 275 | + // If the owner is disabled no access to the link is granted |
|
| 276 | + $owner = $this->userManager->get($share->getShareOwner()); |
|
| 277 | + if ($owner === null || !$owner->isEnabled()) { |
|
| 278 | + return false; |
|
| 279 | + } |
|
| 280 | + |
|
| 281 | + // If the initiator of the share is disabled no access is granted |
|
| 282 | + $initiator = $this->userManager->get($share->getSharedBy()); |
|
| 283 | + if ($initiator === null || !$initiator->isEnabled()) { |
|
| 284 | + return false; |
|
| 285 | + } |
|
| 286 | + |
|
| 287 | + return $share->getNode()->isReadable() && $share->getNode()->isShareable(); |
|
| 288 | + } |
|
| 289 | + |
|
| 290 | + /** |
|
| 291 | + * @param string $path |
|
| 292 | + * @return TemplateResponse |
|
| 293 | + * @throws NotFoundException |
|
| 294 | + * @throws \Exception |
|
| 295 | + */ |
|
| 296 | + #[PublicPage] |
|
| 297 | + #[NoCSRFRequired] |
|
| 298 | + public function showShare($path = ''): TemplateResponse { |
|
| 299 | + \OC_User::setIncognitoMode(true); |
|
| 300 | + |
|
| 301 | + // Check whether share exists |
|
| 302 | + try { |
|
| 303 | + $share = $this->shareManager->getShareByToken($this->getToken()); |
|
| 304 | + } catch (ShareNotFound $e) { |
|
| 305 | + // The share does not exists, we do not emit an ShareLinkAccessedEvent |
|
| 306 | + $this->emitAccessShareHook($this->getToken(), 404, 'Share not found'); |
|
| 307 | + throw new NotFoundException($this->l10n->t('This share does not exist or is no longer available')); |
|
| 308 | + } |
|
| 309 | + |
|
| 310 | + if (!$this->validateShare($share)) { |
|
| 311 | + throw new NotFoundException($this->l10n->t('This share does not exist or is no longer available')); |
|
| 312 | + } |
|
| 313 | + |
|
| 314 | + $shareNode = $share->getNode(); |
|
| 315 | + |
|
| 316 | + try { |
|
| 317 | + $templateProvider = $this->publicShareTemplateFactory->getProvider($share); |
|
| 318 | + $response = $templateProvider->renderPage($share, $this->getToken(), $path); |
|
| 319 | + } catch (NotFoundException $e) { |
|
| 320 | + $this->emitAccessShareHook($share, 404, 'Share not found'); |
|
| 321 | + $this->emitShareAccessEvent($share, ShareController::SHARE_ACCESS, 404, 'Share not found'); |
|
| 322 | + throw new NotFoundException($this->l10n->t('This share does not exist or is no longer available')); |
|
| 323 | + } |
|
| 324 | + |
|
| 325 | + // We can't get the path of a file share |
|
| 326 | + try { |
|
| 327 | + if ($shareNode instanceof File && $path !== '') { |
|
| 328 | + $this->emitAccessShareHook($share, 404, 'Share not found'); |
|
| 329 | + $this->emitShareAccessEvent($share, self::SHARE_ACCESS, 404, 'Share not found'); |
|
| 330 | + throw new NotFoundException($this->l10n->t('This share does not exist or is no longer available')); |
|
| 331 | + } |
|
| 332 | + } catch (\Exception $e) { |
|
| 333 | + $this->emitAccessShareHook($share, 404, 'Share not found'); |
|
| 334 | + $this->emitShareAccessEvent($share, self::SHARE_ACCESS, 404, 'Share not found'); |
|
| 335 | + throw $e; |
|
| 336 | + } |
|
| 337 | + |
|
| 338 | + |
|
| 339 | + $this->emitAccessShareHook($share); |
|
| 340 | + $this->emitShareAccessEvent($share, self::SHARE_ACCESS); |
|
| 341 | + |
|
| 342 | + return $response; |
|
| 343 | + } |
|
| 344 | + |
|
| 345 | + /** |
|
| 346 | + * @throws NotFoundException |
|
| 347 | + * @deprecated 31.0.0 Users are encouraged to use the DAV endpoint |
|
| 348 | + */ |
|
| 349 | + #[PublicPage] |
|
| 350 | + #[NoCSRFRequired] |
|
| 351 | + #[NoSameSiteCookieRequired] |
|
| 352 | + public function downloadShare(string $token, ?string $files = null, string $path = ''): NotFoundResponse|RedirectResponse|DataResponse { |
|
| 353 | + \OC_User::setIncognitoMode(true); |
|
| 354 | + |
|
| 355 | + $share = $this->shareManager->getShareByToken($token); |
|
| 356 | + |
|
| 357 | + if (!($share->getPermissions() & Constants::PERMISSION_READ)) { |
|
| 358 | + return new DataResponse('Share has no read permission'); |
|
| 359 | + } |
|
| 360 | + |
|
| 361 | + $attributes = $share->getAttributes(); |
|
| 362 | + if ($attributes?->getAttribute('permissions', 'download') === false) { |
|
| 363 | + return new DataResponse('Share has no download permission'); |
|
| 364 | + } |
|
| 365 | + |
|
| 366 | + if (!$this->validateShare($share)) { |
|
| 367 | + throw new NotFoundException(); |
|
| 368 | + } |
|
| 369 | + |
|
| 370 | + $node = $share->getNode(); |
|
| 371 | + if ($node instanceof Folder) { |
|
| 372 | + // Directory share |
|
| 373 | + |
|
| 374 | + // Try to get the path |
|
| 375 | + if ($path !== '') { |
|
| 376 | + try { |
|
| 377 | + $node = $node->get($path); |
|
| 378 | + } catch (NotFoundException $e) { |
|
| 379 | + $this->emitAccessShareHook($share, 404, 'Share not found'); |
|
| 380 | + $this->emitShareAccessEvent($share, self::SHARE_DOWNLOAD, 404, 'Share not found'); |
|
| 381 | + return new NotFoundResponse(); |
|
| 382 | + } |
|
| 383 | + } |
|
| 384 | + |
|
| 385 | + if ($node instanceof Folder) { |
|
| 386 | + if ($files === null || $files === '') { |
|
| 387 | + if ($share->getHideDownload()) { |
|
| 388 | + throw new NotFoundException('Downloading a folder'); |
|
| 389 | + } |
|
| 390 | + } |
|
| 391 | + } |
|
| 392 | + } |
|
| 393 | + |
|
| 394 | + $this->emitAccessShareHook($share); |
|
| 395 | + $this->emitShareAccessEvent($share, self::SHARE_DOWNLOAD); |
|
| 396 | + |
|
| 397 | + $davUrl = '/public.php/dav/files/' . $token . '/?accept=zip'; |
|
| 398 | + if ($files !== null) { |
|
| 399 | + $davUrl .= '&files=' . $files; |
|
| 400 | + } |
|
| 401 | + return new RedirectResponse($this->urlGenerator->getAbsoluteURL($davUrl)); |
|
| 402 | + } |
|
| 403 | 403 | } |
@@ -349,7 +349,7 @@ discard block |
||
| 349 | 349 | #[PublicPage] |
| 350 | 350 | #[NoCSRFRequired] |
| 351 | 351 | #[NoSameSiteCookieRequired] |
| 352 | - public function downloadShare(string $token, ?string $files = null, string $path = ''): NotFoundResponse|RedirectResponse|DataResponse { |
|
| 352 | + public function downloadShare(string $token, ?string $files = null, string $path = ''): NotFoundResponse | RedirectResponse | DataResponse { |
|
| 353 | 353 | \OC_User::setIncognitoMode(true); |
| 354 | 354 | |
| 355 | 355 | $share = $this->shareManager->getShareByToken($token); |
@@ -394,9 +394,9 @@ discard block |
||
| 394 | 394 | $this->emitAccessShareHook($share); |
| 395 | 395 | $this->emitShareAccessEvent($share, self::SHARE_DOWNLOAD); |
| 396 | 396 | |
| 397 | - $davUrl = '/public.php/dav/files/' . $token . '/?accept=zip'; |
|
| 397 | + $davUrl = '/public.php/dav/files/'.$token.'/?accept=zip'; |
|
| 398 | 398 | if ($files !== null) { |
| 399 | - $davUrl .= '&files=' . $files; |
|
| 399 | + $davUrl .= '&files='.$files; |
|
| 400 | 400 | } |
| 401 | 401 | return new RedirectResponse($this->urlGenerator->getAbsoluteURL($davUrl)); |
| 402 | 402 | } |
@@ -28,179 +28,179 @@ |
||
| 28 | 28 | |
| 29 | 29 | class PublicPreviewController extends PublicShareController { |
| 30 | 30 | |
| 31 | - /** @var IShare */ |
|
| 32 | - private $share; |
|
| 33 | - |
|
| 34 | - public function __construct( |
|
| 35 | - string $appName, |
|
| 36 | - IRequest $request, |
|
| 37 | - private ShareManager $shareManager, |
|
| 38 | - ISession $session, |
|
| 39 | - private IPreview $previewManager, |
|
| 40 | - private IMimeIconProvider $mimeIconProvider, |
|
| 41 | - ) { |
|
| 42 | - parent::__construct($appName, $request, $session); |
|
| 43 | - } |
|
| 44 | - |
|
| 45 | - protected function getPasswordHash(): ?string { |
|
| 46 | - return $this->share->getPassword(); |
|
| 47 | - } |
|
| 48 | - |
|
| 49 | - public function isValidToken(): bool { |
|
| 50 | - try { |
|
| 51 | - $this->share = $this->shareManager->getShareByToken($this->getToken()); |
|
| 52 | - return true; |
|
| 53 | - } catch (ShareNotFound $e) { |
|
| 54 | - return false; |
|
| 55 | - } |
|
| 56 | - } |
|
| 57 | - |
|
| 58 | - protected function isPasswordProtected(): bool { |
|
| 59 | - return $this->share->getPassword() !== null; |
|
| 60 | - } |
|
| 61 | - |
|
| 62 | - |
|
| 63 | - /** |
|
| 64 | - * Get a preview for a shared file |
|
| 65 | - * |
|
| 66 | - * @param string $token Token of the share |
|
| 67 | - * @param string $file File in the share |
|
| 68 | - * @param int $x Width of the preview |
|
| 69 | - * @param int $y Height of the preview |
|
| 70 | - * @param bool $a Whether to not crop the preview |
|
| 71 | - * @param bool $mimeFallback Whether to fallback to the mime icon if no preview is available |
|
| 72 | - * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, array{}>|RedirectResponse<Http::STATUS_SEE_OTHER, array{}> |
|
| 73 | - * |
|
| 74 | - * 200: Preview returned |
|
| 75 | - * 303: Redirect to the mime icon url if mimeFallback is true |
|
| 76 | - * 400: Getting preview is not possible |
|
| 77 | - * 403: Getting preview is not allowed |
|
| 78 | - * 404: Share or preview not found |
|
| 79 | - */ |
|
| 80 | - #[PublicPage] |
|
| 81 | - #[NoCSRFRequired] |
|
| 82 | - #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] |
|
| 83 | - public function getPreview( |
|
| 84 | - string $token, |
|
| 85 | - string $file = '', |
|
| 86 | - int $x = 32, |
|
| 87 | - int $y = 32, |
|
| 88 | - $a = false, |
|
| 89 | - bool $mimeFallback = false, |
|
| 90 | - ) { |
|
| 91 | - $cacheForSeconds = 60 * 60 * 24; // 1 day |
|
| 92 | - |
|
| 93 | - if ($token === '' || $x === 0 || $y === 0) { |
|
| 94 | - return new DataResponse([], Http::STATUS_BAD_REQUEST); |
|
| 95 | - } |
|
| 96 | - |
|
| 97 | - try { |
|
| 98 | - $share = $this->shareManager->getShareByToken($token); |
|
| 99 | - } catch (ShareNotFound $e) { |
|
| 100 | - return new DataResponse([], Http::STATUS_NOT_FOUND); |
|
| 101 | - } |
|
| 102 | - |
|
| 103 | - if (($share->getPermissions() & Constants::PERMISSION_READ) === 0) { |
|
| 104 | - return new DataResponse([], Http::STATUS_FORBIDDEN); |
|
| 105 | - } |
|
| 106 | - |
|
| 107 | - // Only explicitly set to false will forbid the download! |
|
| 108 | - $downloadForbidden = !$share->canSeeContent(); |
|
| 109 | - |
|
| 110 | - // Is this header is set it means our UI is doing a preview for no-download shares |
|
| 111 | - // we check a header so we at least prevent people from using the link directly (obfuscation) |
|
| 112 | - $isPublicPreview = $this->request->getHeader('x-nc-preview') === 'true'; |
|
| 113 | - |
|
| 114 | - if ($isPublicPreview && $downloadForbidden) { |
|
| 115 | - // Only cache for 15 minutes on public preview requests to quickly remove from cache |
|
| 116 | - $cacheForSeconds = 15 * 60; |
|
| 117 | - } elseif ($downloadForbidden) { |
|
| 118 | - // This is not a public share preview so we only allow a preview if download permissions are granted |
|
| 119 | - return new DataResponse([], Http::STATUS_FORBIDDEN); |
|
| 120 | - } |
|
| 121 | - |
|
| 122 | - try { |
|
| 123 | - $node = $share->getNode(); |
|
| 124 | - if ($node instanceof Folder) { |
|
| 125 | - $file = $node->get($file); |
|
| 126 | - } else { |
|
| 127 | - $file = $node; |
|
| 128 | - } |
|
| 129 | - |
|
| 130 | - $f = $this->previewManager->getPreview($file, $x, $y, !$a); |
|
| 131 | - $response = new FileDisplayResponse($f, Http::STATUS_OK, ['Content-Type' => $f->getMimeType()]); |
|
| 132 | - $response->cacheFor($cacheForSeconds); |
|
| 133 | - return $response; |
|
| 134 | - } catch (NotFoundException $e) { |
|
| 135 | - // If we have no preview enabled, we can redirect to the mime icon if any |
|
| 136 | - if ($mimeFallback) { |
|
| 137 | - if ($url = $this->mimeIconProvider->getMimeIconUrl($file->getMimeType())) { |
|
| 138 | - return new RedirectResponse($url); |
|
| 139 | - } |
|
| 140 | - } |
|
| 141 | - return new DataResponse([], Http::STATUS_NOT_FOUND); |
|
| 142 | - } catch (\InvalidArgumentException $e) { |
|
| 143 | - return new DataResponse([], Http::STATUS_BAD_REQUEST); |
|
| 144 | - } |
|
| 145 | - } |
|
| 146 | - |
|
| 147 | - /** |
|
| 148 | - * Get a direct link preview for a shared file |
|
| 149 | - * |
|
| 150 | - * @param string $token Token of the share |
|
| 151 | - * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, array{}> |
|
| 152 | - * |
|
| 153 | - * 200: Preview returned |
|
| 154 | - * 400: Getting preview is not possible |
|
| 155 | - * 403: Getting preview is not allowed |
|
| 156 | - * 404: Share or preview not found |
|
| 157 | - */ |
|
| 158 | - #[PublicPage] |
|
| 159 | - #[NoCSRFRequired] |
|
| 160 | - #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] |
|
| 161 | - #[NoSameSiteCookieRequired] |
|
| 162 | - public function directLink(string $token) { |
|
| 163 | - // No token no image |
|
| 164 | - if ($token === '') { |
|
| 165 | - return new DataResponse([], Http::STATUS_BAD_REQUEST); |
|
| 166 | - } |
|
| 167 | - |
|
| 168 | - // No share no image |
|
| 169 | - try { |
|
| 170 | - $share = $this->shareManager->getShareByToken($token); |
|
| 171 | - } catch (ShareNotFound $e) { |
|
| 172 | - return new DataResponse([], Http::STATUS_NOT_FOUND); |
|
| 173 | - } |
|
| 174 | - |
|
| 175 | - // No permissions no image |
|
| 176 | - if (($share->getPermissions() & Constants::PERMISSION_READ) === 0) { |
|
| 177 | - return new DataResponse([], Http::STATUS_FORBIDDEN); |
|
| 178 | - } |
|
| 179 | - |
|
| 180 | - // Password protected shares have no direct link! |
|
| 181 | - if ($share->getPassword() !== null) { |
|
| 182 | - return new DataResponse([], Http::STATUS_FORBIDDEN); |
|
| 183 | - } |
|
| 184 | - |
|
| 185 | - if (!$share->canSeeContent()) { |
|
| 186 | - return new DataResponse([], Http::STATUS_FORBIDDEN); |
|
| 187 | - } |
|
| 188 | - |
|
| 189 | - try { |
|
| 190 | - $node = $share->getNode(); |
|
| 191 | - if ($node instanceof Folder) { |
|
| 192 | - // Direct link only works for single files |
|
| 193 | - return new DataResponse([], Http::STATUS_BAD_REQUEST); |
|
| 194 | - } |
|
| 195 | - |
|
| 196 | - $f = $this->previewManager->getPreview($node, -1, -1, false); |
|
| 197 | - $response = new FileDisplayResponse($f, Http::STATUS_OK, ['Content-Type' => $f->getMimeType()]); |
|
| 198 | - $response->cacheFor(3600 * 24); |
|
| 199 | - return $response; |
|
| 200 | - } catch (NotFoundException $e) { |
|
| 201 | - return new DataResponse([], Http::STATUS_NOT_FOUND); |
|
| 202 | - } catch (\InvalidArgumentException $e) { |
|
| 203 | - return new DataResponse([], Http::STATUS_BAD_REQUEST); |
|
| 204 | - } |
|
| 205 | - } |
|
| 31 | + /** @var IShare */ |
|
| 32 | + private $share; |
|
| 33 | + |
|
| 34 | + public function __construct( |
|
| 35 | + string $appName, |
|
| 36 | + IRequest $request, |
|
| 37 | + private ShareManager $shareManager, |
|
| 38 | + ISession $session, |
|
| 39 | + private IPreview $previewManager, |
|
| 40 | + private IMimeIconProvider $mimeIconProvider, |
|
| 41 | + ) { |
|
| 42 | + parent::__construct($appName, $request, $session); |
|
| 43 | + } |
|
| 44 | + |
|
| 45 | + protected function getPasswordHash(): ?string { |
|
| 46 | + return $this->share->getPassword(); |
|
| 47 | + } |
|
| 48 | + |
|
| 49 | + public function isValidToken(): bool { |
|
| 50 | + try { |
|
| 51 | + $this->share = $this->shareManager->getShareByToken($this->getToken()); |
|
| 52 | + return true; |
|
| 53 | + } catch (ShareNotFound $e) { |
|
| 54 | + return false; |
|
| 55 | + } |
|
| 56 | + } |
|
| 57 | + |
|
| 58 | + protected function isPasswordProtected(): bool { |
|
| 59 | + return $this->share->getPassword() !== null; |
|
| 60 | + } |
|
| 61 | + |
|
| 62 | + |
|
| 63 | + /** |
|
| 64 | + * Get a preview for a shared file |
|
| 65 | + * |
|
| 66 | + * @param string $token Token of the share |
|
| 67 | + * @param string $file File in the share |
|
| 68 | + * @param int $x Width of the preview |
|
| 69 | + * @param int $y Height of the preview |
|
| 70 | + * @param bool $a Whether to not crop the preview |
|
| 71 | + * @param bool $mimeFallback Whether to fallback to the mime icon if no preview is available |
|
| 72 | + * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, array{}>|RedirectResponse<Http::STATUS_SEE_OTHER, array{}> |
|
| 73 | + * |
|
| 74 | + * 200: Preview returned |
|
| 75 | + * 303: Redirect to the mime icon url if mimeFallback is true |
|
| 76 | + * 400: Getting preview is not possible |
|
| 77 | + * 403: Getting preview is not allowed |
|
| 78 | + * 404: Share or preview not found |
|
| 79 | + */ |
|
| 80 | + #[PublicPage] |
|
| 81 | + #[NoCSRFRequired] |
|
| 82 | + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] |
|
| 83 | + public function getPreview( |
|
| 84 | + string $token, |
|
| 85 | + string $file = '', |
|
| 86 | + int $x = 32, |
|
| 87 | + int $y = 32, |
|
| 88 | + $a = false, |
|
| 89 | + bool $mimeFallback = false, |
|
| 90 | + ) { |
|
| 91 | + $cacheForSeconds = 60 * 60 * 24; // 1 day |
|
| 92 | + |
|
| 93 | + if ($token === '' || $x === 0 || $y === 0) { |
|
| 94 | + return new DataResponse([], Http::STATUS_BAD_REQUEST); |
|
| 95 | + } |
|
| 96 | + |
|
| 97 | + try { |
|
| 98 | + $share = $this->shareManager->getShareByToken($token); |
|
| 99 | + } catch (ShareNotFound $e) { |
|
| 100 | + return new DataResponse([], Http::STATUS_NOT_FOUND); |
|
| 101 | + } |
|
| 102 | + |
|
| 103 | + if (($share->getPermissions() & Constants::PERMISSION_READ) === 0) { |
|
| 104 | + return new DataResponse([], Http::STATUS_FORBIDDEN); |
|
| 105 | + } |
|
| 106 | + |
|
| 107 | + // Only explicitly set to false will forbid the download! |
|
| 108 | + $downloadForbidden = !$share->canSeeContent(); |
|
| 109 | + |
|
| 110 | + // Is this header is set it means our UI is doing a preview for no-download shares |
|
| 111 | + // we check a header so we at least prevent people from using the link directly (obfuscation) |
|
| 112 | + $isPublicPreview = $this->request->getHeader('x-nc-preview') === 'true'; |
|
| 113 | + |
|
| 114 | + if ($isPublicPreview && $downloadForbidden) { |
|
| 115 | + // Only cache for 15 minutes on public preview requests to quickly remove from cache |
|
| 116 | + $cacheForSeconds = 15 * 60; |
|
| 117 | + } elseif ($downloadForbidden) { |
|
| 118 | + // This is not a public share preview so we only allow a preview if download permissions are granted |
|
| 119 | + return new DataResponse([], Http::STATUS_FORBIDDEN); |
|
| 120 | + } |
|
| 121 | + |
|
| 122 | + try { |
|
| 123 | + $node = $share->getNode(); |
|
| 124 | + if ($node instanceof Folder) { |
|
| 125 | + $file = $node->get($file); |
|
| 126 | + } else { |
|
| 127 | + $file = $node; |
|
| 128 | + } |
|
| 129 | + |
|
| 130 | + $f = $this->previewManager->getPreview($file, $x, $y, !$a); |
|
| 131 | + $response = new FileDisplayResponse($f, Http::STATUS_OK, ['Content-Type' => $f->getMimeType()]); |
|
| 132 | + $response->cacheFor($cacheForSeconds); |
|
| 133 | + return $response; |
|
| 134 | + } catch (NotFoundException $e) { |
|
| 135 | + // If we have no preview enabled, we can redirect to the mime icon if any |
|
| 136 | + if ($mimeFallback) { |
|
| 137 | + if ($url = $this->mimeIconProvider->getMimeIconUrl($file->getMimeType())) { |
|
| 138 | + return new RedirectResponse($url); |
|
| 139 | + } |
|
| 140 | + } |
|
| 141 | + return new DataResponse([], Http::STATUS_NOT_FOUND); |
|
| 142 | + } catch (\InvalidArgumentException $e) { |
|
| 143 | + return new DataResponse([], Http::STATUS_BAD_REQUEST); |
|
| 144 | + } |
|
| 145 | + } |
|
| 146 | + |
|
| 147 | + /** |
|
| 148 | + * Get a direct link preview for a shared file |
|
| 149 | + * |
|
| 150 | + * @param string $token Token of the share |
|
| 151 | + * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, array{}> |
|
| 152 | + * |
|
| 153 | + * 200: Preview returned |
|
| 154 | + * 400: Getting preview is not possible |
|
| 155 | + * 403: Getting preview is not allowed |
|
| 156 | + * 404: Share or preview not found |
|
| 157 | + */ |
|
| 158 | + #[PublicPage] |
|
| 159 | + #[NoCSRFRequired] |
|
| 160 | + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] |
|
| 161 | + #[NoSameSiteCookieRequired] |
|
| 162 | + public function directLink(string $token) { |
|
| 163 | + // No token no image |
|
| 164 | + if ($token === '') { |
|
| 165 | + return new DataResponse([], Http::STATUS_BAD_REQUEST); |
|
| 166 | + } |
|
| 167 | + |
|
| 168 | + // No share no image |
|
| 169 | + try { |
|
| 170 | + $share = $this->shareManager->getShareByToken($token); |
|
| 171 | + } catch (ShareNotFound $e) { |
|
| 172 | + return new DataResponse([], Http::STATUS_NOT_FOUND); |
|
| 173 | + } |
|
| 174 | + |
|
| 175 | + // No permissions no image |
|
| 176 | + if (($share->getPermissions() & Constants::PERMISSION_READ) === 0) { |
|
| 177 | + return new DataResponse([], Http::STATUS_FORBIDDEN); |
|
| 178 | + } |
|
| 179 | + |
|
| 180 | + // Password protected shares have no direct link! |
|
| 181 | + if ($share->getPassword() !== null) { |
|
| 182 | + return new DataResponse([], Http::STATUS_FORBIDDEN); |
|
| 183 | + } |
|
| 184 | + |
|
| 185 | + if (!$share->canSeeContent()) { |
|
| 186 | + return new DataResponse([], Http::STATUS_FORBIDDEN); |
|
| 187 | + } |
|
| 188 | + |
|
| 189 | + try { |
|
| 190 | + $node = $share->getNode(); |
|
| 191 | + if ($node instanceof Folder) { |
|
| 192 | + // Direct link only works for single files |
|
| 193 | + return new DataResponse([], Http::STATUS_BAD_REQUEST); |
|
| 194 | + } |
|
| 195 | + |
|
| 196 | + $f = $this->previewManager->getPreview($node, -1, -1, false); |
|
| 197 | + $response = new FileDisplayResponse($f, Http::STATUS_OK, ['Content-Type' => $f->getMimeType()]); |
|
| 198 | + $response->cacheFor(3600 * 24); |
|
| 199 | + return $response; |
|
| 200 | + } catch (NotFoundException $e) { |
|
| 201 | + return new DataResponse([], Http::STATUS_NOT_FOUND); |
|
| 202 | + } catch (\InvalidArgumentException $e) { |
|
| 203 | + return new DataResponse([], Http::STATUS_BAD_REQUEST); |
|
| 204 | + } |
|
| 205 | + } |
|
| 206 | 206 | } |
@@ -26,184 +26,184 @@ |
||
| 26 | 26 | * Class to dispatch the request to the middleware dispatcher |
| 27 | 27 | */ |
| 28 | 28 | class Dispatcher { |
| 29 | - /** |
|
| 30 | - * @param Http $protocol the http protocol with contains all status headers |
|
| 31 | - * @param MiddlewareDispatcher $middlewareDispatcher the dispatcher which |
|
| 32 | - * runs the middleware |
|
| 33 | - */ |
|
| 34 | - public function __construct( |
|
| 35 | - private readonly Http $protocol, |
|
| 36 | - private readonly MiddlewareDispatcher $middlewareDispatcher, |
|
| 37 | - private readonly ControllerMethodReflector $reflector, |
|
| 38 | - private readonly IRequest $request, |
|
| 39 | - private readonly IConfig $config, |
|
| 40 | - private readonly ConnectionAdapter $connection, |
|
| 41 | - private readonly LoggerInterface $logger, |
|
| 42 | - private readonly IEventLogger $eventLogger, |
|
| 43 | - private readonly ContainerInterface $appContainer, |
|
| 44 | - ) { |
|
| 45 | - } |
|
| 46 | - |
|
| 47 | - |
|
| 48 | - /** |
|
| 49 | - * Handles a request and calls the dispatcher on the controller |
|
| 50 | - * @param Controller $controller the controller which will be called |
|
| 51 | - * @param string $methodName the method name which will be called on |
|
| 52 | - * the controller |
|
| 53 | - * @return array{0: string, 1: array, 2: array, 3: string, 4: Response} |
|
| 54 | - * $array[0] contains the http status header as a string, |
|
| 55 | - * $array[1] contains response headers as an array, |
|
| 56 | - * $array[2] contains response cookies as an array, |
|
| 57 | - * $array[3] contains the response output as a string, |
|
| 58 | - * $array[4] contains the response object |
|
| 59 | - * @throws \Exception |
|
| 60 | - */ |
|
| 61 | - public function dispatch(Controller $controller, string $methodName): array { |
|
| 62 | - try { |
|
| 63 | - // prefill reflector with everything that's needed for the |
|
| 64 | - // middlewares |
|
| 65 | - $this->reflector->reflect($controller, $methodName); |
|
| 66 | - |
|
| 67 | - $this->middlewareDispatcher->beforeController($controller, |
|
| 68 | - $methodName); |
|
| 69 | - |
|
| 70 | - $databaseStatsBefore = []; |
|
| 71 | - if ($this->config->getSystemValueBool('debug', false)) { |
|
| 72 | - $databaseStatsBefore = $this->connection->getInner()->getStats(); |
|
| 73 | - } |
|
| 74 | - |
|
| 75 | - $response = $this->executeController($controller, $methodName); |
|
| 76 | - |
|
| 77 | - if (!empty($databaseStatsBefore)) { |
|
| 78 | - $databaseStatsAfter = $this->connection->getInner()->getStats(); |
|
| 79 | - $numBuilt = $databaseStatsAfter['built'] - $databaseStatsBefore['built']; |
|
| 80 | - $numExecuted = $databaseStatsAfter['executed'] - $databaseStatsBefore['executed']; |
|
| 81 | - |
|
| 82 | - if ($numBuilt > 50) { |
|
| 83 | - $this->logger->debug('Controller {class}::{method} created {count} QueryBuilder objects, please check if they are created inside a loop by accident.', [ |
|
| 84 | - 'class' => get_class($controller), |
|
| 85 | - 'method' => $methodName, |
|
| 86 | - 'count' => $numBuilt, |
|
| 87 | - ]); |
|
| 88 | - } |
|
| 89 | - |
|
| 90 | - if ($numExecuted > 100) { |
|
| 91 | - $this->logger->warning('Controller {class}::{method} executed {count} queries.', [ |
|
| 92 | - 'class' => get_class($controller), |
|
| 93 | - 'method' => $methodName, |
|
| 94 | - 'count' => $numExecuted, |
|
| 95 | - ]); |
|
| 96 | - } |
|
| 97 | - } |
|
| 98 | - |
|
| 99 | - // if an exception appears, the middleware checks if it can handle the |
|
| 100 | - // exception and creates a response. If no response is created, it is |
|
| 101 | - // assumed that there's no middleware who can handle it and the error is |
|
| 102 | - // thrown again |
|
| 103 | - } catch (\Exception $exception) { |
|
| 104 | - $response = $this->middlewareDispatcher->afterException( |
|
| 105 | - $controller, $methodName, $exception); |
|
| 106 | - } catch (\Throwable $throwable) { |
|
| 107 | - $exception = new \Exception($throwable->getMessage() . ' in file \'' . $throwable->getFile() . '\' line ' . $throwable->getLine(), $throwable->getCode(), $throwable); |
|
| 108 | - $response = $this->middlewareDispatcher->afterException( |
|
| 109 | - $controller, $methodName, $exception); |
|
| 110 | - } |
|
| 111 | - |
|
| 112 | - $response = $this->middlewareDispatcher->afterController( |
|
| 113 | - $controller, $methodName, $response); |
|
| 114 | - |
|
| 115 | - // depending on the cache object the headers need to be changed |
|
| 116 | - return [ |
|
| 117 | - $this->protocol->getStatusHeader($response->getStatus()), |
|
| 118 | - array_merge($response->getHeaders()), |
|
| 119 | - $response->getCookies(), |
|
| 120 | - $this->middlewareDispatcher->beforeOutput( |
|
| 121 | - $controller, $methodName, $response->render() |
|
| 122 | - ), |
|
| 123 | - $response, |
|
| 124 | - ]; |
|
| 125 | - } |
|
| 126 | - |
|
| 127 | - |
|
| 128 | - /** |
|
| 129 | - * Uses the reflected parameters, types and request parameters to execute |
|
| 130 | - * the controller |
|
| 131 | - * @param Controller $controller the controller to be executed |
|
| 132 | - * @param string $methodName the method on the controller that should be executed |
|
| 133 | - * @return Response |
|
| 134 | - */ |
|
| 135 | - private function executeController(Controller $controller, string $methodName): Response { |
|
| 136 | - $arguments = []; |
|
| 137 | - |
|
| 138 | - // valid types that will be cast |
|
| 139 | - $types = ['int', 'integer', 'bool', 'boolean', 'float', 'double']; |
|
| 140 | - |
|
| 141 | - foreach ($this->reflector->getParameters() as $param => $default) { |
|
| 142 | - // try to get the parameter from the request object and cast |
|
| 143 | - // it to the type annotated in the @param annotation |
|
| 144 | - $value = $this->request->getParam($param, $default); |
|
| 145 | - $type = $this->reflector->getType($param); |
|
| 146 | - |
|
| 147 | - // Converted the string `'false'` to false when the controller wants a boolean |
|
| 148 | - if ($value === 'false' && ($type === 'bool' || $type === 'boolean')) { |
|
| 149 | - $value = false; |
|
| 150 | - } elseif ($value !== null && \in_array($type, $types, true)) { |
|
| 151 | - settype($value, $type); |
|
| 152 | - $this->ensureParameterValueSatisfiesRange($param, $value); |
|
| 153 | - } elseif ($value === null && $type !== null && $this->appContainer->has($type)) { |
|
| 154 | - $value = $this->appContainer->get($type); |
|
| 155 | - } |
|
| 156 | - |
|
| 157 | - $arguments[] = $value; |
|
| 158 | - } |
|
| 159 | - |
|
| 160 | - $this->eventLogger->start('controller:' . get_class($controller) . '::' . $methodName, 'App framework controller execution'); |
|
| 161 | - try { |
|
| 162 | - $response = \call_user_func_array([$controller, $methodName], $arguments); |
|
| 163 | - } catch (\TypeError $e) { |
|
| 164 | - // Only intercept TypeErrors occurring on the first line, meaning that the invocation of the controller method failed. |
|
| 165 | - // Any other TypeError happens inside the controller method logic and should be logged as normal. |
|
| 166 | - if ($e->getFile() === $this->reflector->getFile() && $e->getLine() === $this->reflector->getStartLine()) { |
|
| 167 | - $this->logger->debug('Failed to call controller method: ' . $e->getMessage(), ['exception' => $e]); |
|
| 168 | - return new Response(Http::STATUS_BAD_REQUEST); |
|
| 169 | - } |
|
| 170 | - |
|
| 171 | - throw $e; |
|
| 172 | - } |
|
| 173 | - $this->eventLogger->end('controller:' . get_class($controller) . '::' . $methodName); |
|
| 174 | - |
|
| 175 | - if (!($response instanceof Response)) { |
|
| 176 | - $this->logger->debug($controller::class . '::' . $methodName . ' returned raw data. Please wrap it in a Response or one of it\'s inheritors.'); |
|
| 177 | - } |
|
| 178 | - |
|
| 179 | - // format response |
|
| 180 | - if ($response instanceof DataResponse || !($response instanceof Response)) { |
|
| 181 | - $format = $this->request->getFormat(); |
|
| 182 | - if ($format !== null && $controller->isResponderRegistered($format)) { |
|
| 183 | - $response = $controller->buildResponse($response, $format); |
|
| 184 | - } else { |
|
| 185 | - $response = $controller->buildResponse($response); |
|
| 186 | - } |
|
| 187 | - } |
|
| 188 | - |
|
| 189 | - return $response; |
|
| 190 | - } |
|
| 191 | - |
|
| 192 | - /** |
|
| 193 | - * @psalm-param mixed $value |
|
| 194 | - * @throws ParameterOutOfRangeException |
|
| 195 | - */ |
|
| 196 | - private function ensureParameterValueSatisfiesRange(string $param, $value): void { |
|
| 197 | - $rangeInfo = $this->reflector->getRange($param); |
|
| 198 | - if ($rangeInfo) { |
|
| 199 | - if ($value < $rangeInfo['min'] || $value > $rangeInfo['max']) { |
|
| 200 | - throw new ParameterOutOfRangeException( |
|
| 201 | - $param, |
|
| 202 | - $value, |
|
| 203 | - $rangeInfo['min'], |
|
| 204 | - $rangeInfo['max'], |
|
| 205 | - ); |
|
| 206 | - } |
|
| 207 | - } |
|
| 208 | - } |
|
| 29 | + /** |
|
| 30 | + * @param Http $protocol the http protocol with contains all status headers |
|
| 31 | + * @param MiddlewareDispatcher $middlewareDispatcher the dispatcher which |
|
| 32 | + * runs the middleware |
|
| 33 | + */ |
|
| 34 | + public function __construct( |
|
| 35 | + private readonly Http $protocol, |
|
| 36 | + private readonly MiddlewareDispatcher $middlewareDispatcher, |
|
| 37 | + private readonly ControllerMethodReflector $reflector, |
|
| 38 | + private readonly IRequest $request, |
|
| 39 | + private readonly IConfig $config, |
|
| 40 | + private readonly ConnectionAdapter $connection, |
|
| 41 | + private readonly LoggerInterface $logger, |
|
| 42 | + private readonly IEventLogger $eventLogger, |
|
| 43 | + private readonly ContainerInterface $appContainer, |
|
| 44 | + ) { |
|
| 45 | + } |
|
| 46 | + |
|
| 47 | + |
|
| 48 | + /** |
|
| 49 | + * Handles a request and calls the dispatcher on the controller |
|
| 50 | + * @param Controller $controller the controller which will be called |
|
| 51 | + * @param string $methodName the method name which will be called on |
|
| 52 | + * the controller |
|
| 53 | + * @return array{0: string, 1: array, 2: array, 3: string, 4: Response} |
|
| 54 | + * $array[0] contains the http status header as a string, |
|
| 55 | + * $array[1] contains response headers as an array, |
|
| 56 | + * $array[2] contains response cookies as an array, |
|
| 57 | + * $array[3] contains the response output as a string, |
|
| 58 | + * $array[4] contains the response object |
|
| 59 | + * @throws \Exception |
|
| 60 | + */ |
|
| 61 | + public function dispatch(Controller $controller, string $methodName): array { |
|
| 62 | + try { |
|
| 63 | + // prefill reflector with everything that's needed for the |
|
| 64 | + // middlewares |
|
| 65 | + $this->reflector->reflect($controller, $methodName); |
|
| 66 | + |
|
| 67 | + $this->middlewareDispatcher->beforeController($controller, |
|
| 68 | + $methodName); |
|
| 69 | + |
|
| 70 | + $databaseStatsBefore = []; |
|
| 71 | + if ($this->config->getSystemValueBool('debug', false)) { |
|
| 72 | + $databaseStatsBefore = $this->connection->getInner()->getStats(); |
|
| 73 | + } |
|
| 74 | + |
|
| 75 | + $response = $this->executeController($controller, $methodName); |
|
| 76 | + |
|
| 77 | + if (!empty($databaseStatsBefore)) { |
|
| 78 | + $databaseStatsAfter = $this->connection->getInner()->getStats(); |
|
| 79 | + $numBuilt = $databaseStatsAfter['built'] - $databaseStatsBefore['built']; |
|
| 80 | + $numExecuted = $databaseStatsAfter['executed'] - $databaseStatsBefore['executed']; |
|
| 81 | + |
|
| 82 | + if ($numBuilt > 50) { |
|
| 83 | + $this->logger->debug('Controller {class}::{method} created {count} QueryBuilder objects, please check if they are created inside a loop by accident.', [ |
|
| 84 | + 'class' => get_class($controller), |
|
| 85 | + 'method' => $methodName, |
|
| 86 | + 'count' => $numBuilt, |
|
| 87 | + ]); |
|
| 88 | + } |
|
| 89 | + |
|
| 90 | + if ($numExecuted > 100) { |
|
| 91 | + $this->logger->warning('Controller {class}::{method} executed {count} queries.', [ |
|
| 92 | + 'class' => get_class($controller), |
|
| 93 | + 'method' => $methodName, |
|
| 94 | + 'count' => $numExecuted, |
|
| 95 | + ]); |
|
| 96 | + } |
|
| 97 | + } |
|
| 98 | + |
|
| 99 | + // if an exception appears, the middleware checks if it can handle the |
|
| 100 | + // exception and creates a response. If no response is created, it is |
|
| 101 | + // assumed that there's no middleware who can handle it and the error is |
|
| 102 | + // thrown again |
|
| 103 | + } catch (\Exception $exception) { |
|
| 104 | + $response = $this->middlewareDispatcher->afterException( |
|
| 105 | + $controller, $methodName, $exception); |
|
| 106 | + } catch (\Throwable $throwable) { |
|
| 107 | + $exception = new \Exception($throwable->getMessage() . ' in file \'' . $throwable->getFile() . '\' line ' . $throwable->getLine(), $throwable->getCode(), $throwable); |
|
| 108 | + $response = $this->middlewareDispatcher->afterException( |
|
| 109 | + $controller, $methodName, $exception); |
|
| 110 | + } |
|
| 111 | + |
|
| 112 | + $response = $this->middlewareDispatcher->afterController( |
|
| 113 | + $controller, $methodName, $response); |
|
| 114 | + |
|
| 115 | + // depending on the cache object the headers need to be changed |
|
| 116 | + return [ |
|
| 117 | + $this->protocol->getStatusHeader($response->getStatus()), |
|
| 118 | + array_merge($response->getHeaders()), |
|
| 119 | + $response->getCookies(), |
|
| 120 | + $this->middlewareDispatcher->beforeOutput( |
|
| 121 | + $controller, $methodName, $response->render() |
|
| 122 | + ), |
|
| 123 | + $response, |
|
| 124 | + ]; |
|
| 125 | + } |
|
| 126 | + |
|
| 127 | + |
|
| 128 | + /** |
|
| 129 | + * Uses the reflected parameters, types and request parameters to execute |
|
| 130 | + * the controller |
|
| 131 | + * @param Controller $controller the controller to be executed |
|
| 132 | + * @param string $methodName the method on the controller that should be executed |
|
| 133 | + * @return Response |
|
| 134 | + */ |
|
| 135 | + private function executeController(Controller $controller, string $methodName): Response { |
|
| 136 | + $arguments = []; |
|
| 137 | + |
|
| 138 | + // valid types that will be cast |
|
| 139 | + $types = ['int', 'integer', 'bool', 'boolean', 'float', 'double']; |
|
| 140 | + |
|
| 141 | + foreach ($this->reflector->getParameters() as $param => $default) { |
|
| 142 | + // try to get the parameter from the request object and cast |
|
| 143 | + // it to the type annotated in the @param annotation |
|
| 144 | + $value = $this->request->getParam($param, $default); |
|
| 145 | + $type = $this->reflector->getType($param); |
|
| 146 | + |
|
| 147 | + // Converted the string `'false'` to false when the controller wants a boolean |
|
| 148 | + if ($value === 'false' && ($type === 'bool' || $type === 'boolean')) { |
|
| 149 | + $value = false; |
|
| 150 | + } elseif ($value !== null && \in_array($type, $types, true)) { |
|
| 151 | + settype($value, $type); |
|
| 152 | + $this->ensureParameterValueSatisfiesRange($param, $value); |
|
| 153 | + } elseif ($value === null && $type !== null && $this->appContainer->has($type)) { |
|
| 154 | + $value = $this->appContainer->get($type); |
|
| 155 | + } |
|
| 156 | + |
|
| 157 | + $arguments[] = $value; |
|
| 158 | + } |
|
| 159 | + |
|
| 160 | + $this->eventLogger->start('controller:' . get_class($controller) . '::' . $methodName, 'App framework controller execution'); |
|
| 161 | + try { |
|
| 162 | + $response = \call_user_func_array([$controller, $methodName], $arguments); |
|
| 163 | + } catch (\TypeError $e) { |
|
| 164 | + // Only intercept TypeErrors occurring on the first line, meaning that the invocation of the controller method failed. |
|
| 165 | + // Any other TypeError happens inside the controller method logic and should be logged as normal. |
|
| 166 | + if ($e->getFile() === $this->reflector->getFile() && $e->getLine() === $this->reflector->getStartLine()) { |
|
| 167 | + $this->logger->debug('Failed to call controller method: ' . $e->getMessage(), ['exception' => $e]); |
|
| 168 | + return new Response(Http::STATUS_BAD_REQUEST); |
|
| 169 | + } |
|
| 170 | + |
|
| 171 | + throw $e; |
|
| 172 | + } |
|
| 173 | + $this->eventLogger->end('controller:' . get_class($controller) . '::' . $methodName); |
|
| 174 | + |
|
| 175 | + if (!($response instanceof Response)) { |
|
| 176 | + $this->logger->debug($controller::class . '::' . $methodName . ' returned raw data. Please wrap it in a Response or one of it\'s inheritors.'); |
|
| 177 | + } |
|
| 178 | + |
|
| 179 | + // format response |
|
| 180 | + if ($response instanceof DataResponse || !($response instanceof Response)) { |
|
| 181 | + $format = $this->request->getFormat(); |
|
| 182 | + if ($format !== null && $controller->isResponderRegistered($format)) { |
|
| 183 | + $response = $controller->buildResponse($response, $format); |
|
| 184 | + } else { |
|
| 185 | + $response = $controller->buildResponse($response); |
|
| 186 | + } |
|
| 187 | + } |
|
| 188 | + |
|
| 189 | + return $response; |
|
| 190 | + } |
|
| 191 | + |
|
| 192 | + /** |
|
| 193 | + * @psalm-param mixed $value |
|
| 194 | + * @throws ParameterOutOfRangeException |
|
| 195 | + */ |
|
| 196 | + private function ensureParameterValueSatisfiesRange(string $param, $value): void { |
|
| 197 | + $rangeInfo = $this->reflector->getRange($param); |
|
| 198 | + if ($rangeInfo) { |
|
| 199 | + if ($value < $rangeInfo['min'] || $value > $rangeInfo['max']) { |
|
| 200 | + throw new ParameterOutOfRangeException( |
|
| 201 | + $param, |
|
| 202 | + $value, |
|
| 203 | + $rangeInfo['min'], |
|
| 204 | + $rangeInfo['max'], |
|
| 205 | + ); |
|
| 206 | + } |
|
| 207 | + } |
|
| 208 | + } |
|
| 209 | 209 | } |