OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
288
app/Core/Middleware/AuthCheck.php
Normal file
288
app/Core/Middleware/AuthCheck.php
Normal file
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Auth\AuthManager;
|
||||
use Illuminate\Cache\RateLimiter;
|
||||
use Illuminate\Contracts\Auth\Factory as Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Http\ApiRequest;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthCheck
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
/**
|
||||
* Public actions
|
||||
*/
|
||||
private array $publicActions = [
|
||||
'auth.login',
|
||||
'auth.resetPw',
|
||||
'auth.userInvite',
|
||||
'install',
|
||||
'install.index',
|
||||
'install.update',
|
||||
'errors.error404',
|
||||
'errors.error500',
|
||||
'api.i18n',
|
||||
'api.static-asset',
|
||||
'calendar.ical',
|
||||
'oidc.login',
|
||||
'oidc.callback',
|
||||
'oidc.mobile',
|
||||
'status',
|
||||
'status.index',
|
||||
'cron.run',
|
||||
'auth.callback',
|
||||
'auth.redirect',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
protected Environment $config,
|
||||
protected Auth $auth,
|
||||
protected AuthManager $authManager,
|
||||
) {
|
||||
$this->publicActions = self::dispatchFilter('publicActions', $this->publicActions, ['bootloader' => $this]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the request
|
||||
*/
|
||||
public function handle(IncomingRequest $request, Closure $next): Response
|
||||
{
|
||||
|
||||
if ($this->isPublicController($request->getCurrentRoute())) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Throttle credential brute force on token-authenticated endpoints (/api, /mcp). This
|
||||
// must live here rather than in RequestRateLimiter: that middleware runs AFTER AuthCheck,
|
||||
// so a failed-auth 401 short-circuits the pipeline before any request limit is counted.
|
||||
if ($request instanceof ApiRequest && $this->tooManyFailedAuthAttempts($request)) {
|
||||
return new Response(
|
||||
json_encode(['error' => 'Too many failed authentication attempts. Try again later.']),
|
||||
Response::HTTP_TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
$loginRedirect = self::dispatch_filter('loginRoute', 'auth.login', ['request' => $request]);
|
||||
|
||||
if ($request instanceof ApiRequest) {
|
||||
self::dispatchEvent('before_api_request', ['application' => app()], 'leantime.core.middleware.apiAuth.handle');
|
||||
}
|
||||
|
||||
$authCheckResponse = $this->authenticate($request, array_keys($this->config->get('auth.guards')), $loginRedirect, $next);
|
||||
|
||||
// If auth fails either return json response or do the redirect
|
||||
if ($authCheckResponse !== true) {
|
||||
return $authCheckResponse;
|
||||
}
|
||||
|
||||
self::dispatchEvent('logged_in', ['application' => $this]);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
protected function authenticate($request, array $guards, $loginRedirect, $next)
|
||||
{
|
||||
if ($request->isApiOrCronRequest() || $request->isMcpRequest()) {
|
||||
return $this->authenticateApi($request, $guards);
|
||||
}
|
||||
|
||||
return $this->authenticateWeb($request, $guards, $loginRedirect, $next);
|
||||
}
|
||||
|
||||
protected function authenticateWeb(IncomingRequest $request, array $guards, string $loginRedirect, Closure $next): bool|Response
|
||||
{
|
||||
$authenticated = false;
|
||||
$response = null;
|
||||
|
||||
if (empty($guards)) {
|
||||
$guards = [null];
|
||||
}
|
||||
|
||||
foreach ($guards as $guard) {
|
||||
if ($this->auth->guard($guard)->check()) {
|
||||
$this->auth->shouldUse($guard);
|
||||
|
||||
// Check two-factor authentication
|
||||
if (session('userdata.twoFAEnabled') && ! session('userdata.twoFAVerified')) {
|
||||
$response = $this->redirectWithOrigin('twoFA.verify', $_GET['redirect'] ?? '', $request) ?: $next($request);
|
||||
} else {
|
||||
$authenticated = true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $authenticated && ! $response) {
|
||||
if ($request instanceof ApiRequest) {
|
||||
$this->hitFailedAuthLimiter($request);
|
||||
$response = new Response(json_encode(['error' => 'Invalid API Key']), 401);
|
||||
} else {
|
||||
$response = $this->redirectWithOrigin($loginRedirect, $request->getRequestUri(), $request) ?: $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
return $authenticated ? true : $response;
|
||||
}
|
||||
|
||||
protected function authenticateApi($request, array $guards)
|
||||
{
|
||||
foreach ($guards as $guard) {
|
||||
try {
|
||||
if ($this->auth->guard($guard)->check()) {
|
||||
$this->auth->shouldUse($guard);
|
||||
|
||||
$this->establishApiUserSession($request);
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// A guard that throws while evaluating this request type must not abort the chain;
|
||||
// keep trying the others, then fall through to the Bearer / 401 handling below.
|
||||
// Logged at debug, not warning: a misconfigured/disabled guard would throw on
|
||||
// every API request, so warning-level here would flood production logs for a
|
||||
// condition we recover from cleanly. Debug keeps it available when investigating.
|
||||
Log::debug('API auth guard "'.$guard.'" threw while evaluating the request', ['exception' => $e]);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Bearer / personal-access-token fallback. Leantime mints plain Str::random tokens
|
||||
// (sha256-hashed in zp_access_tokens) — NOT Sanctum's {id}|{plaintext} format — so
|
||||
// Sanctum's guard never resolves them and Bearer auth 401s. Validate against the core
|
||||
// token store directly (the same path the McpServer + AuthUser provider use), so Bearer
|
||||
// auth works for the mobile app and AdvancedAuth integrators independent of Sanctum's
|
||||
// token format or the plugin. getUserByToken enforces expiry and returns the user row.
|
||||
// Use ApiRequest::getBearerToken(), not Laravel's $request->bearerToken(): the latter only
|
||||
// reads the plain `Authorization` header, which Apache does not expose to PHP's header bag
|
||||
// here (it lands in HTTP_AUTHORIZATION / REDIRECT_HTTP_AUTHORIZATION). getBearerToken()
|
||||
// checks those variants, so this fires where bearerToken() silently returned null.
|
||||
$bearer = method_exists($request, 'getBearerToken') ? $request->getBearerToken() : $request->bearerToken();
|
||||
|
||||
if (! empty($bearer)) {
|
||||
$user = app(\Leantime\Domain\Auth\Services\Auth::class)->getUserByToken($bearer);
|
||||
|
||||
if (is_array($user) && ! empty($user['id'])) {
|
||||
// Establish the Leantime user context the permission engine (and the rest of the
|
||||
// app) reads — session('userdata'). Deliberately NOT setting a request user
|
||||
// resolver: leaving $request->user() null lets AuthenticateSession bail instead of
|
||||
// calling viaRemember() on the non-session WebGuard, matching the x-api-key path.
|
||||
app(\Leantime\Domain\Api\Services\Api::class)->setApiUserSession($user, true);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$this->hitFailedAuthLimiter($request);
|
||||
|
||||
return new Response(json_encode(['error' => 'Unauthorized']), 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this client IP has exceeded the failed-authentication budget (shared with the
|
||||
* login attempt limit, LEAN_RATELIMIT_AUTH). Counted per minute.
|
||||
*/
|
||||
protected function tooManyFailedAuthAttempts(IncomingRequest $request): bool
|
||||
{
|
||||
$limit = $this->config->ratelimitAuth ?? 20;
|
||||
|
||||
return app(RateLimiter::class)->tooManyAttempts($this->failedAuthKey($request), $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed authentication attempt for this client IP (1-minute decay).
|
||||
*/
|
||||
protected function hitFailedAuthLimiter(IncomingRequest $request): void
|
||||
{
|
||||
app(RateLimiter::class)->hit($this->failedAuthKey($request), 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiter key for failed token-auth attempts, scoped to the client IP.
|
||||
*/
|
||||
protected function failedAuthKey(IncomingRequest $request): string
|
||||
{
|
||||
return 'api-auth-failures:'.$request->getClientIp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish the Leantime user context (`session('userdata')`) for an authenticated API request.
|
||||
*
|
||||
* Every API guard must leave the SAME context behind: the permission engine — and everything
|
||||
* else — reads the user's id and role from `session('userdata')`. The x-api-key guard populates
|
||||
* it as a side effect of {@see \Leantime\Domain\Api\Services\Api::getAPIKeyUser()}, but the
|
||||
* Sanctum (Bearer) guard resolves the user straight from its token and never does — so the
|
||||
* engine saw no user and denied every gated `@api` method with -32001 on Bearer requests.
|
||||
*
|
||||
* This makes the HTTP API auth path uniform: whichever guard authenticated, the context is
|
||||
* built once, from the canonical user row, through the same `setApiUserSession()` builder the
|
||||
* x-api-key path uses. Idempotent — it skips when a guard already populated `userdata`
|
||||
* (x-api-key, or a stateful web session), so those paths are byte-for-byte untouched. Services
|
||||
* are resolved lazily because this only runs for an authenticated API request.
|
||||
*/
|
||||
protected function establishApiUserSession(IncomingRequest $request): void
|
||||
{
|
||||
if (session()->exists('userdata') || ($apiUser = $request->user()) === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userData = app(\Leantime\Domain\Users\Services\Users::class)->getUser((int) $apiUser->id);
|
||||
|
||||
if (is_array($userData)) {
|
||||
app(\Leantime\Domain\Api\Services\Api::class)->setApiUserSession($userData, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect with origin
|
||||
* Returns false if the current route is already the redirection route.
|
||||
*
|
||||
*
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
*/
|
||||
public function redirectWithOrigin(string $route, string $origin, IncomingRequest $request): false|RedirectResponse
|
||||
{
|
||||
|
||||
$uri = ltrim(str_replace('.', '/', $route), '/');
|
||||
$destination = BASE_URL.'/'.$uri;
|
||||
$originClean = Str::replaceStart('/', '', $origin);
|
||||
$queryParams = ! empty($origin) && $origin !== '/' ? '?'.http_build_query(['redirect' => $originClean]) : '';
|
||||
|
||||
if ($request->getCurrentRoute() === $route) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new RedirectResponse($destination.$queryParams);
|
||||
}
|
||||
|
||||
public function isPublicController($currentPath): bool
|
||||
{
|
||||
|
||||
// path comes in with dots as separator.
|
||||
// We only need to compare the first 2 segments
|
||||
if (empty($currentPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$pathSegments = explode('.', $currentPath);
|
||||
|
||||
$routeToCheck = match (count($pathSegments)) {
|
||||
1 => $pathSegments[0],
|
||||
default => $pathSegments[0].'.'.$pathSegments[1],
|
||||
};
|
||||
|
||||
return $routeToCheck !== null && in_array($routeToCheck, $this->publicActions, true);
|
||||
|
||||
}
|
||||
}
|
||||
148
app/Core/Middleware/AuthenticateSession.php
Normal file
148
app/Core/Middleware/AuthenticateSession.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Contracts\Auth\Factory as AuthFactory;
|
||||
use Illuminate\Contracts\Session\Middleware\AuthenticatesSessions;
|
||||
use Illuminate\Http\Request;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Domain\Setting\Services\Setting;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthenticateSession implements AuthenticatesSessions
|
||||
{
|
||||
/**
|
||||
* Create a new middleware instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
protected AuthFactory $auth,
|
||||
private readonly Setting $settings) {}
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle($request, Closure $next)
|
||||
{
|
||||
if (! $request->hasSession() || ! $request->user()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($this->guard()->viaRemember()) {
|
||||
$passwordHash = explode('|', $request->cookies->get($this->guard()->getRecallerName()))[2] ?? null;
|
||||
|
||||
if (! $passwordHash || $passwordHash != $request->user()->getAuthPassword()) {
|
||||
$this->logout($request);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $request->session()->has('password_hash_'.$this->auth->getDefaultDriver())) {
|
||||
$this->storePasswordHashInSession($request);
|
||||
}
|
||||
|
||||
if ($request->session()->get('password_hash_'.$this->auth->getDefaultDriver()) !== $request->user(
|
||||
)->getAuthPassword()) {
|
||||
$this->logout($request);
|
||||
}
|
||||
|
||||
return tap($next($request), function () use ($request) {
|
||||
if (! is_null($this->guard()->user())) {
|
||||
$this->storePasswordHashInSession($request);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the user's current password hash in the session.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return void
|
||||
*/
|
||||
protected function storePasswordHashInSession($request)
|
||||
{
|
||||
if (! $request->user()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request->session()->put([
|
||||
'password_hash_'.$this->auth->getDefaultDriver() => $request->user()->getAuthPassword(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user out of the application.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return void
|
||||
*
|
||||
* @throws \Illuminate\Auth\AuthenticationException
|
||||
*/
|
||||
protected function logout($request)
|
||||
{
|
||||
$this->guard()->logoutCurrentDevice();
|
||||
|
||||
$request->session()->flush();
|
||||
|
||||
throw new AuthenticationException(
|
||||
'Unauthenticated.', [$this->auth->getDefaultDriver()], $this->redirectTo($request)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the guard instance that should be used by the middleware.
|
||||
*
|
||||
* @return \Illuminate\Contracts\Auth\Guard
|
||||
*/
|
||||
protected function guard()
|
||||
{
|
||||
return $this->auth->guard();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path the user should be redirected to when their session is not authenticated.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
protected function redirectTo(Request $request)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function setLeantimeSession(IncomingRequest $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->hasSession() || ! $request->user()) {
|
||||
session(['userdata' => null]);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// Set up the user session data
|
||||
$currentUser = [
|
||||
'id' => (int) $user->id,
|
||||
'name' => strip_tags($user->firstname),
|
||||
'profileId' => $user->profileId,
|
||||
'mail' => filter_var($user->username, FILTER_SANITIZE_EMAIL),
|
||||
'clientId' => $user->clientId,
|
||||
'role' => $user->role,
|
||||
'settings' => $user->settings ? safe_unserialize($user->settings, []) : [],
|
||||
'twoFAEnabled' => $user->twoFAEnabled ?? false,
|
||||
'twoFAVerified' => false,
|
||||
'twoFASecret' => $user->twoFASecret ?? '',
|
||||
'isExternalAuth' => false,
|
||||
'createdOn' => ! empty($user->createdOn) ? dtHelper()->parseDbDateTime($user->createdOn) : dtHelper()->userNow(),
|
||||
'modified' => ! empty($user->modified) ? dtHelper()->parseDbDateTime($user->modified) : dtHelper()->userNow(),
|
||||
];
|
||||
|
||||
session(['userdata' => $currentUser]);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
78
app/Core/Middleware/InitialHeaders.php
Normal file
78
app/Core/Middleware/InitialHeaders.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class InitialHeaders
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
/**
|
||||
* Set up the initial headers
|
||||
*
|
||||
* @param \Closure(IncomingRequest): Response $next
|
||||
*
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
**/
|
||||
public function handle($request, Closure $next): Response
|
||||
{
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
// Content Security Policy
|
||||
$cspParts = [
|
||||
"default-src 'self' 'unsafe-inline'",
|
||||
"base-uri 'self';",
|
||||
"script-src 'self' 'unsafe-inline' unpkg.com",
|
||||
"font-src 'self' data: unpkg.com",
|
||||
"img-src * 'self' *.leantime.io *.amazonaws.com data: blob: marketplace.localhost",
|
||||
// Allow all embed providers supported by the TipTap embed extension.
|
||||
// Each entry corresponds to one or more embed types in embed.js.
|
||||
"frame-src 'self'"
|
||||
.' *.google.com' // googleDocs, googleSheets, googleSlides, googleForms
|
||||
.' *.microsoft.com *.live.com *.sharepoint.com *.officeapps.live.com' // oneDrive, office365
|
||||
.' *.figma.com' // figma
|
||||
.' *.miro.com' // miro
|
||||
.' *.youtube.com *.youtube-nocookie.com' // youtube
|
||||
.' player.vimeo.com *.vimeo.com' // vimeo
|
||||
.' *.loom.com' // loom
|
||||
.' *.airtable.com' // airtable
|
||||
.' *.typeform.com form.typeform.com' // typeform
|
||||
.' calendly.com' // calendly
|
||||
.' codepen.io' // codepen
|
||||
.' *.codesandbox.io', // codesandbox
|
||||
"frame-ancestors 'self' *.google.com *.microsoft.com *.live.com",
|
||||
];
|
||||
$cspParts = self::dispatchFilter('cspParts', $cspParts);
|
||||
$csp = implode(';', $cspParts);
|
||||
|
||||
foreach (
|
||||
self::dispatchFilter('headers', [
|
||||
'X-Frame-Options' => 'SAMEORIGIN',
|
||||
'X-XSS-Protection' => '1; mode=block',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'Referrer-Policy' => 'same-origin',
|
||||
'Access-Control-Allow-Origin' => BASE_URL,
|
||||
'Cache-Control' => 'no-cache, no-store, must-revalidate',
|
||||
'Pragma' => 'no-cache',
|
||||
'Content-Security-Policy' => $csp,
|
||||
]) as $key => $value
|
||||
) {
|
||||
if ($response->headers->has($key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$response->headers->set($key, $value);
|
||||
}
|
||||
|
||||
if ($request->isSecure() || env('LEAN_HSTS_ENABLED', false)) {
|
||||
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
111
app/Core/Middleware/Installed.php
Normal file
111
app/Core/Middleware/Installed.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class Installed
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
/**
|
||||
* Check if Leantime is installed
|
||||
*
|
||||
* @param \Closure(IncomingRequest): Response $next
|
||||
*
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
**/
|
||||
public function handle(IncomingRequest $request, Closure $next): Response
|
||||
{
|
||||
$session_says = session()->exists('isInstalled') && session('isInstalled');
|
||||
|
||||
// For HTMX and API requests, trust the session -- install state can't change mid-session.
|
||||
// This avoids a cache/DB lookup on every partial request.
|
||||
if ($session_says && ($request->isHtmxRequest() || $request->isApiOrCronRequest())) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$config_says = app()->make(SettingRepository::class)->checkIfInstalled();
|
||||
|
||||
if (! $session_says && ! $config_says) {
|
||||
$this->setUninstalled();
|
||||
|
||||
if (! $response = $this->redirectToInstall($request)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
if ($session_says && ! $config_says) {
|
||||
$this->setUninstalled();
|
||||
|
||||
if (! $response = $this->redirectToInstall($request)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (! $session_says) {
|
||||
$this->setInstalled();
|
||||
}
|
||||
|
||||
self::dispatchEvent('after_install');
|
||||
|
||||
$route = $request->getCurrentRoute();
|
||||
|
||||
if ($session_says && $route == 'install') {
|
||||
return Frontcontroller::redirect(BASE_URL.'/auth/logout');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set installed
|
||||
*/
|
||||
private function setInstalled(): void
|
||||
{
|
||||
session(['isInstalled' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set uninstalled
|
||||
*/
|
||||
private function setUninstalled(): void
|
||||
{
|
||||
session(['isInstalled' => false]);
|
||||
|
||||
if (session()->exists('userdata')) {
|
||||
session()->forget('userdata');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to install
|
||||
*
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
*/
|
||||
private function redirectToInstall(IncomingRequest $request): Response|false
|
||||
{
|
||||
$frontController = app()->make(Frontcontroller::class);
|
||||
|
||||
$allowedRoutes = ['install', 'install.update', 'api.i18n'];
|
||||
$allowedRoutes = self::dispatchFilter('allowedRoutes', $allowedRoutes);
|
||||
$route = $request->getCurrentRoute();
|
||||
if (in_array($request->getCurrentRoute(), $allowedRoutes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$route = BASE_URL.'/install';
|
||||
$route = self::dispatchFilter('redirectroute', $route);
|
||||
|
||||
return $frontController::redirect($route);
|
||||
}
|
||||
}
|
||||
38
app/Core/Middleware/LoadPlugins.php
Normal file
38
app/Core/Middleware/LoadPlugins.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class LoadPlugins
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
protected $pluginMiddleware = [];
|
||||
|
||||
/**
|
||||
* Set up the initial headers
|
||||
*
|
||||
* @param \Closure(IncomingRequest): Response $next
|
||||
*
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
**/
|
||||
public function handle($request, Closure $next): Response
|
||||
{
|
||||
|
||||
// Event Registrar hooks into this and calls enabled plugin register files
|
||||
self::dispatchEvent('pluginsStart', ['request' => $request]);
|
||||
|
||||
// Good event to use for all kinds of plugin events that should run early on like adding language files
|
||||
self::dispatchEvent('pluginsEvents', ['request' => $request], 'leantime.core.middleware.loadplugins.handle');
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
self::dispatchEvent('pluginsTermintate', ['request' => $request, 'response' => $response]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
100
app/Core/Middleware/Localization.php
Normal file
100
app/Core/Middleware/Localization.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Middleware;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Closure;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\Support\CarbonMacros;
|
||||
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class Localization
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SettingRepository $settingsRepo,
|
||||
private readonly Environment $config,
|
||||
private readonly Language $language,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*
|
||||
* @param IncomingRequest $request The incoming request object.
|
||||
* @param Closure $next The closure to execute next.
|
||||
* @return Response The response object.
|
||||
*
|
||||
* @throws \ReflectionException
|
||||
*/
|
||||
public function handle(IncomingRequest $request, Closure $next): Response
|
||||
{
|
||||
// Check if localization settings are already cached in the session.
|
||||
// Settings rarely change mid-session, so we only fetch from DB on first load.
|
||||
// When users change their settings, the settings save endpoint refreshes the session.
|
||||
if (session()->has('localization.cached')) {
|
||||
date_default_timezone_set(session('usersettings.timezone') ?: $this->config->defaultTimezone);
|
||||
|
||||
CarbonImmutable::mixin(new CarbonMacros(
|
||||
session('usersettings.timezone') ?: $this->config->defaultTimezone,
|
||||
str_replace('-', '_', session('usersettings.language') ?: session('companysettings.language') ?: $this->config->language),
|
||||
session('usersettings.date_format') ?: $this->language->__('language.dateformat'),
|
||||
session('usersettings.time_format') ?: $this->language->__('language.timeformat')
|
||||
));
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// First request in session: batch-fetch all localization settings at once
|
||||
$userId = session('userdata.id') ?? false;
|
||||
|
||||
$settingKeys = ['companysettings.language'];
|
||||
if ($userId) {
|
||||
$settingKeys = array_merge($settingKeys, [
|
||||
"usersettings.$userId.language",
|
||||
"usersettings.$userId.timezone",
|
||||
"usersettings.$userId.date_format",
|
||||
"usersettings.$userId.time_format",
|
||||
]);
|
||||
}
|
||||
|
||||
// Single batch query instead of 5 individual queries
|
||||
$settings = $this->settingsRepo->getSettingsForKeys($settingKeys);
|
||||
|
||||
$companyLanguage = $settings['companysettings.language'] ?? $this->config->language;
|
||||
session()->put('companysettings.language', $companyLanguage ?: $this->config->language);
|
||||
|
||||
if (! $userId) {
|
||||
CarbonImmutable::mixin(new CarbonMacros(
|
||||
$this->config->defaultTimezone,
|
||||
str_replace('-', '_', session('companysettings.language')),
|
||||
$this->language->__('language.dateformat'),
|
||||
$this->language->__('language.timeformat')
|
||||
));
|
||||
|
||||
session()->put('localization.cached', true);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
session()->put('usersettings.language', ($settings["usersettings.$userId.language"] ?? false) ?: session('companysettings.language'));
|
||||
session()->put('usersettings.timezone', ($settings["usersettings.$userId.timezone"] ?? false) ?: $this->config->defaultTimezone);
|
||||
date_default_timezone_set(session('usersettings.timezone'));
|
||||
|
||||
session()->put('usersettings.date_format', ($settings["usersettings.$userId.date_format"] ?? false) ?: $this->language->__('language.dateformat'));
|
||||
session()->put('usersettings.time_format', ($settings["usersettings.$userId.time_format"] ?? false) ?: $this->language->__('language.timeformat'));
|
||||
|
||||
// Set macros for CarbonImmutable date handling
|
||||
CarbonImmutable::mixin(new CarbonMacros(
|
||||
session('usersettings.timezone'),
|
||||
str_replace('-', '_', session('usersettings.language')),
|
||||
session('usersettings.date_format'),
|
||||
session('usersettings.time_format')
|
||||
));
|
||||
|
||||
session()->put('localization.cached', true);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
21
app/Core/Middleware/RateLimiter.php
Normal file
21
app/Core/Middleware/RateLimiter.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Middleware;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class RateLimiter extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$this->app->singleton(\Illuminate\Cache\RateLimiter::class, function ($app) {
|
||||
return new \Illuminate\Cache\RateLimiter(Cache::store('installation'));
|
||||
});
|
||||
}
|
||||
}
|
||||
175
app/Core/Middleware/RequestRateLimiter.php
Normal file
175
app/Core/Middleware/RequestRateLimiter.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Cache\RateLimiter;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Http\ApiRequest;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Domain\Api\Services\Api;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Class ApiRateLimiter
|
||||
*
|
||||
* This class is responsible for rate limiting requests, login requests and api requests
|
||||
*/
|
||||
class RequestRateLimiter
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
protected RateLimiter $limiter;
|
||||
|
||||
protected Environment $config;
|
||||
|
||||
/**
|
||||
* __construct
|
||||
* Constructor method for the class.
|
||||
*
|
||||
* @param RateLimiter $limiter The RateLimiter object to be initialized.
|
||||
* @return void.
|
||||
*/
|
||||
public function __construct(Environment $config, RateLimiter $limiter)
|
||||
{
|
||||
$this->limiter = $limiter;
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the incoming request.
|
||||
*
|
||||
* @param IncomingRequest $request The incoming request object.
|
||||
* @param Closure $next The next middleware closure.
|
||||
* @return Response The response object.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function handle(IncomingRequest $request, Closure $next): Response
|
||||
{
|
||||
|
||||
if (! session('isInstalled')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Normalize once: the Frontcontroller resolves controller classes case-insensitively, so
|
||||
// /Users/NewUser and /auth/Login reach the same controllers as their lowercase forms. Match
|
||||
// on the lowercased route so a mixed-case path can't slip past the login or signup limiter.
|
||||
$route = strtolower($request->getCurrentRoute());
|
||||
|
||||
$isLoginRoute = $route === 'auth.login';
|
||||
|
||||
// Abuse-sensitive POSTs: self-serve workspace signup and user invites. These send email and
|
||||
// provision resources, so the web form gets a tight per-IP budget (invite-spam abuse). The
|
||||
// JSON-RPC invite path (an ApiRequest) is NOT caught here — it is an API request throttled at
|
||||
// the API budget, and its real backstop is the per-user/per-tenant cap in
|
||||
// Users::invitesRateLimited(), which is entry-point-agnostic.
|
||||
$isSignupPost = in_array($route, ['accounts.register', 'accounts.newteam', 'users.newuser'], true)
|
||||
&& $request->isMethod('POST');
|
||||
|
||||
// Only check rate limits for login page, signup/invite posts, api calls, and the MCP endpoint
|
||||
if (! $isLoginRoute && ! $isSignupPost && ! $request->isApiOrCronRequest() && ! $request->isMcpRequest()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Configurable rate limits
|
||||
$rateLimitGeneral = $this->config->ratelimitGeneral ?? 10000;
|
||||
$rateLimitApi = $this->config->ratelimitApi ?? 100;
|
||||
$rateLimitAuth = $this->config->ratelimitAuth ?? 20;
|
||||
$rateLimitMcp = $this->config->ratelimitMcp ?? 300;
|
||||
$rateLimitSignup = $this->config->ratelimitSignup ?? 5;
|
||||
|
||||
if (config('app.debug')) {
|
||||
$rateLimitGeneral = 999999999;
|
||||
$rateLimitApi = 999999999;
|
||||
$rateLimitAuth = 999999999;
|
||||
$rateLimitMcp = 999999999;
|
||||
$rateLimitSignup = 999999999;
|
||||
}
|
||||
|
||||
// Key
|
||||
// Key lives in domain namespace already
|
||||
$keyModifier = '0';
|
||||
if (session()->exists('userdata')) {
|
||||
$keyModifier = session('userdata.id');
|
||||
}
|
||||
|
||||
$key = 'ratelimit-'.($request->getClientIp()).'-'.$keyModifier;
|
||||
|
||||
// General Limit per minute
|
||||
$limit = $rateLimitGeneral;
|
||||
|
||||
// API Routes Limit
|
||||
if ($request instanceof ApiRequest) {
|
||||
$apiKey = '';
|
||||
// $key = app()->make(Api::class)->getAPIKeyUser($apiKey);
|
||||
$limit = $rateLimitApi;
|
||||
}
|
||||
|
||||
// MCP endpoint gets its own (higher) budget: agentic LLM clients legitimately burst
|
||||
// many parallel tool calls per conversation turn, which the API limit would choke on.
|
||||
if ($request->isMcpRequest()) {
|
||||
$limit = $rateLimitMcp;
|
||||
}
|
||||
|
||||
if ($isSignupPost) {
|
||||
$limit = $rateLimitSignup;
|
||||
// Strictly per-IP: the signup form is unauthenticated (no session user id), and pinning
|
||||
// to IP alone stops one host from cycling sessions to widen its budget.
|
||||
$key = 'ratelimit-'.($request->getClientIp()).':signup';
|
||||
}
|
||||
|
||||
if ($isLoginRoute) {
|
||||
$limit = $rateLimitAuth;
|
||||
$key = $key.':loginAttempts';
|
||||
|
||||
}
|
||||
|
||||
$key = self::dispatchFilter(
|
||||
'rateLimitKey',
|
||||
$key,
|
||||
[
|
||||
'bootloader' => $this,
|
||||
],
|
||||
);
|
||||
|
||||
$limit = self::dispatchFilter(
|
||||
'rateLimit',
|
||||
$limit,
|
||||
[
|
||||
'bootloader' => $this,
|
||||
'key' => $key,
|
||||
],
|
||||
);
|
||||
|
||||
if ($this->limiter->tooManyAttempts($key, $limit)) {
|
||||
Log::warning('too many requests per minute: '.$key);
|
||||
|
||||
return new Response(
|
||||
json_encode(['error' => 'Too many requests per minute.']),
|
||||
Response::HTTP_TOO_MANY_REQUESTS,
|
||||
$this->getHeaders($key, (int) $limit),
|
||||
);
|
||||
}
|
||||
|
||||
$this->limiter->hit($key, 60);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limiter headers for response.
|
||||
*/
|
||||
private function getHeaders(string $key, int $limit): array
|
||||
{
|
||||
return [
|
||||
'X-RateLimit-Remaining' => $this->limiter->retriesLeft($key, $limit),
|
||||
'X-RateLimit-Retry-After' => $this->limiter->availableIn($key),
|
||||
'X-RateLimit-Limit' => $this->limiter->attempts($key),
|
||||
'Retry-After' => $this->limiter->availableIn($key),
|
||||
];
|
||||
}
|
||||
}
|
||||
97
app/Core/Middleware/SetCacheHeaders.php
Normal file
97
app/Core/Middleware/SetCacheHeaders.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class SetCacheHeaders
|
||||
{
|
||||
/**
|
||||
* Specify the options for the middleware.
|
||||
*
|
||||
* @param array|string $options
|
||||
* @return string
|
||||
*/
|
||||
public static function using($options)
|
||||
{
|
||||
if (is_string($options)) {
|
||||
return static::class.':'.$options;
|
||||
}
|
||||
|
||||
return collect($options)
|
||||
->map(function ($value, $key) {
|
||||
if (is_bool($value)) {
|
||||
return $value ? $key : null;
|
||||
}
|
||||
|
||||
return is_int($key) ? $value : "{$key}={$value}";
|
||||
})
|
||||
->filter()
|
||||
->map(fn ($value) => Str::finish($value, ';'))
|
||||
->pipe(fn ($options) => rtrim(static::class.':'.$options->implode(''), ';'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add cache related HTTP headers.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param string|array $options
|
||||
* @return \Symfony\Component\HttpFoundation\Response
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function handle($request, Closure $next, $options = [])
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
if (! $request->isMethodCacheable()
|
||||
|| (! $response->getContent() && ! $response instanceof BinaryFileResponse && ! $response instanceof StreamedResponse)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (is_string($options)) {
|
||||
$options = $this->parseOptions($options);
|
||||
}
|
||||
|
||||
// Controllers can set the cache headers in here.
|
||||
if (! $response->isSuccessful()) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (isset($options['etag']) && $options['etag'] === true) {
|
||||
$options['etag'] = $response->getEtag() ?? ($response->getContent() ? md5($response->getContent()) : null);
|
||||
}
|
||||
|
||||
if (isset($options['last_modified'])) {
|
||||
if (is_numeric($options['last_modified'])) {
|
||||
$options['last_modified'] = Carbon::createFromTimestamp($options['last_modified'], date_default_timezone_get());
|
||||
} else {
|
||||
$options['last_modified'] = Carbon::parse($options['last_modified']);
|
||||
}
|
||||
}
|
||||
|
||||
$response->setCache($options);
|
||||
$response->isNotModified($request);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the given header options.
|
||||
*
|
||||
* @param string $options
|
||||
* @return array
|
||||
*/
|
||||
protected function parseOptions($options)
|
||||
{
|
||||
return collect(explode(';', rtrim($options, ';')))->mapWithKeys(function ($option) {
|
||||
$data = explode('=', $option, 2);
|
||||
|
||||
return [$data[0] => $data[1] ?? true];
|
||||
})->all();
|
||||
}
|
||||
}
|
||||
476
app/Core/Middleware/StartSession.php
Normal file
476
app/Core/Middleware/StartSession.php
Normal file
@@ -0,0 +1,476 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Contracts\Session\Session;
|
||||
use Illuminate\Session\SessionManager;
|
||||
use Illuminate\Session\Store;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Date;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class StartSession
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
/**
|
||||
* The session manager.
|
||||
*
|
||||
* @var \Illuminate\Session\SessionManager
|
||||
*/
|
||||
protected $manager;
|
||||
|
||||
/**
|
||||
* The callback that can resolve an instance of the cache factory.
|
||||
*
|
||||
* @var callable|null
|
||||
*/
|
||||
protected $cacheFactoryResolver;
|
||||
|
||||
/**
|
||||
* Create a new session middleware.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(SessionManager $manager, ?callable $cacheFactoryResolver = null)
|
||||
{
|
||||
$this->manager = $manager;
|
||||
$this->cacheFactoryResolver = $cacheFactoryResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(IncomingRequest $request, Closure $next)
|
||||
{
|
||||
|
||||
if (! $this->sessionConfigured()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// For API and cron requests, use in-memory array driver to prevent
|
||||
// persistent session accumulation. Must run BEFORE getSession() so the
|
||||
// session object is created with the array handler from the start.
|
||||
// Browser AJAX requests (JS calling JSON-RPC) are excluded so they
|
||||
// continue to share the user's web session.
|
||||
if ($request->isApiOrCronRequest() && ! $request->ajax()) {
|
||||
config(['session.driver' => 'array']);
|
||||
$this->manager->setDefaultDriver('array');
|
||||
}
|
||||
|
||||
$session = $this->getSession($request);
|
||||
|
||||
self::dispatchEvent('session_initialized');
|
||||
|
||||
// API and cron requests are stateful but non-persisting and never lock
|
||||
// (unchanged behavior: their writes were never saved to begin with).
|
||||
if (! $this->shouldPersistSession($request)) {
|
||||
return $this->handleStatelessRequest($request, $session, $next);
|
||||
}
|
||||
|
||||
// Web requests use optimistic concurrency: run lock-free, then persist only
|
||||
// the keys that actually changed, merging them under a brief lock so parallel
|
||||
// requests (e.g. dashboard widgets) can't clobber each other's writes.
|
||||
return $this->handleOptimisticRequest($request, $session, $next);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a stateful but non-persisting request (API / cron). The session is
|
||||
* started so it can be read, but it is never locked and never written back —
|
||||
* this preserves the pre-existing behavior for these request types.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Session\Session $session
|
||||
* @return mixed
|
||||
*/
|
||||
protected function handleStatelessRequest(IncomingRequest $request, $session, Closure $next)
|
||||
{
|
||||
$request->setLaravelSession($this->startSession($request, $session));
|
||||
|
||||
self::dispatchEvent('session_started');
|
||||
|
||||
$this->collectGarbage($session);
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
$this->addCookieToResponse($response, $session);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a web request with optimistic session concurrency. The request runs
|
||||
* without holding the session lock so concurrent requests (e.g. parallel
|
||||
* dashboard widgets) are not serialized. Only when the session actually
|
||||
* changed do we briefly lock, re-read the freshest persisted state, and merge
|
||||
* just this request's changed keys — preventing the lost-update race that
|
||||
* previously forced blanket locking.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Session\Session $session
|
||||
* @return mixed
|
||||
*/
|
||||
protected function handleOptimisticRequest(IncomingRequest $request, $session, Closure $next)
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
$request->setLaravelSession($this->startSession($request, $session));
|
||||
|
||||
self::dispatchEvent('session_started');
|
||||
|
||||
$this->collectGarbage($session);
|
||||
|
||||
$initialId = $session->getId();
|
||||
$initialData = $session->all();
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
$this->storeCurrentUrl($request, $session);
|
||||
|
||||
$this->persistSessionChanges($request, $session, $initialId, $initialData);
|
||||
|
||||
$duration = microtime(true) - $startTime;
|
||||
if ($duration > 3.0) {
|
||||
Log::warning("Long session operation detected: {$duration}s for session {$session->getId()}");
|
||||
}
|
||||
|
||||
$this->addCookieToResponse($response, $session);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist session changes using a lock-on-write strategy.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Session\Session $session
|
||||
*/
|
||||
protected function persistSessionChanges(IncomingRequest $request, $session, string $initialId, array $initialData): void
|
||||
{
|
||||
// The session identity changed (login/logout regenerate or invalidate).
|
||||
// Keys can't be safely merged onto a different id, so fall back to a full,
|
||||
// locked save of the live session.
|
||||
if ($session->getId() !== $initialId) {
|
||||
$this->withSessionLock($request, $session, fn () => $session->save());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
[$changed, $removed] = $this->diffSession($initialData, $session->all());
|
||||
|
||||
// Pure read: nothing changed, so we never touch the lock or storage.
|
||||
if ($changed === [] && $removed === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->withSessionLock($request, $session, fn () => $this->mergeSessionChanges($session, $changed, $removed));
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the freshest persisted session state and apply ONLY the keys this
|
||||
* request changed/removed onto it, then write it back. Merging by changed-key
|
||||
* (rather than overwriting the whole blob) is what lets a concurrent writer's
|
||||
* keys survive. Must be called while holding the per-session lock.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Session\Session $session
|
||||
* @param array<string, mixed> $changed
|
||||
* @param array<int, string> $removed
|
||||
*/
|
||||
protected function mergeSessionChanges($session, array $changed, array $removed): void
|
||||
{
|
||||
$merged = new Store(
|
||||
$session->getName(),
|
||||
$session->getHandler(),
|
||||
$session->getId(),
|
||||
$this->manager->getSessionConfig()['serialization'] ?? 'php'
|
||||
);
|
||||
|
||||
$merged->start();
|
||||
|
||||
// Keep the CSRF token consistent with what the live session (and the
|
||||
// already-rendered response) used; a fresh Store would otherwise
|
||||
// regenerate a different token and break the next POST.
|
||||
$merged->put('_token', $session->token());
|
||||
|
||||
foreach ($changed as $key => $value) {
|
||||
$merged->put($key, $value);
|
||||
}
|
||||
|
||||
foreach ($removed as $key) {
|
||||
$merged->forget($key);
|
||||
}
|
||||
|
||||
$merged->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the keys this request added/changed and the keys it removed,
|
||||
* comparing the session state captured before the request against the
|
||||
* state after it.
|
||||
*
|
||||
* @return array{0: array<string, mixed>, 1: array<int, string>}
|
||||
*/
|
||||
protected function diffSession(array $initial, array $current): array
|
||||
{
|
||||
$changed = [];
|
||||
|
||||
foreach ($current as $key => $value) {
|
||||
if (! array_key_exists($key, $initial) || $initial[$key] !== $value) {
|
||||
$changed[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$removed = array_keys(array_diff_key($initial, $current));
|
||||
|
||||
return [$changed, $removed];
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the per-session lock, run the persistence callback, and release.
|
||||
* Falls back to an exponential-backoff retry if the lock can't be acquired.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Session\Session $session
|
||||
*/
|
||||
protected function withSessionLock(IncomingRequest $request, $session, Closure $callback): void
|
||||
{
|
||||
// Dynamic lock period for different request types
|
||||
$holdLockFor = $this->calculateLockDuration($request); // Hold lock for x seconds after acquiring
|
||||
|
||||
// Maximum time to wait for acquiring the lock if already held
|
||||
$maxWaitForLock = 5; // Wait for up to y seconds to acquire the lock
|
||||
|
||||
$lock = $this->cache($this->manager->blockDriver())
|
||||
->lock('session:'.$session->getId(), $holdLockFor)
|
||||
->betweenBlockedAttemptsSleepFor(50);
|
||||
|
||||
try {
|
||||
$lock->block($maxWaitForLock);
|
||||
|
||||
$callback();
|
||||
} catch (LockTimeoutException $e) {
|
||||
Log::warning("Session lock timeout for session {$session->getId()}: {$e->getMessage()}");
|
||||
|
||||
// Implement exponential backoff retry
|
||||
$this->retryWithBackoff($callback, $session);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate appropriate lock duration based on request type. This is v0. We'll need to make this smarter
|
||||
*/
|
||||
protected function calculateLockDuration(IncomingRequest $request): int
|
||||
{
|
||||
if ($request->isMethod('GET')) {
|
||||
return 1; // Shorter duration for GET requests
|
||||
}
|
||||
|
||||
if ($request->ajax()) {
|
||||
return 2; // Medium duration for AJAX requests
|
||||
}
|
||||
|
||||
return 3; // Default duration for other requests
|
||||
}
|
||||
|
||||
/**
|
||||
* Implement exponential backoff retry strategy for the persistence callback.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Session\Session $session
|
||||
*/
|
||||
protected function retryWithBackoff(Closure $callback, $session, int $attempts = 3): void
|
||||
{
|
||||
for ($i = 0; $i < $attempts; $i++) {
|
||||
try {
|
||||
$waitTime = min(100 * pow(2, $i), 1000); // Exponential backoff with max 1 second
|
||||
$jitter = random_int(-100, 100); // Add jitter to prevent thundering herd
|
||||
usleep(($waitTime + $jitter) * 1000); // Convert to microseconds
|
||||
|
||||
$callback();
|
||||
|
||||
return;
|
||||
} catch (\Exception $e) {
|
||||
Log::warning("Retry attempt {$i} failed for session {$session->getId()}: {$e->getMessage()}");
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If all retries fail, persist without the lock as a last resort.
|
||||
Log::error("All retry attempts failed for session {$session->getId()}, persisting without lock");
|
||||
|
||||
$callback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the session for the given request.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Session\Session $session
|
||||
* @return \Illuminate\Contracts\Session\Session
|
||||
*/
|
||||
protected function startSession(IncomingRequest $request, $session)
|
||||
{
|
||||
return tap($session, function ($session) use ($request) {
|
||||
$session->setRequestOnHandler($request);
|
||||
|
||||
$session->start();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session implementation from the manager.
|
||||
*
|
||||
* @return \Illuminate\Contracts\Session\Session
|
||||
*/
|
||||
public function getSession(IncomingRequest $request)
|
||||
{
|
||||
return tap($this->manager->driver(), function ($session) use ($request) {
|
||||
$session->setId($request->cookies->get($session->getName()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the garbage from the session if necessary.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function collectGarbage(Session $session)
|
||||
{
|
||||
$config = $this->manager->getSessionConfig();
|
||||
|
||||
// Here we will see if this request hits the garbage collection lottery by hitting
|
||||
// the odds needed to perform garbage collection on any given request. If we do
|
||||
// hit it, we'll call this handler to let it delete all the expired sessions.
|
||||
if ($this->configHitsLottery($config)) {
|
||||
$session->getHandler()->gc($this->getSessionLifetimeInSeconds());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the configuration odds hit the lottery.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function configHitsLottery(array $config)
|
||||
{
|
||||
return random_int(1, $config['lottery'][1]) <= $config['lottery'][0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the current URL for the request if necessary.
|
||||
*
|
||||
* @param \Illuminate\Contracts\Session\Session $session
|
||||
* @return void
|
||||
*/
|
||||
protected function storeCurrentUrl(IncomingRequest $request, $session)
|
||||
{
|
||||
// Only full-page navigations set the "previous URL" used for back-redirects.
|
||||
// HTMX partials must not, otherwise every background widget load would dirty
|
||||
// the session and force a needless lock-merge-save.
|
||||
if (
|
||||
$request->isMethod('GET')
|
||||
&& ! $request->isHtmxRequest()
|
||||
&& $this->shouldPersistSession($request)
|
||||
) {
|
||||
$session->setPreviousUrl($request->fullUrl());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the session cookie to the application response.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function addCookieToResponse(Response $response, Session $session)
|
||||
{
|
||||
if ($this->sessionIsPersistent($config = $this->manager->getSessionConfig())) {
|
||||
$response->headers->setCookie(new Cookie(
|
||||
$session->getName(),
|
||||
$session->getId(),
|
||||
$this->getCookieExpirationDate(),
|
||||
$config['path'],
|
||||
$config['domain'],
|
||||
$config['secure'] ?? false,
|
||||
$config['http_only'] ?? true,
|
||||
false,
|
||||
$config['same_site'] ?? null,
|
||||
$config['partitioned'] ?? false
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether this request should persist its session to storage.
|
||||
* API and cron requests are stateful-but-throwaway and are never persisted.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function shouldPersistSession(IncomingRequest $request)
|
||||
{
|
||||
return $request->isApiOrCronRequest() === false && $this->sessionConfigured();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session lifetime in seconds.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
protected function getSessionLifetimeInSeconds()
|
||||
{
|
||||
return ($this->manager->getSessionConfig()['lifetime'] ?? null) * 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cookie lifetime in seconds.
|
||||
*
|
||||
* @return \DateTimeInterface|int
|
||||
*/
|
||||
protected function getCookieExpirationDate()
|
||||
{
|
||||
$config = $this->manager->getSessionConfig();
|
||||
|
||||
return $config['expire_on_close'] ? 0 : Date::instance(
|
||||
Carbon::now()->addRealMinutes($config['lifetime'])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a session driver has been configured.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function sessionConfigured()
|
||||
{
|
||||
return ! is_null($this->manager->getSessionConfig()['driver'] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the configured session driver is persistent.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function sessionIsPersistent(?array $config = null)
|
||||
{
|
||||
$config = $config ?: $this->manager->getSessionConfig();
|
||||
|
||||
return ! is_null($config['driver'] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the given cache driver.
|
||||
*
|
||||
* @param string $driver
|
||||
* @return \Illuminate\Contracts\Cache\Repository
|
||||
*/
|
||||
protected function cache($driver)
|
||||
{
|
||||
return Cache::store($driver);
|
||||
}
|
||||
}
|
||||
19
app/Core/Middleware/TrimStrings.php
Normal file
19
app/Core/Middleware/TrimStrings.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Middleware;
|
||||
|
||||
use Illuminate\Foundation\Http\Middleware\TrimStrings as Middleware;
|
||||
|
||||
class TrimStrings extends Middleware
|
||||
{
|
||||
/**
|
||||
* The names of the attributes that should not be trimmed.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $except = [
|
||||
'current_password',
|
||||
'password',
|
||||
'password_confirmation',
|
||||
];
|
||||
}
|
||||
67
app/Core/Middleware/TrustProxies.php
Normal file
67
app/Core/Middleware/TrustProxies.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Class TrustProxies
|
||||
*
|
||||
* The TrustProxies class is responsible for handling incoming requests and checking if they are from trusted proxies.
|
||||
*/
|
||||
class TrustProxies
|
||||
{
|
||||
/**
|
||||
* The trusted proxies for this application.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $proxies = [];
|
||||
|
||||
/**
|
||||
* The headers that should be used to detect proxies.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $headers = IncomingRequest::HEADER_X_FORWARDED_FOR |
|
||||
IncomingRequest::HEADER_X_FORWARDED_HOST |
|
||||
IncomingRequest::HEADER_X_FORWARDED_PORT |
|
||||
IncomingRequest::HEADER_X_FORWARDED_PROTO |
|
||||
IncomingRequest::HEADER_X_FORWARDED_AWS_ELB;
|
||||
|
||||
/**
|
||||
* Constructor for the class.
|
||||
*
|
||||
* @param Environment $config An instance of the Environment class.
|
||||
*/
|
||||
public function __construct(Environment $config)
|
||||
{
|
||||
|
||||
$this->proxies = explode(',', ($config->trustedProxies ?? '127.0.0.1,REMOTE_ADDR'));
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the incoming request and pass it to the next middleware.
|
||||
* If the request is not from a trusted proxy, it returns a response with an error message.
|
||||
*
|
||||
* @param IncomingRequest $request The incoming request.
|
||||
* @param Closure $next The next middleware closure.
|
||||
* @return Response The response returned by the next middleware.
|
||||
*/
|
||||
public function handle(IncomingRequest $request, Closure $next): Response
|
||||
{
|
||||
|
||||
// Trusted proxies config is set in LoadConfig
|
||||
// $request::setTrustedProxies($this->proxies, $this->headers);
|
||||
|
||||
if (! $request->isFromTrustedProxy()) {
|
||||
return new Response(json_encode(['error' => 'Not a trusted proxy']), 403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
108
app/Core/Middleware/Updated.php
Normal file
108
app/Core/Middleware/Updated.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Leantime\Core\Configuration\AppSettings;
|
||||
use Leantime\Core\Controller\Frontcontroller;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class Updated
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
/**
|
||||
* Check if Leantime is installed
|
||||
*
|
||||
* @param \Closure(IncomingRequest): Response $next
|
||||
*
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
**/
|
||||
public function handle(IncomingRequest $request, Closure $next): Response
|
||||
{
|
||||
// For HTMX and API requests, trust the session -- DB version can't change mid-session.
|
||||
if (session('isUpdated') && ($request->isHtmxRequest() || $request->isApiOrCronRequest())) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$cachedDbVersion = session('dbVersion');
|
||||
$dbVersion = $cachedDbVersion ?? app()->make(SettingRepository::class)->getSetting('db-version');
|
||||
$settingsDbVersion = app()->make(AppSettings::class)->dbVersion;
|
||||
|
||||
if ($dbVersion !== false) {
|
||||
// Setting dbVersion only if there is one in the db
|
||||
// Otherwise leave dbVersion unset so we can recheck every time the settings db returns false.
|
||||
session(['dbVersion' => $dbVersion]);
|
||||
}
|
||||
|
||||
$dbVersionInt = $this->getVersionInt($dbVersion);
|
||||
$settingsDbVersionInt = $this->getVersionInt($settingsDbVersion);
|
||||
|
||||
// Self-heal a stale session cache: the cached db-version survives an
|
||||
// update run by ANOTHER session (an admin upgrading the install), which
|
||||
// used to strand every other live session in a redirect loop (any page
|
||||
// -> /install/update -> back again) until their cookies were cleared.
|
||||
// Before concluding "not updated" from a CACHED value, re-read the real
|
||||
// version from the database — one extra query, and only on the path
|
||||
// that would otherwise redirect.
|
||||
if ($dbVersionInt < $settingsDbVersionInt && $cachedDbVersion !== null) {
|
||||
$freshDbVersion = app()->make(SettingRepository::class)->getSetting('db-version');
|
||||
if ($freshDbVersion !== false) {
|
||||
$dbVersion = $freshDbVersion;
|
||||
session(['dbVersion' => $dbVersion]);
|
||||
$dbVersionInt = $this->getVersionInt($dbVersion);
|
||||
}
|
||||
}
|
||||
|
||||
session(['isUpdated' => $dbVersionInt >= $settingsDbVersionInt]);
|
||||
|
||||
if (session('isUpdated')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (! $response = $this->redirectToUpdate()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to update
|
||||
*
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
*/
|
||||
private function redirectToUpdate(): Response|false
|
||||
{
|
||||
$frontController = app()->make(Frontcontroller::class);
|
||||
|
||||
$allowedRoutes = ['install', 'install.update', 'api.i18n'];
|
||||
$allowedRoutes = self::dispatchFilter('allowedRoutes', $allowedRoutes);
|
||||
if (in_array($frontController::getCurrentRoute(), $allowedRoutes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$route = BASE_URL.'/install/update';
|
||||
$route = self::dispatchFilter('redirectroute', $route);
|
||||
|
||||
return $frontController::redirect($route);
|
||||
}
|
||||
|
||||
private function getVersionInt($version)
|
||||
{
|
||||
$versionArray = explode('.', $version);
|
||||
if (is_array($versionArray) && count($versionArray) == 3) {
|
||||
$major = $versionArray[0];
|
||||
$minor = str_pad($versionArray[1], 2, '0', STR_PAD_LEFT);
|
||||
$patch = str_pad($versionArray[2], 2, '0', STR_PAD_LEFT);
|
||||
$newDBVersion = $major.$minor.$patch;
|
||||
|
||||
return $newDBVersion;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
34
app/Core/Middleware/VerifyCsrfToken.php
Normal file
34
app/Core/Middleware/VerifyCsrfToken.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Middleware;
|
||||
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as BaseVerifier;
|
||||
|
||||
/**
|
||||
* Verifies CSRF tokens on state-changing requests.
|
||||
*
|
||||
* Extends Laravel's built-in CSRF middleware. The token is read from
|
||||
* the session and checked against the `_token` POST field or the
|
||||
* `X-CSRF-TOKEN` header (used by HTMX via hx-headers on the body tag).
|
||||
*
|
||||
* Routes that use their own authentication (API keys, webhooks, cron)
|
||||
* are excluded since they don't rely on browser sessions.
|
||||
*/
|
||||
class VerifyCsrfToken extends BaseVerifier
|
||||
{
|
||||
/**
|
||||
* Routes excluded from CSRF verification.
|
||||
*
|
||||
* API endpoints use API-key / Sanctum auth, not session cookies.
|
||||
* Cron and webhook endpoints are server-to-server calls.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $except = [
|
||||
'api/*',
|
||||
'cron/*',
|
||||
'webhook/*',
|
||||
'install',
|
||||
'install/*',
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user