OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View 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);
}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}

View 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'));
});
}
}

View 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),
];
}
}

View 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();
}
}

View 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);
}
}

View 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',
];
}

View 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);
}
}

View 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;
}
}

View 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/*',
];
}