NavigationContextService.php 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. <?php
  2. namespace App\Services;
  3. use Illuminate\Http\Request;
  4. use Illuminate\Support\Str;
  5. class NavigationContextService
  6. {
  7. private const SESSION_KEY = 'navigation';
  8. private const MAX_CONTEXTS = 30;
  9. private const MAX_STACK_SIZE = 20;
  10. private const TTL_SECONDS = 86400;
  11. public function getOrCreateToken(Request $request): string
  12. {
  13. $this->pruneExpired();
  14. $token = trim((string) $request->input('nav', ''));
  15. if ($token !== '' && $this->contextExists($token)) {
  16. return $token;
  17. }
  18. $token = $this->createToken();
  19. $this->saveContext($token, [
  20. 'updated_at' => time(),
  21. 'stack' => [],
  22. ]);
  23. return $token;
  24. }
  25. public function createToken(): string
  26. {
  27. $this->pruneExpired();
  28. $token = bin2hex(random_bytes(8));
  29. $this->saveContext($token, [
  30. 'updated_at' => time(),
  31. 'stack' => [],
  32. ]);
  33. return $token;
  34. }
  35. public function rememberCurrentPage(Request $request, string $token): void
  36. {
  37. if (!$this->isGetPage($request)) {
  38. return;
  39. }
  40. $this->appendUrl($token, $request->fullUrl());
  41. }
  42. public function backUrl(Request $request, string $token, ?string $fallback = null): ?string
  43. {
  44. $context = $this->context($token);
  45. $stack = $context['stack'] ?? [];
  46. if (empty($stack)) {
  47. return $this->appendNav($fallback, $token);
  48. }
  49. $currentUrl = $this->normalizeUrl($request->fullUrl());
  50. $lastUrl = end($stack);
  51. if ($lastUrl === $currentUrl) {
  52. $previousUrl = prev($stack);
  53. return $this->appendNav($previousUrl !== false ? $previousUrl : $fallback, $token);
  54. }
  55. return $this->appendNav($lastUrl ?: $fallback, $token);
  56. }
  57. public function parentUrl(string $token, ?string $fallback = null): ?string
  58. {
  59. $context = $this->context($token);
  60. $stack = $context['stack'] ?? [];
  61. if (empty($stack)) {
  62. return $this->appendNav($fallback, $token);
  63. }
  64. $lastUrl = array_pop($stack);
  65. if (empty($stack)) {
  66. return $this->appendNav($fallback ?? $lastUrl, $token);
  67. }
  68. return $this->appendNav(end($stack) ?: $fallback, $token);
  69. }
  70. public function routeParams(array $params, string $token): array
  71. {
  72. return array_merge($params, ['nav' => $token]);
  73. }
  74. public function pruneExpired(): void
  75. {
  76. $contexts = session(self::SESSION_KEY, []);
  77. if (empty($contexts)) {
  78. return;
  79. }
  80. $now = time();
  81. foreach ($contexts as $token => $context) {
  82. $updatedAt = (int) ($context['updated_at'] ?? 0);
  83. if ($updatedAt < ($now - self::TTL_SECONDS)) {
  84. unset($contexts[$token]);
  85. }
  86. }
  87. if (count($contexts) > self::MAX_CONTEXTS) {
  88. uasort($contexts, static fn (array $left, array $right) => ($left['updated_at'] ?? 0) <=> ($right['updated_at'] ?? 0));
  89. $contexts = array_slice($contexts, -self::MAX_CONTEXTS, null, true);
  90. }
  91. session([self::SESSION_KEY => $contexts]);
  92. }
  93. public function forgetToken(string $token): void
  94. {
  95. $contexts = session(self::SESSION_KEY, []);
  96. unset($contexts[$token]);
  97. session([self::SESSION_KEY => $contexts]);
  98. }
  99. private function appendUrl(string $token, string $url): void
  100. {
  101. $normalizedUrl = $this->normalizeUrl($url);
  102. if ($normalizedUrl === '') {
  103. return;
  104. }
  105. $context = $this->context($token);
  106. $stack = $context['stack'] ?? [];
  107. $existingIndex = array_search($normalizedUrl, $stack, true);
  108. if ($existingIndex !== false) {
  109. $stack = array_slice($stack, 0, $existingIndex + 1);
  110. } else {
  111. $stack[] = $normalizedUrl;
  112. }
  113. if (count($stack) > self::MAX_STACK_SIZE) {
  114. $stack = array_slice($stack, -self::MAX_STACK_SIZE);
  115. }
  116. $context['updated_at'] = time();
  117. $context['stack'] = $stack;
  118. $this->saveContext($token, $context);
  119. }
  120. private function normalizeUrl(string $url): string
  121. {
  122. $parts = parse_url($url);
  123. if ($parts === false || empty($parts['path'])) {
  124. return '';
  125. }
  126. $query = [];
  127. if (!empty($parts['query'])) {
  128. parse_str($parts['query'], $query);
  129. unset($query['nav']);
  130. }
  131. $normalizedUrl = '';
  132. if (!empty($parts['scheme'])) {
  133. $normalizedUrl .= $parts['scheme'] . '://';
  134. }
  135. if (!empty($parts['user'])) {
  136. $normalizedUrl .= $parts['user'];
  137. if (!empty($parts['pass'])) {
  138. $normalizedUrl .= ':' . $parts['pass'];
  139. }
  140. $normalizedUrl .= '@';
  141. }
  142. if (!empty($parts['host'])) {
  143. $normalizedUrl .= $parts['host'];
  144. }
  145. if (!empty($parts['port'])) {
  146. $normalizedUrl .= ':' . $parts['port'];
  147. }
  148. $normalizedUrl .= $parts['path'];
  149. if (!empty($query)) {
  150. $normalizedUrl .= '?' . http_build_query($query);
  151. }
  152. return $normalizedUrl;
  153. }
  154. private function appendNav(?string $url, string $token): ?string
  155. {
  156. if (empty($url)) {
  157. return $url;
  158. }
  159. $parts = parse_url($url);
  160. if ($parts === false || empty($parts['path'])) {
  161. return $url;
  162. }
  163. $query = [];
  164. if (!empty($parts['query'])) {
  165. parse_str($parts['query'], $query);
  166. }
  167. $query['nav'] = $token;
  168. $rebuiltUrl = '';
  169. if (!empty($parts['scheme'])) {
  170. $rebuiltUrl .= $parts['scheme'] . '://';
  171. }
  172. if (!empty($parts['user'])) {
  173. $rebuiltUrl .= $parts['user'];
  174. if (!empty($parts['pass'])) {
  175. $rebuiltUrl .= ':' . $parts['pass'];
  176. }
  177. $rebuiltUrl .= '@';
  178. }
  179. if (!empty($parts['host'])) {
  180. $rebuiltUrl .= $parts['host'];
  181. }
  182. if (!empty($parts['port'])) {
  183. $rebuiltUrl .= ':' . $parts['port'];
  184. }
  185. $rebuiltUrl .= $parts['path'];
  186. $rebuiltUrl .= '?' . http_build_query($query);
  187. return $rebuiltUrl;
  188. }
  189. private function isGetPage(Request $request): bool
  190. {
  191. return strtoupper($request->method()) === 'GET';
  192. }
  193. private function contextExists(string $token): bool
  194. {
  195. return array_key_exists($token, session(self::SESSION_KEY, []));
  196. }
  197. private function context(string $token): array
  198. {
  199. return session(self::SESSION_KEY . '.' . $token, [
  200. 'updated_at' => time(),
  201. 'stack' => [],
  202. ]);
  203. }
  204. private function saveContext(string $token, array $context): void
  205. {
  206. session([self::SESSION_KEY . '.' . $token => $context]);
  207. }
  208. }