Просмотр исходного кода

Merge pull request #2050 from causefx/v2-develop

V2 develop
causefx 1 месяц назад
Родитель
Сommit
d67f4004b3
43 измененных файлов с 4586 добавлено и 2689 удалено
  1. 99 1
      api/classes/organizr.class.php
  2. 1 1
      api/classes/ping.class.php
  3. 7 1
      api/composer.json
  4. 18 15
      api/composer.lock
  5. 45 1
      api/config/default.php
  6. 35 22
      api/functions/UptimeKumaMetrics.php
  7. 602 0
      api/functions/oidc-functions.php
  8. 32 0
      api/functions/organizr-functions.php
  9. 2 2
      api/functions/sso-functions.php
  10. 1 0
      api/homepage/pihole.php
  11. 1 1
      api/pages/error.php
  12. 14 0
      api/pages/login.php
  13. 19 1
      api/plugins/invites/config.php
  14. 541 5
      api/plugins/invites/plugin.php
  15. 4 0
      api/v2/.htaccess
  16. 2 1
      api/v2/index.php
  17. 100 0
      api/v2/routes/oidc.php
  18. 0 8
      api/vendor/Expectation.php
  19. 12 2
      api/vendor/autoload.php
  20. 72 65
      api/vendor/composer/ClassLoader.php
  21. 58 14
      api/vendor/composer/InstalledVersions.php
  22. 1 1
      api/vendor/composer/autoload_psr4.php
  23. 10 17
      api/vendor/composer/autoload_real.php
  24. 82 82
      api/vendor/composer/autoload_static.php
  25. 10 10
      api/vendor/composer/installed.json
  26. 71 71
      api/vendor/composer/installed.php
  27. 2 3
      api/vendor/composer/platform_check.php
  28. 20 233
      api/vendor/league/oauth2-client/README.md
  29. 3 3
      api/vendor/league/oauth2-client/composer.json
  30. 102 4
      api/vendor/league/oauth2-client/src/Provider/AbstractProvider.php
  31. 2 2
      api/vendor/league/oauth2-client/src/Provider/Exception/IdentityProviderException.php
  32. 14 0
      api/vendor/league/oauth2-client/src/Provider/GenericProvider.php
  33. 2 0
      api/vendor/league/oauth2-client/src/Token/AccessTokenInterface.php
  34. 1 1
      api/vendor/league/oauth2-client/src/Tool/QueryBuilderTrait.php
  35. 681 550
      css/organizr.css
  36. 0 139
      debug_jellystat_metadata.php
  37. 55 0
      docs/api.json
  38. 1727 1228
      js/functions.js
  39. 137 128
      js/news.json
  40. 0 0
      js/version.json
  41. 1 1
      scripts/linux-update.sh
  42. 0 39
      test_debug.php
  43. 0 37
      test_jellystat_api.html

+ 99 - 1
api/classes/organizr.class.php

@@ -17,6 +17,7 @@ class Organizr
 	use NetDataFunctions;
 	use NormalFunctions;
 	use OAuthFunctions;
+	use OIDCFunctions;
 	use OptionsFunction;
 	use OrganizrFunctions;
 	use PluginFunctions;
@@ -72,7 +73,7 @@ class Organizr
 
 	// ===================================
 	// Organizr Version
-	public $version = '2.1.3180';
+	public $version = '2.1.4';
 	// ===================================
 	// Quick php Version check
 	public $minimumPHP = '7.4';
@@ -1670,6 +1671,34 @@ class Organizr
 		return $this->processQueries($response);
 	}
 
+	public function getUserByUsername($username)
+	{
+		$response = [
+			array(
+				'function' => 'fetch',
+				'query' => array(
+					'SELECT * FROM users WHERE username = ? COLLATE NOCASE',
+					$username
+				)
+			)
+		];
+		return $this->processQueries($response);
+	}
+
+	public function updateUserById($id, $updates)
+	{
+		if (empty($updates) || !$id) {
+			return false;
+		}
+		try {
+			$this->db->query('UPDATE [users] SET', $updates, 'WHERE id = ?', $id);
+			return true;
+		} catch (Exception $e) {
+			$this->setLoggerChannel('User')->error('Failed to update user: ' . $e->getMessage());
+			return false;
+		}
+	}
+
 	protected function invalidToken($token)
 	{
 		if (isset($_COOKIE[$this->cookieName])) {
@@ -2624,6 +2653,71 @@ class Organizr
 				$this->settingsOption('password', 'komgaFallbackPassword', ['label' => 'Komga Fallback Password']),
 				$this->settingsOption('password', 'komgaSSOMasterPassword', ['label' => 'Komga Master Password', 'help' => 'Sets master password if using oAuth backend - This will set the password on the login form for logins using oAuth where no password is supplied.']),
 			],
+			'OIDC Global' => [
+				$this->settingsOption('enable', 'oidcEnabled', ['label' => 'Enable OIDC Authentication']),
+				$this->settingsOption('switch', 'oidcAutoRedirect', ['label' => 'Auto-Redirect to OIDC', 'help' => 'Automatically redirect to OIDC provider on login page (add #noredirect to URL to bypass)']),
+				$this->settingsOption('select', 'oidcAutoRedirectProvider', ['label' => 'Auto-Redirect Provider', 'options' => [
+					['name' => 'None', 'value' => ''],
+					['name' => 'Authentik', 'value' => 'authentik'],
+					['name' => 'Keycloak', 'value' => 'keycloak'],
+					['name' => 'PocketID', 'value' => 'pocketid'],
+					['name' => 'Zitadel', 'value' => 'zitadel'],
+				]]),
+				$this->settingsOption('url', 'oidcAutoRedirectLogoutUrl', ['label' => 'Custom Logout URL', 'help' => 'Redirect to this URL after logout (e.g., OIDC provider logout endpoint)']),
+				$this->settingsOption('blank'),
+				$this->settingsOption('switch', 'oidcAutoCreateUsers', ['label' => 'Auto-Create Users', 'help' => 'Automatically create users on first OIDC login']),
+				$this->settingsOption('switch', 'oidcLinkExistingUsers', ['label' => 'Link Existing Users', 'help' => 'Link OIDC accounts to existing Organizr users by email']),
+				$this->settingsOption('switch', 'oidcUpdateGroupsOnLogin', ['label' => 'Update Groups on Login', 'help' => 'Re-sync group membership from OIDC on each login']),
+				$this->settingsOption('select', 'oidcDefaultGroupId', ['label' => 'Default Group', 'help' => 'Group assigned to new OIDC users if no mapping matches', 'options' => $this->groupSelect()]),
+				$this->settingsOption('blank'),
+				$this->settingsOption('input', 'oidcGroupClaimName', ['label' => 'Group Claim Name', 'placeholder' => 'groups', 'help' => 'JWT claim containing user groups']),
+				$this->settingsOption('select', 'oidcGroupMappingMode', ['label' => 'Group Mapping Mode', 'help' => 'How to handle multiple matching groups', 'options' => [
+					['name' => 'First Match', 'value' => 'first'],
+					['name' => 'Highest Privilege (lowest ID)', 'value' => 'highest_privilege'],
+					['name' => 'Lowest Privilege (highest ID)', 'value' => 'lowest_privilege'],
+				]]),
+				$this->settingsOption('textbox', 'oidcGroupMappings', ['label' => 'Group Mappings (JSON)', 'help' => 'Map OIDC groups to Organizr group IDs. Example: {"oidc-admins": 0, "oidc-users": 3}']),
+			],
+			'OIDC: Authentik' => [
+				$this->settingsOption('enable', 'oidcAuthentikEnabled', ['label' => 'Enable Authentik']),
+				$this->settingsOption('input', 'oidcAuthentikName', ['label' => 'Display Name', 'placeholder' => 'Authentik']),
+				$this->settingsOption('url', 'oidcAuthentikDiscoveryUrl', ['label' => 'Discovery URL', 'help' => 'e.g., https://authentik.example.com/application/o/organizr/.well-known/openid-configuration']),
+				$this->settingsOption('input', 'oidcAuthentikClientId', ['label' => 'Client ID']),
+				$this->settingsOption('password', 'oidcAuthentikClientSecret', ['label' => 'Client Secret']),
+				$this->settingsOption('input', 'oidcAuthentikScopes', ['label' => 'Scopes', 'placeholder' => 'openid,profile,email,groups']),
+				$this->settingsOption('input', 'oidcAuthentikGroupClaim', ['label' => 'Group Claim Override', 'placeholder' => 'groups', 'help' => 'Leave empty to use global setting']),
+				$this->settingsOption('button', 'testOIDCAuthentik', ['label' => 'Test Connection', 'icon' => 'fa fa-plug', 'text' => 'Test', 'attr' => 'onclick="testOIDCConnection(\'authentik\')"']),
+			],
+			'OIDC: Keycloak' => [
+				$this->settingsOption('enable', 'oidcKeycloakEnabled', ['label' => 'Enable Keycloak']),
+				$this->settingsOption('input', 'oidcKeycloakName', ['label' => 'Display Name', 'placeholder' => 'Keycloak']),
+				$this->settingsOption('url', 'oidcKeycloakDiscoveryUrl', ['label' => 'Discovery URL', 'help' => 'e.g., https://keycloak.example.com/realms/master/.well-known/openid-configuration']),
+				$this->settingsOption('input', 'oidcKeycloakClientId', ['label' => 'Client ID']),
+				$this->settingsOption('password', 'oidcKeycloakClientSecret', ['label' => 'Client Secret']),
+				$this->settingsOption('input', 'oidcKeycloakScopes', ['label' => 'Scopes', 'placeholder' => 'openid,profile,email']),
+				$this->settingsOption('input', 'oidcKeycloakGroupClaim', ['label' => 'Group Claim Override', 'placeholder' => 'groups', 'help' => 'Leave empty to use global setting']),
+				$this->settingsOption('button', 'testOIDCKeycloak', ['label' => 'Test Connection', 'icon' => 'fa fa-plug', 'text' => 'Test', 'attr' => 'onclick="testOIDCConnection(\'keycloak\')"']),
+			],
+			'OIDC: PocketID' => [
+				$this->settingsOption('enable', 'oidcPocketidEnabled', ['label' => 'Enable PocketID']),
+				$this->settingsOption('input', 'oidcPocketidName', ['label' => 'Display Name', 'placeholder' => 'PocketID']),
+				$this->settingsOption('url', 'oidcPocketidDiscoveryUrl', ['label' => 'Discovery URL', 'help' => 'e.g., https://pocketid.example.com/.well-known/openid-configuration']),
+				$this->settingsOption('input', 'oidcPocketidClientId', ['label' => 'Client ID']),
+				$this->settingsOption('password', 'oidcPocketidClientSecret', ['label' => 'Client Secret']),
+				$this->settingsOption('input', 'oidcPocketidScopes', ['label' => 'Scopes', 'placeholder' => 'openid,profile,email']),
+				$this->settingsOption('input', 'oidcPocketidGroupClaim', ['label' => 'Group Claim Override', 'placeholder' => 'groups', 'help' => 'Leave empty to use global setting']),
+				$this->settingsOption('button', 'testOIDCPocketid', ['label' => 'Test Connection', 'icon' => 'fa fa-plug', 'text' => 'Test', 'attr' => 'onclick="testOIDCConnection(\'pocketid\')"']),
+			],
+			'OIDC: Zitadel' => [
+				$this->settingsOption('enable', 'oidcZitadelEnabled', ['label' => 'Enable Zitadel']),
+				$this->settingsOption('input', 'oidcZitadelName', ['label' => 'Display Name', 'placeholder' => 'Zitadel']),
+				$this->settingsOption('url', 'oidcZitadelDiscoveryUrl', ['label' => 'Discovery URL', 'help' => 'e.g., https://zitadel.example.com/.well-known/openid-configuration']),
+				$this->settingsOption('input', 'oidcZitadelClientId', ['label' => 'Client ID']),
+				$this->settingsOption('password', 'oidcZitadelClientSecret', ['label' => 'Client Secret']),
+				$this->settingsOption('input', 'oidcZitadelScopes', ['label' => 'Scopes', 'placeholder' => 'openid,profile,email,urn:zitadel:iam:org:project:roles']),
+				$this->settingsOption('input', 'oidcZitadelGroupClaim', ['label' => 'Group Claim Override', 'placeholder' => 'urn:zitadel:iam:org:project:roles', 'help' => 'Zitadel uses a special claim format for roles']),
+				$this->settingsOption('button', 'testOIDCZitadel', ['label' => 'Test Connection', 'icon' => 'fa fa-plug', 'text' => 'Test', 'attr' => 'onclick="testOIDCConnection(\'zitadel\')"']),
+			],
 		];
 	}
 
@@ -2919,6 +3013,10 @@ class Organizr
 
 	public function wizardConfig($array)
 	{
+		if($this->hasConfig() && $this->hasDB()) {
+			$this->setAPIResponse('error', 'Endpoint disabled as database already exists', 401);
+			return false;
+		}
 		$array['driver'] = $array['driver'] ?? 'sqlite3';
 		$driver = $this->formatDatabaseDriver($array['driver']);
 		$dbName = $array['dbName'] ?? null;

+ 1 - 1
api/classes/ping.class.php

@@ -219,7 +219,7 @@ class Ping
 		exec($exec_string, $output, $return);
 		// Strip empty lines and reorder the indexes from 0 (to make results more
 		// uniform across OS versions).
-		$this->commandOutput = implode($output, '');
+		$this->commandOutput = implode('', $output);
 		$output = array_values(array_filter($output));
 		// If the result line in the output is not empty, parse it.
 		if (!empty($output[1])) {

+ 7 - 1
api/composer.json

@@ -22,6 +22,12 @@
     "peppeocchi/php-cron-scheduler": "^4.0",
     "simshaun/recurr": "^5.0",
     "stripe/stripe-php": "^7.116",
-    "ramsey/uuid": "^4.2"
+    "ramsey/uuid": "^4.2",
+    "league/oauth2-client": "^2.7"
+  },
+  "config": {
+    "platform": {
+      "php": "7.4.0"
+    }
   }
 }

+ 18 - 15
api/composer.lock

@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "ca4f53441305ffa3e09c532006fa9c19",
+    "content-hash": "09e533e3077f054c098497069a935661",
     "packages": [
         {
             "name": "adldap2/adldap2",
@@ -1215,27 +1215,27 @@
         },
         {
             "name": "league/oauth2-client",
-            "version": "2.6.0",
+            "version": "2.7.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/oauth2-client.git",
-                "reference": "badb01e62383430706433191b82506b6df24ad98"
+                "reference": "160d6274b03562ebeb55ed18399281d8118b76c8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/badb01e62383430706433191b82506b6df24ad98",
-                "reference": "badb01e62383430706433191b82506b6df24ad98",
+                "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/160d6274b03562ebeb55ed18399281d8118b76c8",
+                "reference": "160d6274b03562ebeb55ed18399281d8118b76c8",
                 "shasum": ""
             },
             "require": {
-                "guzzlehttp/guzzle": "^7.0",
+                "guzzlehttp/guzzle": "^6.0 || ^7.0",
                 "paragonie/random_compat": "^1 || ^2 || ^9.99",
                 "php": "^5.6 || ^7.0 || ^8.0"
             },
             "require-dev": {
-                "mockery/mockery": "^1.3",
-                "php-parallel-lint/php-parallel-lint": "^1.2",
-                "phpunit/phpunit": "^5.7 || ^6.0 || ^9.3",
+                "mockery/mockery": "^1.3.5",
+                "php-parallel-lint/php-parallel-lint": "^1.3.1",
+                "phpunit/phpunit": "^5.7 || ^6.0 || ^9.5",
                 "squizlabs/php_codesniffer": "^2.3 || ^3.0"
             },
             "type": "library",
@@ -1279,9 +1279,9 @@
             ],
             "support": {
                 "issues": "https://github.com/thephpleague/oauth2-client/issues",
-                "source": "https://github.com/thephpleague/oauth2-client/tree/2.6.0"
+                "source": "https://github.com/thephpleague/oauth2-client/tree/2.7.0"
             },
-            "time": "2020-10-28T02:03:40+00:00"
+            "time": "2023-04-16T18:19:15+00:00"
         },
         {
             "name": "monolog/monolog",
@@ -4146,10 +4146,13 @@
     "packages-dev": [],
     "aliases": [],
     "minimum-stability": "stable",
-    "stability-flags": [],
+    "stability-flags": {},
     "prefer-stable": false,
     "prefer-lowest": false,
-    "platform": [],
-    "platform-dev": [],
-    "plugin-api-version": "2.3.0"
+    "platform": {},
+    "platform-dev": {},
+    "platform-overrides": {
+        "php": "7.4.0"
+    },
+    "plugin-api-version": "2.9.0"
 }

+ 45 - 1
api/config/default.php

@@ -770,5 +770,49 @@ return [
 	'socksDebug' => false,
 	'maxSocksDebugSize' => 100,
 	'bypassLoginForLocal' => false,
-	'localLoginUserId' => "1"
+	'localLoginUserId' => "1",
+	// OIDC Global Settings
+	'oidcEnabled' => false,
+	'oidcAutoRedirect' => false,
+	'oidcAutoRedirectProvider' => '',
+	'oidcAutoRedirectLogoutUrl' => '',
+	'oidcAutoCreateUsers' => true,
+	'oidcLinkExistingUsers' => true,
+	'oidcDefaultGroupId' => 4,
+	'oidcGroupClaimName' => 'groups',
+	'oidcGroupMappings' => '{}',
+	'oidcGroupMappingMode' => 'first',
+	'oidcUpdateGroupsOnLogin' => false,
+	// OIDC Authentik Provider
+	'oidcAuthentikEnabled' => false,
+	'oidcAuthentikName' => 'Authentik',
+	'oidcAuthentikClientId' => '',
+	'oidcAuthentikClientSecret' => '',
+	'oidcAuthentikDiscoveryUrl' => '',
+	'oidcAuthentikScopes' => 'openid,profile,email,groups',
+	'oidcAuthentikGroupClaim' => '',
+	// OIDC Keycloak Provider
+	'oidcKeycloakEnabled' => false,
+	'oidcKeycloakName' => 'Keycloak',
+	'oidcKeycloakClientId' => '',
+	'oidcKeycloakClientSecret' => '',
+	'oidcKeycloakDiscoveryUrl' => '',
+	'oidcKeycloakScopes' => 'openid,profile,email',
+	'oidcKeycloakGroupClaim' => '',
+	// OIDC PocketID Provider
+	'oidcPocketidEnabled' => false,
+	'oidcPocketidName' => 'PocketID',
+	'oidcPocketidClientId' => '',
+	'oidcPocketidClientSecret' => '',
+	'oidcPocketidDiscoveryUrl' => '',
+	'oidcPocketidScopes' => 'openid,profile,email',
+	'oidcPocketidGroupClaim' => '',
+	// OIDC Zitadel Provider
+	'oidcZitadelEnabled' => false,
+	'oidcZitadelName' => 'Zitadel',
+	'oidcZitadelClientId' => '',
+	'oidcZitadelClientSecret' => '',
+	'oidcZitadelDiscoveryUrl' => '',
+	'oidcZitadelScopes' => 'openid,profile,email,urn:zitadel:iam:org:project:roles',
+	'oidcZitadelGroupClaim' => 'urn:zitadel:iam:org:project:roles',
 ];

+ 35 - 22
api/functions/UptimeKumaMetrics.php

@@ -21,7 +21,7 @@ class UptimeKumaMetrics
         $latencies = array_filter($processed, function (string $item) {
             return str_starts_with($item, 'monitor_response_time');
         });
-        
+
         $monitors = array_map(function (string $item) {
             return $this->parseMonitorStatus($item);
         }, $monitors);
@@ -37,23 +37,36 @@ class UptimeKumaMetrics
     }
 
     private function parseMonitorStatus(string $status): ?array
-    {  
+    {
         if (substr($status, -1) === '2') {
             return null;
-		}
-
-		$up = (substr($status, -1)) == '0' ? false : true;
-		$status = substr($status, 15);
-		$status = substr($status, 0, -4);
-		$status = explode(',', $status);
-		$data = [
-			'name' => $this->getStringBetweenQuotes($status[0]),
-			'url' => $this->getStringBetweenQuotes($status[2]),
-			'type' => $this->getStringBetweenQuotes($status[1]),
-			'status' => $up,
-		];
-
-		return $data;
+        }
+
+        if (preg_match('/{(.*?)}/', $status, $match) != 1) {
+            return null;
+        }
+
+        $matches = explode(',', $match[1]);
+
+        $data = [];
+        foreach ($matches as $match) {
+            switch (true) {
+                case str_starts_with($match, "monitor_name"):
+                    $data['name'] = $this->getStringBetweenQuotes($match);
+                    break;
+                case str_starts_with($match, "monitor_url"):
+                    $data['url'] = $this->getStringBetweenQuotes($match);
+                    break;
+                case str_starts_with($match, "monitor_type"):
+                    $data['type'] = $this->getStringBetweenQuotes($match);
+                    break;
+            }
+        }
+
+        $up = (substr($status, -1)) == '0' ? false : true;
+        $data['status'] = $up;
+
+        return $data;
     }
 
     private function addLatencyToMonitors(array &$monitors, array $latencies)
@@ -79,10 +92,10 @@ class UptimeKumaMetrics
     }
 
     private function getStringBetweenQuotes(string $input): string
-	{
-		if (preg_match('/"(.*?)"/', $input, $match) == 1) {
-			return $match[1];
-		}
-		return '';
-	} 
+    {
+        if (preg_match('/"(.*?)"/', $input, $match) == 1) {
+            return $match[1];
+        }
+        return '';
+    }
 }

+ 602 - 0
api/functions/oidc-functions.php

@@ -0,0 +1,602 @@
+<?php
+
+trait OIDCFunctions
+{
+	/**
+	 * Provider registry with provider-specific configurations
+	 */
+	private $oidcProviders = [
+		'authentik' => [
+			'configPrefix' => 'oidcAuthentik',
+			'defaultGroupClaim' => 'groups',
+			'supportsEndSession' => true,
+		],
+		'keycloak' => [
+			'configPrefix' => 'oidcKeycloak',
+			'defaultGroupClaim' => 'groups',
+			'supportsEndSession' => true,
+		],
+		'pocketid' => [
+			'configPrefix' => 'oidcPocketid',
+			'defaultGroupClaim' => 'groups',
+			'supportsEndSession' => false,
+		],
+		'zitadel' => [
+			'configPrefix' => 'oidcZitadel',
+			'defaultGroupClaim' => 'urn:zitadel:iam:org:project:roles',
+			'supportsEndSession' => true,
+			'rolesAsObject' => true,
+		],
+	];
+
+	/**
+	 * Get list of enabled OIDC providers
+	 */
+	public function getEnabledOIDCProviders()
+	{
+		$enabled = [];
+		if (!$this->config['oidcEnabled']) {
+			return $enabled;
+		}
+		foreach ($this->oidcProviders as $provider => $config) {
+			$enabledKey = $config['configPrefix'] . 'Enabled';
+			if ($this->config[$enabledKey] ?? false) {
+				$enabled[$provider] = $config;
+			}
+		}
+		return $enabled;
+	}
+
+	/**
+	 * Get provider configuration
+	 */
+	public function getOIDCProviderConfig($provider)
+	{
+		if (!isset($this->oidcProviders[$provider])) {
+			return null;
+		}
+		$config = $this->oidcProviders[$provider];
+		$prefix = $config['configPrefix'];
+		return [
+			'provider' => $provider,
+			'name' => $this->config[$prefix . 'Name'] ?? ucfirst($provider),
+			'clientId' => $this->config[$prefix . 'ClientId'] ?? '',
+			'clientSecret' => ($this->config[$prefix . 'ClientSecret'] ?? ''),
+			'discoveryUrl' => $this->config[$prefix . 'DiscoveryUrl'] ?? '',
+			'scopes' => $this->config[$prefix . 'Scopes'] ?? 'openid,profile,email',
+			'groupClaim' => $this->config[$prefix . 'GroupClaim'] ?: ($config['defaultGroupClaim'] ?? 'groups'),
+			'supportsEndSession' => $config['supportsEndSession'] ?? false,
+			'rolesAsObject' => $config['rolesAsObject'] ?? false,
+		];
+	}
+
+	/**
+	 * Fetch and cache OIDC discovery document
+	 */
+	public function getOIDCDiscovery($provider)
+	{
+		$config = $this->getOIDCProviderConfig($provider);
+		if (!$config || empty($config['discoveryUrl'])) {
+			$this->setLoggerChannel('OIDC')->warning('Discovery URL not configured for provider: ' . $provider);
+			return null;
+		}
+		$cacheKey = 'oidc_discovery_' . $provider;
+		$cached = $_SESSION[$cacheKey] ?? null;
+		if ($cached && (time() - ($cached['fetched_at'] ?? 0)) < 3600) {
+			return $cached['data'];
+		}
+		try {
+			$options = ($this->localURL($config['discoveryUrl'])) ? array('verify' => false) : array('verify' => $this->getCert());
+			$response = Requests::get($config['discoveryUrl'], [], $options);
+			if ($response->success) {
+				$discovery = json_decode($response->body, true);
+				$_SESSION[$cacheKey] = [
+					'data' => $discovery,
+					'fetched_at' => time(),
+				];
+				return $discovery;
+			}
+			$this->setLoggerChannel('OIDC')->warning('Failed to fetch discovery document: ' . $response->status_code);
+			return null;
+		} catch (Requests_Exception $e) {
+			$this->setLoggerChannel('OIDC')->error($e);
+			return null;
+		}
+	}
+
+	/**
+	 * Generate PKCE code verifier and challenge
+	 */
+	public function generatePKCE()
+	{
+		$codeVerifier = bin2hex(random_bytes(32));
+		$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
+		return [
+			'code_verifier' => $codeVerifier,
+			'code_challenge' => $codeChallenge,
+			'code_challenge_method' => 'S256',
+		];
+	}
+
+	/**
+	 * Generate state parameter for CSRF protection
+	 */
+	public function generateOIDCState($provider)
+	{
+		$state = bin2hex(random_bytes(16));
+		$_SESSION['oidc_state'] = $state;
+		$_SESSION['oidc_state_provider'] = $provider;
+		$_SESSION['oidc_state_time'] = time();
+		return $state;
+	}
+
+	/**
+	 * Validate state parameter
+	 */
+	public function validateOIDCState($state, $provider)
+	{
+		$storedState = $_SESSION['oidc_state'] ?? null;
+		$storedProvider = $_SESSION['oidc_state_provider'] ?? null;
+		$stateTime = $_SESSION['oidc_state_time'] ?? 0;
+		// State expires after 10 minutes
+		if (time() - $stateTime > 600) {
+			$this->setLoggerChannel('OIDC')->warning('OIDC state expired');
+			return false;
+		}
+		if (!hash_equals($storedState ?? '', $state) || $storedProvider !== $provider) {
+			$this->setLoggerChannel('OIDC')->warning('OIDC state mismatch');
+			return false;
+		}
+		// Clear used state
+		unset($_SESSION['oidc_state'], $_SESSION['oidc_state_provider'], $_SESSION['oidc_state_time']);
+		return true;
+	}
+
+	/**
+	 * Generate authorization URL for OIDC provider
+	 */
+	public function getOIDCAuthorizationUrl($provider)
+	{
+		$config = $this->getOIDCProviderConfig($provider);
+		if (!$config) {
+			return null;
+		}
+		$discovery = $this->getOIDCDiscovery($provider);
+		if (!$discovery || empty($discovery['authorization_endpoint'])) {
+			$this->setLoggerChannel('OIDC')->warning('Authorization endpoint not found in discovery');
+			return null;
+		}
+		$pkce = $this->generatePKCE();
+		$_SESSION['oidc_code_verifier'] = $pkce['code_verifier'];
+		$state = $this->generateOIDCState($provider);
+		$nonce = bin2hex(random_bytes(16));
+		$_SESSION['oidc_nonce'] = $nonce;
+		$scopes = str_replace(',', ' ', $config['scopes']);
+		$params = [
+			'response_type' => 'code',
+			'client_id' => $config['clientId'],
+			'redirect_uri' => $this->getServerPath() . 'api/v2/oidc/' . $provider . '/callback',
+			'scope' => $scopes,
+			'state' => $state,
+			'nonce' => $nonce,
+			'code_challenge' => $pkce['code_challenge'],
+			'code_challenge_method' => $pkce['code_challenge_method'],
+		];
+		return $discovery['authorization_endpoint'] . '?' . http_build_query($params);
+	}
+
+	/**
+	 * Exchange authorization code for tokens
+	 */
+	public function exchangeOIDCCode($provider, $code)
+	{
+		$config = $this->getOIDCProviderConfig($provider);
+		if (!$config) {
+			return null;
+		}
+		$discovery = $this->getOIDCDiscovery($provider);
+		if (!$discovery || empty($discovery['token_endpoint'])) {
+			$this->setLoggerChannel('OIDC')->warning('Token endpoint not found in discovery');
+			return null;
+		}
+		$codeVerifier = $_SESSION['oidc_code_verifier'] ?? '';
+		unset($_SESSION['oidc_code_verifier']);
+		$data = [
+			'grant_type' => 'authorization_code',
+			'code' => $code,
+			'redirect_uri' => $this->getServerPath() . 'api/v2/oidc/' . $provider . '/callback',
+			'client_id' => $config['clientId'],
+			'client_secret' => $config['clientSecret'],
+			'code_verifier' => $codeVerifier,
+		];
+		try {
+			$options = ($this->localURL($discovery['token_endpoint'])) ? array('verify' => false) : array('verify' => $this->getCert());
+			$response = Requests::post($discovery['token_endpoint'], [
+				'Content-Type' => 'application/x-www-form-urlencoded',
+			], http_build_query($data), $options);
+			if ($response->success) {
+				$tokens = json_decode($response->body, true);
+				$this->setLoggerChannel('OIDC')->debug('Token exchange successful for provider: ' . $provider);
+				return $tokens;
+			}
+			$this->setLoggerChannel('OIDC')->warning('Token exchange failed: ' . $response->body);
+			return null;
+		} catch (Requests_Exception $e) {
+			$this->setLoggerChannel('OIDC')->error($e);
+			return null;
+		}
+	}
+
+	/**
+	 * Get user info from OIDC provider
+	 */
+	public function getOIDCUserInfo($provider, $accessToken)
+	{
+		$discovery = $this->getOIDCDiscovery($provider);
+		if (!$discovery || empty($discovery['userinfo_endpoint'])) {
+			$this->setLoggerChannel('OIDC')->warning('Userinfo endpoint not found, using ID token claims');
+			return null;
+		}
+		try {
+			$options = ($this->localURL($discovery['userinfo_endpoint'])) ? array('verify' => false) : array('verify' => $this->getCert());
+			$response = Requests::get($discovery['userinfo_endpoint'], [
+				'Authorization' => 'Bearer ' . $accessToken,
+			], $options);
+			if ($response->success) {
+				return json_decode($response->body, true);
+			}
+			$this->setLoggerChannel('OIDC')->warning('Userinfo request failed: ' . $response->status_code);
+			return null;
+		} catch (Requests_Exception $e) {
+			$this->setLoggerChannel('OIDC')->error($e);
+			return null;
+		}
+	}
+
+	/**
+	 * Decode JWT ID token (without verification - tokens already verified by provider)
+	 */
+	public function decodeOIDCIdToken($idToken)
+	{
+		$parts = explode('.', $idToken);
+		if (count($parts) !== 3) {
+			return null;
+		}
+		$payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true);
+		return $payload;
+	}
+
+	/**
+	 * Extract groups from OIDC claims
+	 */
+	public function extractOIDCGroups($provider, $claims)
+	{
+		$config = $this->getOIDCProviderConfig($provider);
+		$groupClaim = $config['groupClaim'] ?? $this->config['oidcGroupClaimName'];
+		$groups = $claims[$groupClaim] ?? [];
+		// Handle Zitadel's role format (object keys)
+		if ($provider === 'zitadel' && ($config['rolesAsObject'] ?? false)) {
+			if (is_array($groups) && !empty($groups) && !isset($groups[0])) {
+				$groups = array_keys($groups);
+			}
+		}
+		// Ensure array format
+		if (is_string($groups)) {
+			$groups = [$groups];
+		}
+		return $groups;
+	}
+
+	/**
+	 * Map OIDC groups to Organizr group ID
+	 */
+	public function mapOIDCGroupToOrganizr($oidcGroups)
+	{
+		$mappings = json_decode($this->config['oidcGroupMappings'] ?? '{}', true) ?: [];
+		$mode = $this->config['oidcGroupMappingMode'] ?? 'first';
+		$matchedGroups = [];
+		foreach ($oidcGroups as $oidcGroup) {
+			// Direct match
+			if (isset($mappings[$oidcGroup])) {
+				$matchedGroups[] = (int)$mappings[$oidcGroup];
+				continue;
+			}
+			// Try matching without leading slash (Keycloak nested groups)
+			$stripped = ltrim($oidcGroup, '/');
+			if (isset($mappings[$stripped])) {
+				$matchedGroups[] = (int)$mappings[$stripped];
+				continue;
+			}
+			// Try matching last segment only
+			$segments = explode('/', $stripped);
+			$lastSegment = end($segments);
+			if (isset($mappings[$lastSegment])) {
+				$matchedGroups[] = (int)$mappings[$lastSegment];
+			}
+		}
+		if (empty($matchedGroups)) {
+			// Fallback to default group
+			$defaultGroupId = $this->config['oidcDefaultGroupId'];
+			if ($defaultGroupId !== null && $defaultGroupId !== '') {
+				return (int)$defaultGroupId;
+			}
+			// Use system default
+			return $this->getDefaultGroupId();
+		}
+		switch ($mode) {
+			case 'highest_privilege':
+				// Lower group_id = higher privilege in Organizr (0 = admin)
+				return min($matchedGroups);
+			case 'lowest_privilege':
+				return max($matchedGroups);
+			case 'first':
+			default:
+				return $matchedGroups[0];
+		}
+	}
+
+	/**
+	 * Get default group ID from system settings
+	 */
+	private function getDefaultGroupId()
+	{
+		// Default to group ID 3 (User) if not configured
+		return 3;
+	}
+
+	/**
+	 * Create or link user from OIDC claims
+	 */
+	public function createOrLinkOIDCUser($provider, $userInfo, $oidcGroups)
+	{
+		$email = $userInfo['email'] ?? '';
+		$username = $userInfo['preferred_username'] ?? $userInfo['name'] ?? $userInfo['sub'] ?? '';
+		$image = $userInfo['picture'] ?? '';
+		if (empty($username)) {
+			$this->setLoggerChannel('OIDC')->warning('No username available from OIDC claims');
+			return null;
+		}
+		$groupId = $this->mapOIDCGroupToOrganizr($oidcGroups);
+		$group = $this->getGroupById($groupId);
+		$groupName = $group['group'] ?? 'User';
+		// Check if user exists by username
+		$existingUser = $this->getUserByUsername($username);
+		if ($existingUser) {
+			// Update auth_service and optionally group
+			$updates = ['auth_service' => 'oidc::' . $provider];
+			if ($this->config['oidcUpdateGroupsOnLogin']) {
+				$updates['group_id'] = $groupId;
+				$updates['group'] = $groupName;
+			}
+			$this->updateUserById($existingUser['id'], $updates);
+			$this->setLoggerChannel('OIDC')->info('Linked existing user: ' . $username);
+			return $existingUser;
+		}
+		// Check if user exists by email and linking is enabled
+		if (!empty($email) && $this->config['oidcLinkExistingUsers']) {
+			$existingUser = $this->getUserByEmail($email);
+			if ($existingUser) {
+				$updates = ['auth_service' => 'oidc::' . $provider];
+				if ($this->config['oidcUpdateGroupsOnLogin']) {
+					$updates['group_id'] = $groupId;
+					$updates['group'] = $groupName;
+				}
+				$this->updateUserById($existingUser['id'], $updates);
+				$this->setLoggerChannel('OIDC')->info('Linked user by email: ' . $email);
+				return $existingUser;
+			}
+		}
+		// Create new user if auto-create is enabled
+		if (!$this->config['oidcAutoCreateUsers']) {
+			$this->setLoggerChannel('OIDC')->warning('User not found and auto-create disabled: ' . $username);
+			return null;
+		}
+		// Generate random password (user won't use it)
+		$password = bin2hex(random_bytes(16));
+		$userInfo = [
+			'username' => $username,
+			'password' => password_hash($password, PASSWORD_BCRYPT),
+			'email' => $email,
+			'group' => $groupName,
+			'group_id' => $groupId,
+			'image' => $image ?: $this->gravatar($email),
+			'register_date' => $this->currentTime,
+			'auth_service' => 'oidc::' . $provider,
+		];
+		try {
+			$this->db->query('INSERT INTO [users]', $userInfo);
+			$this->setLoggerChannel('OIDC')->info('Created new OIDC user: ' . $username);
+			return $this->getUserByUsername($username);
+		} catch (Exception $e) {
+			$this->setLoggerChannel('OIDC')->error($e);
+			return null;
+		}
+	}
+
+	/**
+	 * Process OIDC callback
+	 */
+	public function processOIDCCallback($provider, $code, $state)
+	{
+		// Validate state
+		if (!$this->validateOIDCState($state, $provider)) {
+			$this->setAPIResponse('error', 'Invalid or expired state parameter', 400);
+			return false;
+		}
+		// Exchange code for tokens
+		$tokens = $this->exchangeOIDCCode($provider, $code);
+		if (!$tokens || empty($tokens['access_token'])) {
+			$this->setAPIResponse('error', 'Failed to exchange authorization code', 500);
+			return false;
+		}
+		// Get user info from userinfo endpoint or ID token
+		$userInfo = $this->getOIDCUserInfo($provider, $tokens['access_token']);
+		if (!$userInfo && !empty($tokens['id_token'])) {
+			$userInfo = $this->decodeOIDCIdToken($tokens['id_token']);
+		}
+		if (!$userInfo) {
+			$this->setAPIResponse('error', 'Failed to get user information', 500);
+			return false;
+		}
+		// Validate nonce if present
+		$storedNonce = $_SESSION['oidc_nonce'] ?? null;
+		unset($_SESSION['oidc_nonce']);
+		if ($storedNonce && isset($userInfo['nonce']) && !hash_equals($storedNonce, $userInfo['nonce'])) {
+			$this->setLoggerChannel('OIDC')->warning('Nonce mismatch');
+			$this->setAPIResponse('error', 'Invalid nonce', 400);
+			return false;
+		}
+		// Extract groups
+		$groups = $this->extractOIDCGroups($provider, $userInfo);
+		// Create or link user
+		$user = $this->createOrLinkOIDCUser($provider, $userInfo, $groups);
+		if (!$user) {
+			$this->setAPIResponse('error', 'Failed to create or link user', 500);
+			return false;
+		}
+		// Create Organizr token
+		$this->createToken($user['username'], $user['email'], $this->config['rememberMeDays']);
+		$this->setLoggerChannel('OIDC')->info('OIDC login successful: ' . $user['username']);
+		return $user;
+	}
+
+	/**
+	 * Initiate OIDC authorization flow
+	 */
+	public function initiateOIDCFlow($provider)
+	{
+		if (!$this->config['oidcEnabled']) {
+			$this->setAPIResponse('error', 'OIDC is not enabled', 403);
+			return false;
+		}
+		$enabledProviders = $this->getEnabledOIDCProviders();
+		if (!isset($enabledProviders[$provider])) {
+			$this->setAPIResponse('error', 'Provider not enabled: ' . $provider, 404);
+			return false;
+		}
+		$authUrl = $this->getOIDCAuthorizationUrl($provider);
+		if (!$authUrl) {
+			$this->setAPIResponse('error', 'Failed to generate authorization URL', 500);
+			return false;
+		}
+		header('Location: ' . $authUrl);
+		exit;
+	}
+
+	/**
+	 * Test OIDC provider connection
+	 */
+	public function testOIDCConnection($provider)
+	{
+		$config = $this->getOIDCProviderConfig($provider);
+		if (!$config) {
+			$this->setAPIResponse('error', 'Provider not found', 404);
+			return false;
+		}
+		if (empty($config['discoveryUrl'])) {
+			$this->setAPIResponse('error', 'Discovery URL not configured', 422);
+			return false;
+		}
+		if (empty($config['clientId'])) {
+			$this->setAPIResponse('error', 'Client ID not configured', 422);
+			return false;
+		}
+		$discovery = $this->getOIDCDiscovery($provider);
+		if (!$discovery) {
+			$this->setAPIResponse('error', 'Failed to fetch discovery document', 500);
+			return false;
+		}
+		$required = ['authorization_endpoint', 'token_endpoint', 'issuer'];
+		foreach ($required as $field) {
+			if (empty($discovery[$field])) {
+				$this->setAPIResponse('error', 'Discovery document missing: ' . $field, 500);
+				return false;
+			}
+		}
+		$this->setAPIResponse('success', 'OIDC connection successful - Issuer: ' . $discovery['issuer'], 200);
+		return true;
+	}
+
+	/**
+	 * Output callback success page - redirects to Organizr root
+	 */
+	public function outputOIDCCallbackSuccess($username)
+	{
+		$this->setLoggerChannel('OIDC')->info('Redirecting user after successful login: ' . $username);
+		header('Location: ' . $this->getServerPath());
+		exit;
+	}
+
+	/**
+	 * Output callback error page
+	 */
+	public function outputOIDCCallbackError($error)
+	{
+		echo '<!DOCTYPE html>
+<html lang="en">
+<head>
+	<link rel="stylesheet" href="' . $this->getServerPath() . '/css/mvp.css">
+	<meta charset="utf-8">
+	<meta name="description" content="OIDC Login Error">
+	<meta name="viewport" content="width=device-width, initial-scale=1.0">
+	<title>OIDC Login Error</title>
+</head>
+<body>
+	<main>
+		<section>
+			<aside>
+				<h3>Login Failed</h3>
+				<p>' . htmlspecialchars($error) . '</p>
+				<p><a href="javascript:window.close()">Close this window</a></p>
+			</aside>
+		</section>
+	</main>
+</body>
+</html>';
+		exit;
+	}
+
+	/**
+	 * Get OIDC logout URL
+	 */
+	public function getOIDCLogoutUrl($provider)
+	{
+		$config = $this->getOIDCProviderConfig($provider);
+		if (!$config || !$config['supportsEndSession']) {
+			return null;
+		}
+		$discovery = $this->getOIDCDiscovery($provider);
+		if (!$discovery || empty($discovery['end_session_endpoint'])) {
+			return null;
+		}
+		$params = [
+			'client_id' => $config['clientId'],
+			'post_logout_redirect_uri' => $this->getServerPath(),
+		];
+		return $discovery['end_session_endpoint'] . '?' . http_build_query($params);
+	}
+
+	/**
+	 * Check if auto-redirect to OIDC is enabled
+	 */
+	public function shouldAutoRedirectToOIDC()
+	{
+		if (!$this->config['oidcEnabled'] || !$this->config['oidcAutoRedirect']) {
+			return false;
+		}
+		$provider = $this->config['oidcAutoRedirectProvider'] ?? '';
+		if (empty($provider)) {
+			return false;
+		}
+		$enabledProviders = $this->getEnabledOIDCProviders();
+		return isset($enabledProviders[$provider]);
+	}
+
+	/**
+	 * Get auto-redirect provider
+	 */
+	public function getAutoRedirectProvider()
+	{
+		return $this->config['oidcAutoRedirectProvider'] ?? '';
+	}
+}

+ 32 - 0
api/functions/organizr-functions.php

@@ -706,6 +706,38 @@ trait OrganizrFunctions
 		return '<a href="javascript:void(0)" class="text-center db ' . $showLogo . '" id="login-logo">' . $html . '</a>';
 	}
 
+	public function showoAuthOIDC()
+	{
+		$buttons = '';
+		$providers = $this->getEnabledOIDCProviders();
+		foreach ($providers as $provider => $config) {
+			$name = htmlspecialchars($this->config[$config['configPrefix'] . 'Name'] ?? ucfirst($provider));
+			$buttons .= '<a href="javascript:void(0)" onclick="oidcStart(\'' . htmlspecialchars($provider) . '\')" class="btn btn-lg btn-block text-uppercase waves-effect waves-light bg-oidc-' . htmlspecialchars($provider) . ' text-muted"> <span>Login with ' . $name . '</span><i aria-hidden="true" class="mdi mdi-shield-key m-l-5"></i> </a>';
+		}
+		if (!$buttons) {
+			return '';
+		}
+		return '
+		<div class="panel">
+			<div class="panel-heading bg-org" id="oidc-login-heading" role="tab">
+				<a class="panel-title" data-toggle="collapse" href="#oidc-login-collapse" data-parent="#login-panels" aria-expanded="false" aria-controls="oidc-login-collapse">
+					<i class="mdi mdi-shield-account"></i> &nbsp;
+					<span class="text-uppercase fw300" lang="en">Single Sign-On</span>
+				</a>
+			</div>
+			<div class="panel-collapse collapse in" id="oidc-login-collapse" aria-labelledby="oidc-login-heading" role="tabpanel">
+				<div class="panel-body">
+					<div class="row">
+						<div class="col-xs-12 col-sm-12 col-md-12 text-center">
+							<div class="social m-b-0">' . $buttons . '</div>
+						</div>
+					</div>
+				</div>
+			</div>
+		</div>
+		';
+	}
+
 	public function settingsDocker()
 	{
 		$type = ($this->docker) ? 'Official Docker' : 'Native';

+ 2 - 2
api/functions/sso-functions.php

@@ -169,7 +169,7 @@ trait SSOFunctions
 			$credentials = array('auth' => new Requests_Auth_Digest(array($email, $password)));
 			$url = $this->qualifyURL($this->config['komgaURL']);
 			$options = $this->requestOptions($url, $this->getSSOTimeout(), true, false, $credentials);
-			$response = Requests::get($url . '/api/v1/users/me', ['X-Auth-Token' => 'organizrSSO'], $options);
+			$response = Requests::get($url . '/api/v2/users/me', ['X-Auth-Token' => 'organizrSSO'], $options);
 			if ($response->success) {
 				if ($response->headers['x-auth-token']) {
 					$this->setLoggerChannel('Komga')->info('Grabbed token');
@@ -395,4 +395,4 @@ trait SSOFunctions
 			return false;
 		}
 	}
-}
+}

+ 1 - 0
api/homepage/pihole.php

@@ -204,6 +204,7 @@ trait PiHoleHomepageItem
 		} catch (Requests_Exception $e) {
 				$this->setResponse(500, $e->getMessage());
 				$this->setLoggerChannel('PiHole')->error($e);
+				throw $e;
 			};
 		return $processedResponse ?? [];
 	}

+ 1 - 1
api/pages/error.php

@@ -70,7 +70,7 @@ function get_page_error($Organizr)
 			<h1 class="text-danger">' . $error . '</h1>
 			<h2 class="text-uppercase" lang="en">' . $errorDetails['type'] . '</h2>
 			<h3 class="text-uppercase" lang="en">' . $errorDetails['description'] . '</h3>
-			<p class="text-muted m-t-30 m-b-30">Hey there, ' . $Organizr->user['username'] . '.  Looks like you tried accessing something that just ain\'t right!  WTF right?! </p>
+			<p class="text-muted m-t-30 m-b-30">Hey there, ' . $Organizr->user['username'] . ', ' . $Organizr->config['customErrorMessage'] . ' . </p>
 			<a href="' . $nonRootPath . '" class="btn btn-danger btn-rounded waves-effect waves-light m-b-40">Back Home</a>
 		</div>
 	</div>

+ 14 - 0
api/pages/login.php

@@ -14,11 +14,22 @@ function get_page_login($Organizr)
 	$hideOrganizrRecoveryPassword = ($Organizr->config['disableRecoverPass']) ? 'hidden' : '';
 	$customForgotPasswordText = (empty($Organizr->config['customForgotPassText'])) ? 'Enter your Email and instructions will be sent to you!' : $Organizr->config['customForgotPassText'];
 	$customForgotPasswordText = ($Organizr->config['disableRecoverPass']) ? 'Disabled' : $customForgotPasswordText;
+	$oidcAutoRedirectScript = '';
+	if ($Organizr->shouldAutoRedirectToOIDC()) {
+		$provider = $Organizr->getAutoRedirectProvider();
+		$oidcAutoRedirectScript = '
+// OIDC Auto-redirect
+if (!window.location.hash.includes("noredirect") && !sessionStorage.getItem("oidc_no_redirect")) {
+	window.location.href = "api/v2/oidc/' . htmlspecialchars($provider) . '/authorize";
+}
+';
+	}
 	return '
 <script>
 if(activeInfo.settings.login.rememberMe){
 	$(\'#checkbox-login\').prop(\'checked\',true);
 }
+' . $oidcAutoRedirectScript . '
 </script>
 <section id="wrapper" class="login-register">
 	<div class="login-box login-sidebar animated slideInRight">
@@ -101,6 +112,9 @@ if(activeInfo.settings.login.rememberMe){
 					<!-- PLEX OAUTH LOGIN -->
 					' . $Organizr->showoAuth() . '
 					<!-- END PLEX OAUTH LOGIN -->
+					<!-- OIDC SSO LOGIN -->
+					' . $Organizr->showoAuthOIDC() . '
+					<!-- END OIDC SSO LOGIN -->
 				</div>
 			</form>
 			<form class="form-horizontal form-material hidden" id="registerForm" onsubmit="return false;">

+ 19 - 1
api/plugins/invites/config.php

@@ -5,10 +5,28 @@ return array(
 	'INVITES-dbVersion' => '1.0.0',
 	'INVITES-type-include' => 'plex',
 	'INVITES-plexLibraries' => '',
+	'INVITES-add-plex-home' => false,
 	'INVITES-EmbyTemplate' => '',
 	'INVITES-plex-tv-labels' => '',
 	'INVITES-plex-music-labels' => '',
 	'INVITES-plex-movies-labels' => '',
 	'INVITES-allow-delete-include' => false,
-	'INVITES-maximum-invites' => '0'
+	'INVITES-maximum-invites' => '0',
+
+	// Komga
+	'INVITES-komga-enabled' => false,
+	'INVITES-komga-uri' => '',
+	'INVITES-komga-api-key' => '',
+	'INVITES-komga-default-user-password' => '',
+	'INVITES-komga-roles' => '',
+	'INVITES-komga-libraryIds' => '',
+
+	// Nextcloud
+	'INVITES-nextcloud-enabled' => false,
+	'INVITES-nextcloud-plex-sso' => false,
+	'INVITES-nextcloud-url' => '',
+	'INVITES-nextcloud-admin-user' => '',
+	'INVITES-nextcloud-admin-password' => '',
+	'INVITES-nextcloud-groups-member' => '',
+	'INVITES-nextcloud-quota' => '',
 );

+ 541 - 5
api/plugins/invites/plugin.php

@@ -233,6 +233,7 @@ class Invites extends Organizr
 	public function _invitesPluginUseCode($code, $array)
 	{
 		$code = ($code) ?? null;
+		$mail = $this->_getEmailFronInviteCode($code);
 		$usedBy = ($array['usedby']) ?? null;
 		$now = date("Y-m-d H:i:s");
 		$currentIP = $this->userIP();
@@ -255,8 +256,8 @@ class Invites extends Organizr
 				)
 			];
 			$query = $this->processQueries($response);
-			$this->setLoggerChannel('Invites')->info('Invite Used [' . $code . ']');
-			return $this->_invitesPluginAction($usedBy, 'share', $this->config['INVITES-type-include']);
+			$this->setLoggerChannel('Invites')->info('Invite Used [' . $code . '] by ' . $usedBy);
+			return $this->_invitesPluginAction($usedBy, 'share', $this->config['INVITES-type-include'], $mail);
 		} else {
 			return false;
 		}
@@ -326,6 +327,11 @@ class Invites extends Organizr
 				),
 			);
 		}
+
+		$komgaRoles = $this->_getKomgaRoles();
+		$komgalibrary = $this->_getKomgaLibraries();
+		$nextcloudRoles = $this->_getNextcloudGroups();
+
 		return array(
 			'Backend' => array(
 				array(
@@ -430,6 +436,105 @@ class Invites extends Organizr
 					'value' => $this->config['INVITES-plex-music-labels'],
 					'placeholder' => 'All'
 				),
+				array(
+					'type' => 'switch',
+					'name' => 'INVITES-add-plex-home',
+					'label' => 'When user subscribe add him to Plex Home',
+					'value' => $this->config['INVITES-add-plex-home']
+				)
+			),
+			'Komga Settings' => array(
+				array(
+					'type' => 'switch',
+					'name' => 'INVITES-komga-enabled',
+					'label' => 'Enable Komga for auto create account',
+					'value' => $this->config['INVITES-komga-enabled'],
+				),
+				array(
+					'type' => 'input',
+					'name' => 'INVITES-komga-uri',
+					'label' => 'URL',
+					'value' => $this->config['INVITES-komga-uri'],
+					'placeholder' => 'http(s)://hostname:port'
+				),
+				array(
+					'type' => 'password-alt',
+					'name' => 'INVITES-komga-api-key',
+					'label' => 'Komga Api Key',
+					'value' => $this->config['INVITES-komga-api-key']
+				),
+				array(
+					'type' => 'password-alt',
+					'name' => 'INVITES-komga-default-user-password',
+					'label' => 'Default password for new user',
+					'value' => $this->config['INVITES-komga-default-user-password']
+				),
+				array(
+					'type' => 'select2',
+					'class' => 'select2-multiple',
+					'id' => 'INVITES-select-' . $this->random_ascii_string(6),
+					'name' => 'INVITES-komga-roles',
+					'label' => 'Roles',
+					'value' => $this->config['INVITES-komga-roles'],
+					'options' => $komgaRoles
+				),
+				array(
+					'type' => 'select2',
+					'class' => 'select2-multiple',
+					'id' => 'INVITES-select-' . $this->random_ascii_string(6),
+					'name' => 'INVITES-komga-libraryIds',
+					'label' => 'Libraries',
+					'value' => $this->config['INVITES-komga-libraryIds'],
+					'options' => $komgalibrary
+				)
+			),
+			'Nextcloud Settings' => array(
+				array(
+					'type' => 'switch',
+					'name' => 'INVITES-nextcloud-enabled',
+					'label' => 'Enable Nextcloud for auto create account',
+					'value' => $this->config['INVITES-nextcloud-enabled'],
+				),
+				array(
+					'type' => 'switch',
+					'name' => 'INVITES-nextcloud-plex-sso',
+					'label' => 'Enable if you have Plex SSO app installed on Nextcloud',
+					'value' => $this->config['INVITES-nextcloud-plex-sso'],
+				),
+				array(
+					'type' => 'input',
+					'name' => 'INVITES-nextcloud-url',
+					'label' => 'Nextcloud URI',
+					'value' => $this->config['INVITES-nextcloud-url']
+				),
+				array(
+					'type' => 'input',
+					'name' => 'INVITES-nextcloud-admin-user',
+					'label' => 'Nextcloud Admin User',
+					'value' => $this->config['INVITES-nextcloud-admin-user']
+				),
+				array(
+					'type' => 'password',
+					'name' => 'INVITES-nextcloud-admin-password',
+					'label' => 'Nextcloud Admin Password',
+					'value' => $this->config['INVITES-nextcloud-admin-password']
+				),
+				array(
+					'type' => 'input',
+					'name' => 'INVITES-nextcloud-quota',
+					'label' => 'Storage quota for user after subscription (empty for no-limit)',
+					'value' => $this->config['INVITES-nextcloud-quota'],
+					'placeholder' => '10GB'
+				),
+				array(
+					'type' => 'select2',
+					'class' => 'select2-multiple',
+					'id' => 'INVITES-select-' . $this->random_ascii_string(6),
+					'name' => 'INVITES-nextcloud-groups-member',
+					'label' => 'Nextcloud Groups after subscription',
+					'value' => $this->config['INVITES-nextcloud-groups-member'],
+					'options' => $nextcloudRoles
+				)
 			),
 			'Emby Settings' => array(
 				array(
@@ -464,7 +569,7 @@ class Invites extends Organizr
 		);
 	}
 
-	public function _invitesPluginAction($username, $action = null, $type = null)
+	public function _invitesPluginAction($username, $action = null, $type = null, $mail)
 	{
 		if ($action == null) {
 			$this->setAPIResponse('error', 'No Action supplied', 409);
@@ -515,6 +620,19 @@ class Invites extends Organizr
 						switch ($action) {
 							case 'share':
 								$response = Requests::post($url, $headers, json_encode($data), array());
+
+								if($this->config['INVITES-add-plex-home']) {
+									$this->_addUserPlexHome($mail);
+								}
+
+								if($this->config['INVITES-komga-enabled']) {
+									$this->_createKomgaAccount($mail);
+								}
+
+								if ($this->config['INVITES-nextcloud-enabled']) {
+									$nextcloudAccountCreated = $this->_createNextcloudAccount($mail, $username);
+								}
+
 								break;
 							case 'unshare':
 								$id = (is_numeric($username) ? $username : $this->_invitesPluginConvertPlexName($username, "id"));
@@ -533,8 +651,8 @@ class Invites extends Organizr
 							switch ($response->status_code) {
 								case 400:
 									$this->setLoggerChannel('Plex')->warning('Plex User already has access');
-									$this->setAPIResponse('error', 'Plex User already has access', 409);
-									return false;
+									$this->setAPIResponse('success', 'Plex User already has access', 200);
+									return true;
 								case 401:
 									$this->setLoggerChannel('Plex')->warning('Incorrect Token');
 									$this->setAPIResponse('error', 'Incorrect Token', 409);
@@ -595,4 +713,422 @@ class Invites extends Organizr
 		return (!empty($plexUser) ? $plexUser : null);
 	}
 
+	/**
+	 * Creates a new Komga user account using the provided email address.
+	 *
+	 * @param string $email The email address for the new Komga user account.
+	 * @return bool True if the account was successfully created, false otherwise.
+	 */
+	private function _createKomgaAccount($email) {
+        $this->logger->info('Try to create Komga account for ' . $email);
+
+
+		if(!$this->_checkKomgaVar()) {
+			return false;
+		}
+
+		if (empty($email)) {
+			$this->setLoggerChannel('Invites')->info('User email empty');
+			return false;
+		}
+
+		$endpoint = rtrim($this->config['INVITES-komga-uri'], '/') . '/api/v2/users';
+		$apiKey = $this->config['INVITES-komga-api-key'];
+		$password =  $this->decrypt($this->config['INVITES-komga-default-user-password']);
+
+		$rolesStr = $this->config['INVITES-komga-roles'] ?? '';
+		$roles = array_values(array_filter(array_map('trim', explode(';', $rolesStr))));
+
+		$libIdsStr = $this->config['INVITES-komga-libraryIds'] ?? '';
+		$libraryIds = array_values(array_filter(array_map('trim', explode(';', $libIdsStr))));
+
+		$headers = array(
+			'accept' => 'application/json',
+			'X-API-Key' => $apiKey,
+			'Content-Type' => 'application/json'
+		);
+
+		$payload = array(
+			'email' => $email,
+			'password' => $password,
+			'roles' => $roles,
+			'sharedLibraries' => array(
+				'all' => false,
+				'libraryIds' => $libraryIds
+			)
+		);
+
+		try {
+			$response = Requests::post($endpoint, $headers, json_encode($payload));
+			if ($response->success) {
+				$this->setLoggerChannel('Komga')->info('User created ' . $email . ' with roles: ' . implode(',', $roles) . ' and libraries: ' . implode(',', $libraryIds));
+				return true;
+			}
+			$this->setLoggerChannel('Komga')->warning('User not created ' . $email . ' HTTP ' . $response->status_code);
+		} catch (Requests_Exception $e) {
+			$this->setLoggerChannel('Komga')->error('User not created ' . $email . ' Requests_Exception: ' . $e->getMessage());
+		}
+		return false;
+	}
+
+    /**
+     * Retrieves a list of Komga roles
+     *
+     * @return array An array of associative arrays, each containing 'name' and 'value' for a Komga role.
+     */
+    public function _getKomgaRoles() {
+        $komgaRoles = array();
+
+        $roleNames = array(
+            'ADMIN' => 'Administrator',
+            'FILE_DOWNLOAD' => 'File download',
+            'PAGE_STREAMING' => 'Page streaming',
+            'KOBO_SYNC' => 'Kobo Sync',
+            'KOREADER_SYNC' => 'Koreader Sync'
+        );
+
+        foreach ($roleNames as $value => $name) {
+            $komgaRoles[] = array(
+                'name' => $name,
+                'value' => $value
+            );
+        }
+        return $komgaRoles;
+    }
+
+	/**
+     * Fetches the list of Komga libraries from the Komga API.
+     *
+     * @return array|false Returns an array of libraries with 'name' and 'id' on success, or false on failure.
+     */
+    public function _getKomgaLibraries() {
+        $this->logger->info('Try to fetch Komga libraries');
+
+		if(!$this->_checkKomgaVar()) {
+			return false;
+		}
+
+        $endpoint = rtrim($this->config['komgaURL'], '/') . '/api/v1/libraries';
+        $apiKey = $this->config['INVITES-komga-api-key'];
+
+        $libraryListDefault = array(
+			array(
+				'name' => 'Refresh page to update List',
+				'value' => '',
+				'disabled' => true,
+			),
+		);
+
+        $headers = array(
+            'accept' => 'application/json',
+            'X-API-Key' => $apiKey
+        );
+
+        try {
+            $response = Requests::get($endpoint, $headers);
+            if ($response->success) {
+                $libraries = json_decode($response->body, true);
+                // Komga retourne un tableau d'objets librairie
+                $result = array();
+                foreach ($libraries as $library) {
+                    $result[] = array(
+                        'name' => $library['name'],
+                        'id' => $library['id']
+                    );
+                }
+                $this->logger->info('Fetched libraries: ' . json_encode($result));
+                if(!empty($result)) {
+                    return $result;
+                }
+            } else {
+                $this->logger->warning("Error HTTP ".$response->status_code.' body='.$response->body);
+            }
+        } catch (Requests_Exception $e) {
+            $this->logger->warning("Exception: " . $e->getMessage());
+        }
+        return $libraryListDefault;
+    }
+
+	/**
+	 * Fetches the list of Nextcloud groups using the configured Nextcloud admin credentials.
+	*
+		* @return array|false Returns an array of groups with 'name' and 'value' keys on success,
+		*                     or false on failure.
+		*/
+	public function _getNextcloudGroups() {
+		$this->logger->info('Try to fetch Nextcloud groups');
+
+		if(!$this->_checkNextcloudVar()) {
+			return false;
+		}
+
+		$url = rtrim($this->config['INVITES-nextcloud-url'], '/') . '/ocs/v1.php/cloud/groups';
+		$adminUser = $this->config['INVITES-nextcloud-admin-user'];
+		$adminPass = $this->decrypt($this->config['INVITES-nextcloud-admin-password']);
+		$headers = array(
+			'OCS-APIRequest' => 'true',
+			'Accept' => 'application/json',
+		);
+
+		try {
+			$options = array(
+				'auth' => array($adminUser, $adminPass),
+			);
+			$response = Requests::get($url, $headers, $options);
+			if ($response->success) {
+				$body = json_decode($response->body, true);
+				if (isset($body['ocs']['data']['groups'])) {
+					$this->logger->info('Fetched groups: ' . implode(', ', $body['ocs']['data']['groups']));
+					$groups = $body['ocs']['data']['groups'];
+					$result = array();
+					foreach ($groups as $group) {
+						$result[] = array(
+							'name' => $group,
+							'value' => $group
+						);
+					}
+					return $result;
+				} else {
+					$this->logger->warning('Groups not found in response');
+				}
+			} else {
+				$this->logger->warning("Error HTTP ".$response->status_code.' body='.$response->body);
+			}
+		} catch (Requests_Exception $e) {
+			$this->logger->warning("Exception: " . $e->getMessage());
+		}
+		return false;
+	}
+
+	/**
+	 * Checks if all required Nextcloud configuration variables are set.
+	 *
+	 * @return bool Returns true if all required Nextcloud configuration variables are set; false otherwise.
+	 */
+	public function _checkNextcloudVar() {
+		if (empty($this->config['INVITES-nextcloud-enabled'])) {
+			$this->logger->info('Nextcloud disabled in config');
+			return false;
+		}
+		if (empty($this->config['INVITES-nextcloud-url'])) {
+			$this->logger->info('Nextcloud URL missing');
+			return false;
+		}
+		if (empty($this->config['INVITES-nextcloud-admin-user']) || empty($this->config['INVITES-nextcloud-admin-password'])) {
+			$this->logger->info('Nextcloud admin credentials missing');
+			return false;
+		}
+		return true;
+	}
+
+	/**
+	 * Checks if all required komga configuration variables are set.
+	 *
+	 * @return bool Returns true if all required komga configuration variables are set; false otherwise.
+	 */
+	public function _checkKomgaVar() {
+		if (empty($this->config['INVITES-komga-uri'])) {
+			$this->setLoggerChannel('Invites')->info('Komga uri is missing');
+			return false;
+		}
+		if (empty($this->config['INVITES-komga-api-key'])) {
+			$this->setLoggerChannel('Invites')->info('Komga api key is missing');
+			return false;
+		}
+		if (empty($this->config['INVITES-komga-roles'])) {
+			$this->setLoggerChannel('Invites')->info('Komga roles empty');
+			return false;
+		}
+		if (empty($this->config['INVITES-komga-libraryIds'])) {
+			$this->setLoggerChannel('Invites')->info('Komga library empty');
+			return false;
+		}
+		if (empty($this->config['INVITES-komga-default-user-password'])) {
+			$this->setLoggerChannel('Invites')->info('Komga default user password empty');
+			return false;
+		}
+		return true;
+	}
+
+
+
+	/**
+	* Creates a Nextcloud account for a user based on their email and other parameters.
+	*
+	* @param string $email                The email address of the user to create in Nextcloud.
+	* @param string $displayName          The display name for the Nextcloud user.
+	* @param string $nextcloudGroupsMember A semicolon-separated list of Nextcloud groups to add the user to.
+	* @param string $nextcloudQuota       The storage quota to assign to the user (e.g., "5GB").
+	*
+	* @return bool Returns true if the account was successfully created, false otherwise.
+	*/
+	public function _createNextcloudAccount($email, $displayName) {
+		$this->logger->info('Try to create Nextcloud account');
+
+		if(!$this->_checkNextcloudVar()) {
+			return false;
+		}
+
+		$nextcloudGroupsMember = $this->config['INVITES-nextcloud-groups-member'] ?? '';
+		$nextcloudQuota = $this->config['INVITES-nextcloud-quota'] ?? '';
+
+		$userid = $email;
+		if($this->config['INVITES-nextcloud-plex-sso']) {
+			$plexUserId = $this->_getPlexUserIdByEmail($email);
+			$this->logger->warning('plexUserId=' . $plexUserId);
+
+			if (!empty($plexUserId)) {
+				$userid = 'PlexTv-' . $plexUserId;
+			}
+		}
+
+		try {
+			$password = bin2hex(random_bytes(12));
+		} catch (\Throwable $e) {
+			$password = bin2hex(openssl_random_pseudo_bytes(12));
+		}
+
+		if (empty($password)) {
+			$this->logger->warning('Error generating password');
+			return false;
+		}
+
+		$url = rtrim($this->config['INVITES-nextcloud-url'], '/') . '/ocs/v1.php/cloud/users';
+		$adminUser = $this->config['INVITES-nextcloud-admin-user'];
+		$adminPass = $this->decrypt($this->config['INVITES-nextcloud-admin-password']);
+		$headers = array(
+			'OCS-APIRequest' => 'true',
+			'Accept' => 'application/json',
+		);
+
+		$data = array(
+			'userid' => $userid,
+			'password' => $password,
+			'email' => $email,
+			'displayName' => $displayName,
+		);
+
+		if (!empty($nextcloudGroupsMember)) {
+			$groups = array_values(array_filter(array_map('trim', explode(';', $nextcloudGroupsMember))));
+			foreach ($groups as $group) {
+				$data['groups[]'] = $group;
+			}
+		}
+
+		if (!empty($nextcloudQuota)) {
+			$data['quota'] = $nextcloudQuota;
+		}
+
+		try {
+			$options = array(
+				'auth' => array($adminUser, $adminPass),
+			);
+			$response = Requests::post($url, $headers, $data, $options);
+			if ($response->success) {
+				$this->logger->info("User created ($email)");
+				return true;
+			}
+			$this->logger->warning("Error ($email) HTTP ".$response->status_code.' body='.$response->body);
+		} catch (Requests_Exception $e) {
+			$this->logger->warning("Exception: " . $e->getMessage());
+		}
+		return false;
+	}
+
+	/**
+	 * Retrieves the Plex user ID associated with a given email address.
+	 *
+	 * @param string $email The email address to search for in the shared Plex users.
+	 * @return string|null The Plex user ID if found, or null if not found or on error.
+	 */
+	public function _getPlexUserIdByEmail($email) {
+		$this->logger->info("Try to get Plex userID for $email");
+
+		if (empty($this->config['plexToken']) || empty($this->config['plexID'])) {
+			$this->logger->warning("PlexToken ou plexID missing");
+			return null;
+		}
+
+		$url = "https://clients.plex.tv/api/invites/requested";
+		$headers = array(
+			"Accept" => "application/json",
+			"X-Plex-Token" => $this->config['plexToken']
+		);
+		try {
+			$response = Requests::get($url, $headers);
+			if ($response->success) {
+				$xml = simplexml_load_string($response->body);
+				// Parcourt les éléments <Invite> du MediaContainer
+				foreach ($xml->Invite as $invite) {
+					$inviteEmail = (string)$invite['email'];
+					$inviteId = (string)$invite['id'];
+					if (strcasecmp($inviteEmail, $email) === 0) {
+						$this->logger->info("Find id=$inviteId for $email");
+						return $inviteId;
+					}
+				}
+			}
+			$this->logger->warning("No userId found for $email");
+		} catch (Requests_Exception $e) {
+			$this->logger->warning("Exception: " . $e->getMessage());
+		}
+		return null;
+	}
+
+	/**
+	 * Retrieves the email address associated with a given invite code.
+	 *
+	 * @param string $inviteCode The invite code to look up.
+	 * @return string|false The email address associated with the invite code, or false if not found.
+	 */
+	public function _getEmailFronInviteCode($inviteCode) {
+		if (empty($inviteCode)) {
+			$this->logger->warning('Invite code not found');
+			return false;
+		}
+		$emailLookupQuery = [
+			array(
+				'function' => 'fetch',
+				'query' => array(
+					'SELECT email FROM invites WHERE code = ? COLLATE NOCASE',
+					$inviteCode
+				)
+			)
+		];
+		$emailRow = $this->processQueries($emailLookupQuery);
+		if ($emailRow && !empty($emailRow['email'])) {
+			$this->logger->info("Email foud via the code [$inviteCode] : ".$emailRow['email']);
+			return $emailRow['email'];
+		} else {
+			$this->logger->warning("No mail found for the code [$inviteCode]");
+			return false;
+		}
+	}
+
+	/**
+	 * Adds a user to Plex Home using the provided email address.
+	 *
+	 * @param string $email The email address of the user to invite.
+	 * @return array|false Returns the decoded response from Plex API on success, or false on failure.
+	 */
+	public function _addUserPlexHome($email){
+		if (empty($email) || empty($this->config['plexToken'])) {
+			$this->logger->warning('_addUserPlexHome: email or plexToken missing');
+			return false;
+		}
+		$url = 'https://clients.plex.tv/api/home/users?invitedEmail=' . urlencode($email) . '&skipFriendship=1&X-Plex-Token=' . urlencode($this->config['plexToken']);
+		try {
+			$response = Requests::post($url, $headers);
+			if ($response->success) {
+				$this->logger->info('User added on plex home');
+				return json_decode($response->body, true);
+			} else {
+				$this->logger->info('_getPlexHomeUserByEmail: error (HTTP ' . $response->status_code . ')');
+			}
+		} catch (Requests_Exception $e) {
+			$this->logger->info('_addUserPlexHome: ' . $e->getMessage());
+		}
+		return false;
+	}
+
 }

+ 4 - 0
api/v2/.htaccess

@@ -0,0 +1,4 @@
+RewriteEngine On
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteCond %{REQUEST_FILENAME} !-d
+RewriteRule ^ index.php [QSA,L]

+ 2 - 1
api/v2/index.php

@@ -47,7 +47,8 @@ $GLOBALS['bypass'] = array(
 	'/api/v2/login',
 	'/api/v2/wizard/path',
 	'/api/v2/login/api',
-	'/api/v2/plex/register'
+	'/api/v2/plex/register',
+	'/api/v2/oidc/providers'
 );
 $GLOBALS['responseCode'] = 200;
 function jsonE($json)

+ 100 - 0
api/v2/routes/oidc.php

@@ -0,0 +1,100 @@
+<?php
+/**
+ * OIDC Authentication Routes
+ */
+
+// Ensure PHP sessions are started for OIDC state management
+if (session_status() === PHP_SESSION_NONE) {
+	// Configure session cookies for cross-site requests (OIDC redirect flow)
+	session_set_cookie_params([
+		'lifetime' => 600, // 10 minutes for OIDC flow
+		'path' => '/',
+		'secure' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
+		'httponly' => true,
+		'samesite' => 'Lax' // Lax allows the session cookie to be sent on top-level navigations
+	]);
+	session_start();
+}
+
+/**
+ * Get enabled OIDC providers (public endpoint)
+ */
+$app->get('/oidc/providers', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$providers = $Organizr->getEnabledOIDCProviders();
+	$result = [];
+	foreach ($providers as $provider => $config) {
+		$result[$provider] = [
+			'name' => $Organizr->config[$config['configPrefix'] . 'Name'] ?? ucfirst($provider),
+			'enabled' => true,
+		];
+	}
+	$GLOBALS['api']['response']['data'] = $result;
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+
+/**
+ * Initiate OIDC authorization flow
+ */
+$app->get('/oidc/{provider}/authorize', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$provider = $args['provider'] ?? '';
+	// This will redirect to the provider, exit happens in initiateOIDCFlow
+	$Organizr->initiateOIDCFlow($provider);
+	// If we get here, there was an error
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+
+/**
+ * OIDC callback handler
+ */
+$app->get('/oidc/{provider}/callback', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$provider = $args['provider'] ?? '';
+	$params = $request->getQueryParams();
+	$code = $params['code'] ?? null;
+	$state = $params['state'] ?? null;
+	$error = $params['error'] ?? null;
+	$errorDescription = $params['error_description'] ?? 'Unknown error';
+	// Handle error from provider
+	if ($error) {
+		$Organizr->outputOIDCCallbackError($errorDescription);
+		return $response;
+	}
+	// Validate required parameters
+	if (!$code || !$state) {
+		$Organizr->outputOIDCCallbackError('Missing code or state parameter');
+		return $response;
+	}
+	// Process callback
+	$user = $Organizr->processOIDCCallback($provider, $code, $state);
+	if ($user) {
+		$Organizr->outputOIDCCallbackSuccess($user['username']);
+	} else {
+		$Organizr->outputOIDCCallbackError($GLOBALS['api']['response']['message'] ?? 'Authentication failed');
+	}
+	return $response;
+});
+
+/**
+ * Test OIDC provider connection (admin only)
+ */
+$app->get('/oidc/{provider}/test', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	if ($Organizr->checkRoute($request)) {
+		if ($Organizr->qualifyRequest(1, true)) {
+			$provider = $args['provider'] ?? '';
+			$Organizr->testOIDCConnection($provider);
+		}
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});

+ 0 - 8
api/vendor/Expectation.php

@@ -1,8 +0,0 @@
-<?php
-
-namespace Pest;
-
-/**
-
- */
-final class Expectation {}

+ 12 - 2
api/vendor/autoload.php

@@ -3,8 +3,18 @@
 // autoload.php @generated by Composer
 
 if (PHP_VERSION_ID < 50600) {
-    echo 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
-    exit(1);
+    if (!headers_sent()) {
+        header('HTTP/1.1 500 Internal Server Error');
+    }
+    $err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
+    if (!ini_get('display_errors')) {
+        if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
+            fwrite(STDERR, $err);
+        } elseif (!headers_sent()) {
+            echo $err;
+        }
+    }
+    throw new RuntimeException($err);
 }
 
 require_once __DIR__ . '/composer/autoload_real.php';

+ 72 - 65
api/vendor/composer/ClassLoader.php

@@ -42,35 +42,37 @@ namespace Composer\Autoload;
  */
 class ClassLoader
 {
-    /** @var ?string */
+    /** @var \Closure(string):void */
+    private static $includeFile;
+
+    /** @var string|null */
     private $vendorDir;
 
     // PSR-4
     /**
-     * @var array[]
-     * @psalm-var array<string, array<string, int>>
+     * @var array<string, array<string, int>>
      */
     private $prefixLengthsPsr4 = array();
     /**
-     * @var array[]
-     * @psalm-var array<string, array<int, string>>
+     * @var array<string, list<string>>
      */
     private $prefixDirsPsr4 = array();
     /**
-     * @var array[]
-     * @psalm-var array<string, string>
+     * @var list<string>
      */
     private $fallbackDirsPsr4 = array();
 
     // PSR-0
     /**
-     * @var array[]
-     * @psalm-var array<string, array<string, string[]>>
+     * List of PSR-0 prefixes
+     *
+     * Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2')))
+     *
+     * @var array<string, array<string, list<string>>>
      */
     private $prefixesPsr0 = array();
     /**
-     * @var array[]
-     * @psalm-var array<string, string>
+     * @var list<string>
      */
     private $fallbackDirsPsr0 = array();
 
@@ -78,8 +80,7 @@ class ClassLoader
     private $useIncludePath = false;
 
     /**
-     * @var string[]
-     * @psalm-var array<string, string>
+     * @var array<string, string>
      */
     private $classMap = array();
 
@@ -87,29 +88,29 @@ class ClassLoader
     private $classMapAuthoritative = false;
 
     /**
-     * @var bool[]
-     * @psalm-var array<string, bool>
+     * @var array<string, bool>
      */
     private $missingClasses = array();
 
-    /** @var ?string */
+    /** @var string|null */
     private $apcuPrefix;
 
     /**
-     * @var self[]
+     * @var array<string, self>
      */
     private static $registeredLoaders = array();
 
     /**
-     * @param ?string $vendorDir
+     * @param string|null $vendorDir
      */
     public function __construct($vendorDir = null)
     {
         $this->vendorDir = $vendorDir;
+        self::initializeIncludeClosure();
     }
 
     /**
-     * @return string[]
+     * @return array<string, list<string>>
      */
     public function getPrefixes()
     {
@@ -121,8 +122,7 @@ class ClassLoader
     }
 
     /**
-     * @return array[]
-     * @psalm-return array<string, array<int, string>>
+     * @return array<string, list<string>>
      */
     public function getPrefixesPsr4()
     {
@@ -130,8 +130,7 @@ class ClassLoader
     }
 
     /**
-     * @return array[]
-     * @psalm-return array<string, string>
+     * @return list<string>
      */
     public function getFallbackDirs()
     {
@@ -139,8 +138,7 @@ class ClassLoader
     }
 
     /**
-     * @return array[]
-     * @psalm-return array<string, string>
+     * @return list<string>
      */
     public function getFallbackDirsPsr4()
     {
@@ -148,8 +146,7 @@ class ClassLoader
     }
 
     /**
-     * @return string[] Array of classname => path
-     * @psalm-return array<string, string>
+     * @return array<string, string> Array of classname => path
      */
     public function getClassMap()
     {
@@ -157,8 +154,7 @@ class ClassLoader
     }
 
     /**
-     * @param string[] $classMap Class to filename map
-     * @psalm-param array<string, string> $classMap
+     * @param array<string, string> $classMap Class to filename map
      *
      * @return void
      */
@@ -175,24 +171,25 @@ class ClassLoader
      * Registers a set of PSR-0 directories for a given prefix, either
      * appending or prepending to the ones previously set for this prefix.
      *
-     * @param string          $prefix  The prefix
-     * @param string[]|string $paths   The PSR-0 root directories
-     * @param bool            $prepend Whether to prepend the directories
+     * @param string              $prefix  The prefix
+     * @param list<string>|string $paths   The PSR-0 root directories
+     * @param bool                $prepend Whether to prepend the directories
      *
      * @return void
      */
     public function add($prefix, $paths, $prepend = false)
     {
+        $paths = (array) $paths;
         if (!$prefix) {
             if ($prepend) {
                 $this->fallbackDirsPsr0 = array_merge(
-                    (array) $paths,
+                    $paths,
                     $this->fallbackDirsPsr0
                 );
             } else {
                 $this->fallbackDirsPsr0 = array_merge(
                     $this->fallbackDirsPsr0,
-                    (array) $paths
+                    $paths
                 );
             }
 
@@ -201,19 +198,19 @@ class ClassLoader
 
         $first = $prefix[0];
         if (!isset($this->prefixesPsr0[$first][$prefix])) {
-            $this->prefixesPsr0[$first][$prefix] = (array) $paths;
+            $this->prefixesPsr0[$first][$prefix] = $paths;
 
             return;
         }
         if ($prepend) {
             $this->prefixesPsr0[$first][$prefix] = array_merge(
-                (array) $paths,
+                $paths,
                 $this->prefixesPsr0[$first][$prefix]
             );
         } else {
             $this->prefixesPsr0[$first][$prefix] = array_merge(
                 $this->prefixesPsr0[$first][$prefix],
-                (array) $paths
+                $paths
             );
         }
     }
@@ -222,9 +219,9 @@ class ClassLoader
      * Registers a set of PSR-4 directories for a given namespace, either
      * appending or prepending to the ones previously set for this namespace.
      *
-     * @param string          $prefix  The prefix/namespace, with trailing '\\'
-     * @param string[]|string $paths   The PSR-4 base directories
-     * @param bool            $prepend Whether to prepend the directories
+     * @param string              $prefix  The prefix/namespace, with trailing '\\'
+     * @param list<string>|string $paths   The PSR-4 base directories
+     * @param bool                $prepend Whether to prepend the directories
      *
      * @throws \InvalidArgumentException
      *
@@ -232,17 +229,18 @@ class ClassLoader
      */
     public function addPsr4($prefix, $paths, $prepend = false)
     {
+        $paths = (array) $paths;
         if (!$prefix) {
             // Register directories for the root namespace.
             if ($prepend) {
                 $this->fallbackDirsPsr4 = array_merge(
-                    (array) $paths,
+                    $paths,
                     $this->fallbackDirsPsr4
                 );
             } else {
                 $this->fallbackDirsPsr4 = array_merge(
                     $this->fallbackDirsPsr4,
-                    (array) $paths
+                    $paths
                 );
             }
         } elseif (!isset($this->prefixDirsPsr4[$prefix])) {
@@ -252,18 +250,18 @@ class ClassLoader
                 throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
             }
             $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
-            $this->prefixDirsPsr4[$prefix] = (array) $paths;
+            $this->prefixDirsPsr4[$prefix] = $paths;
         } elseif ($prepend) {
             // Prepend directories for an already registered namespace.
             $this->prefixDirsPsr4[$prefix] = array_merge(
-                (array) $paths,
+                $paths,
                 $this->prefixDirsPsr4[$prefix]
             );
         } else {
             // Append directories for an already registered namespace.
             $this->prefixDirsPsr4[$prefix] = array_merge(
                 $this->prefixDirsPsr4[$prefix],
-                (array) $paths
+                $paths
             );
         }
     }
@@ -272,8 +270,8 @@ class ClassLoader
      * Registers a set of PSR-0 directories for a given prefix,
      * replacing any others previously set for this prefix.
      *
-     * @param string          $prefix The prefix
-     * @param string[]|string $paths  The PSR-0 base directories
+     * @param string              $prefix The prefix
+     * @param list<string>|string $paths  The PSR-0 base directories
      *
      * @return void
      */
@@ -290,8 +288,8 @@ class ClassLoader
      * Registers a set of PSR-4 directories for a given namespace,
      * replacing any others previously set for this namespace.
      *
-     * @param string          $prefix The prefix/namespace, with trailing '\\'
-     * @param string[]|string $paths  The PSR-4 base directories
+     * @param string              $prefix The prefix/namespace, with trailing '\\'
+     * @param list<string>|string $paths  The PSR-4 base directories
      *
      * @throws \InvalidArgumentException
      *
@@ -425,7 +423,8 @@ class ClassLoader
     public function loadClass($class)
     {
         if ($file = $this->findFile($class)) {
-            includeFile($file);
+            $includeFile = self::$includeFile;
+            $includeFile($file);
 
             return true;
         }
@@ -476,9 +475,9 @@ class ClassLoader
     }
 
     /**
-     * Returns the currently registered loaders indexed by their corresponding vendor directories.
+     * Returns the currently registered loaders keyed by their corresponding vendor directories.
      *
-     * @return self[]
+     * @return array<string, self>
      */
     public static function getRegisteredLoaders()
     {
@@ -555,18 +554,26 @@ class ClassLoader
 
         return false;
     }
-}
 
-/**
- * Scope isolated include.
- *
- * Prevents access to $this/self from included files.
- *
- * @param  string $file
- * @return void
- * @private
- */
-function includeFile($file)
-{
-    include $file;
+    /**
+     * @return void
+     */
+    private static function initializeIncludeClosure()
+    {
+        if (self::$includeFile !== null) {
+            return;
+        }
+
+        /**
+         * Scope isolated include.
+         *
+         * Prevents access to $this/self from included files.
+         *
+         * @param  string $file
+         * @return void
+         */
+        self::$includeFile = \Closure::bind(static function($file) {
+            include $file;
+        }, null, null);
+    }
 }

+ 58 - 14
api/vendor/composer/InstalledVersions.php

@@ -26,12 +26,23 @@ use Composer\Semver\VersionParser;
  */
 class InstalledVersions
 {
+    /**
+     * @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to
+     * @internal
+     */
+    private static $selfDir = null;
+
     /**
      * @var mixed[]|null
-     * @psalm-var array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}, versions: array<string, array{dev_requirement: bool, pretty_version?: string, version?: string, aliases?: string[], reference?: string, replaced?: string[], provided?: string[], install_path?: string, type?: string}>}|array{}|null
+     * @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
      */
     private static $installed;
 
+    /**
+     * @var bool
+     */
+    private static $installedIsLocalDir;
+
     /**
      * @var bool|null
      */
@@ -39,7 +50,7 @@ class InstalledVersions
 
     /**
      * @var array[]
-     * @psalm-var array<string, array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}, versions: array<string, array{dev_requirement: bool, pretty_version?: string, version?: string, aliases?: string[], reference?: string, replaced?: string[], provided?: string[], install_path?: string, type?: string}>}>
+     * @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
      */
     private static $installedByVendor = array();
 
@@ -98,7 +109,7 @@ class InstalledVersions
     {
         foreach (self::getInstalled() as $installed) {
             if (isset($installed['versions'][$packageName])) {
-                return $includeDevRequirements || empty($installed['versions'][$packageName]['dev_requirement']);
+                return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
             }
         }
 
@@ -119,7 +130,7 @@ class InstalledVersions
      */
     public static function satisfies(VersionParser $parser, $packageName, $constraint)
     {
-        $constraint = $parser->parseConstraints($constraint);
+        $constraint = $parser->parseConstraints((string) $constraint);
         $provided = $parser->parseConstraints(self::getVersionRanges($packageName));
 
         return $provided->matches($constraint);
@@ -243,7 +254,7 @@ class InstalledVersions
 
     /**
      * @return array
-     * @psalm-return array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}
+     * @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
      */
     public static function getRootPackage()
     {
@@ -257,7 +268,7 @@ class InstalledVersions
      *
      * @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
      * @return array[]
-     * @psalm-return array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}, versions: array<string, array{dev_requirement: bool, pretty_version?: string, version?: string, aliases?: string[], reference?: string, replaced?: string[], provided?: string[], install_path?: string, type?: string}>}
+     * @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
      */
     public static function getRawData()
     {
@@ -280,7 +291,7 @@ class InstalledVersions
      * Returns the raw data of all installed.php which are currently loaded for custom implementations
      *
      * @return array[]
-     * @psalm-return list<array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}, versions: array<string, array{dev_requirement: bool, pretty_version?: string, version?: string, aliases?: string[], reference?: string, replaced?: string[], provided?: string[], install_path?: string, type?: string}>}>
+     * @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
      */
     public static function getAllRawData()
     {
@@ -303,17 +314,35 @@ class InstalledVersions
      * @param  array[] $data A vendor/composer/installed.php data set
      * @return void
      *
-     * @psalm-param array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}, versions: array<string, array{dev_requirement: bool, pretty_version?: string, version?: string, aliases?: string[], reference?: string, replaced?: string[], provided?: string[], install_path?: string, type?: string}>} $data
+     * @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $data
      */
     public static function reload($data)
     {
         self::$installed = $data;
         self::$installedByVendor = array();
+
+        // when using reload, we disable the duplicate protection to ensure that self::$installed data is
+        // always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not,
+        // so we have to assume it does not, and that may result in duplicate data being returned when listing
+        // all installed packages for example
+        self::$installedIsLocalDir = false;
+    }
+
+    /**
+     * @return string
+     */
+    private static function getSelfDir()
+    {
+        if (self::$selfDir === null) {
+            self::$selfDir = strtr(__DIR__, '\\', '/');
+        }
+
+        return self::$selfDir;
     }
 
     /**
      * @return array[]
-     * @psalm-return list<array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}, versions: array<string, array{dev_requirement: bool, pretty_version?: string, version?: string, aliases?: string[], reference?: string, replaced?: string[], provided?: string[], install_path?: string, type?: string}>}>
+     * @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
      */
     private static function getInstalled()
     {
@@ -322,17 +351,27 @@ class InstalledVersions
         }
 
         $installed = array();
+        $copiedLocalDir = false;
 
         if (self::$canGetVendors) {
+            $selfDir = self::getSelfDir();
             foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
+                $vendorDir = strtr($vendorDir, '\\', '/');
                 if (isset(self::$installedByVendor[$vendorDir])) {
                     $installed[] = self::$installedByVendor[$vendorDir];
                 } elseif (is_file($vendorDir.'/composer/installed.php')) {
-                    $installed[] = self::$installedByVendor[$vendorDir] = require $vendorDir.'/composer/installed.php';
-                    if (null === self::$installed && strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) {
-                        self::$installed = $installed[count($installed) - 1];
+                    /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
+                    $required = require $vendorDir.'/composer/installed.php';
+                    self::$installedByVendor[$vendorDir] = $required;
+                    $installed[] = $required;
+                    if (self::$installed === null && $vendorDir.'/composer' === $selfDir) {
+                        self::$installed = $required;
+                        self::$installedIsLocalDir = true;
                     }
                 }
+                if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) {
+                    $copiedLocalDir = true;
+                }
             }
         }
 
@@ -340,12 +379,17 @@ class InstalledVersions
             // only require the installed.php file if this file is loaded from its dumped location,
             // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
             if (substr(__DIR__, -8, 1) !== 'C') {
-                self::$installed = require __DIR__ . '/installed.php';
+                /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
+                $required = require __DIR__ . '/installed.php';
+                self::$installed = $required;
             } else {
                 self::$installed = array();
             }
         }
-        $installed[] = self::$installed;
+
+        if (self::$installed !== array() && !$copiedLocalDir) {
+            $installed[] = self::$installed;
+        }
 
         return $installed;
     }

+ 1 - 1
api/vendor/composer/autoload_psr4.php

@@ -27,7 +27,7 @@ return array(
     'Pusher\\' => array($vendorDir . '/pusher/pusher-php-server/src'),
     'Psr\\SimpleCache\\' => array($vendorDir . '/psr/simple-cache/src'),
     'Psr\\Log\\' => array($vendorDir . '/psr/log/Psr/Log'),
-    'Psr\\Http\\Server\\' => array($vendorDir . '/psr/http-server-handler/src', $vendorDir . '/psr/http-server-middleware/src'),
+    'Psr\\Http\\Server\\' => array($vendorDir . '/psr/http-server-middleware/src', $vendorDir . '/psr/http-server-handler/src'),
     'Psr\\Http\\Message\\' => array($vendorDir . '/psr/http-factory/src', $vendorDir . '/psr/http-message/src'),
     'Psr\\Http\\Client\\' => array($vendorDir . '/psr/http-client/src'),
     'Psr\\Container\\' => array($vendorDir . '/psr/container/src'),

+ 10 - 17
api/vendor/composer/autoload_real.php

@@ -33,25 +33,18 @@ class ComposerAutoloaderInitcbdc783d76f8e7563dcce7d8af053ecb
 
         $loader->register(true);
 
-        $includeFiles = \Composer\Autoload\ComposerStaticInitcbdc783d76f8e7563dcce7d8af053ecb::$files;
-        foreach ($includeFiles as $fileIdentifier => $file) {
-            composerRequirecbdc783d76f8e7563dcce7d8af053ecb($fileIdentifier, $file);
+        $filesToLoad = \Composer\Autoload\ComposerStaticInitcbdc783d76f8e7563dcce7d8af053ecb::$files;
+        $requireFile = \Closure::bind(static function ($fileIdentifier, $file) {
+            if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {
+                $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;
+
+                require $file;
+            }
+        }, null, null);
+        foreach ($filesToLoad as $fileIdentifier => $file) {
+            $requireFile($fileIdentifier, $file);
         }
 
         return $loader;
     }
 }
-
-/**
- * @param string $fileIdentifier
- * @param string $file
- * @return void
- */
-function composerRequirecbdc783d76f8e7563dcce7d8af053ecb($fileIdentifier, $file)
-{
-    if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {
-        $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;
-
-        require $file;
-    }
-}

+ 82 - 82
api/vendor/composer/autoload_static.php

@@ -28,15 +28,15 @@ class ComposerStaticInitcbdc783d76f8e7563dcce7d8af053ecb
     );
 
     public static $prefixLengthsPsr4 = array (
-        'W' => 
+        'W' =>
         array (
             'Webmozart\\Assert\\' => 17,
         ),
-        'T' => 
+        'T' =>
         array (
             'Tightenco\\Collect\\' => 18,
         ),
-        'S' => 
+        'S' =>
         array (
             'Symfony\\Polyfill\\Util\\' => 22,
             'Symfony\\Polyfill\\Php81\\' => 23,
@@ -52,13 +52,13 @@ class ComposerStaticInitcbdc783d76f8e7563dcce7d8af053ecb
             'Slim\\Psr7\\' => 10,
             'Slim\\' => 5,
         ),
-        'R' => 
+        'R' =>
         array (
             'Recurr\\' => 7,
             'Ramsey\\Uuid\\' => 12,
             'Ramsey\\Collection\\' => 18,
         ),
-        'P' => 
+        'P' =>
         array (
             'Pusher\\' => 7,
             'Psr\\SimpleCache\\' => 16,
@@ -73,323 +73,323 @@ class ComposerStaticInitcbdc783d76f8e7563dcce7d8af053ecb
             'PHPMailer\\PHPMailer\\' => 20,
             'PHPHtmlParser\\' => 14,
         ),
-        'O' => 
+        'O' =>
         array (
             'OpenApi\\' => 8,
         ),
-        'N' => 
+        'N' =>
         array (
             'Nekonomokochan\\PhpJsonLogger\\' => 29,
         ),
-        'M' => 
+        'M' =>
         array (
             'MyCLabs\\Enum\\' => 13,
             'Monolog\\' => 8,
         ),
-        'L' => 
+        'L' =>
         array (
             'League\\OAuth2\\Client\\' => 21,
             'Lcobucci\\JWT\\' => 13,
             'Lcobucci\\Clock\\' => 15,
         ),
-        'K' => 
+        'K' =>
         array (
             'Kryptonit3\\Sonarr\\' => 18,
             'Kryptonit3\\SickRage\\' => 20,
             'Kryptonit3\\CouchPotato\\' => 23,
         ),
-        'I' => 
+        'I' =>
         array (
             'Illuminate\\Contracts\\' => 21,
         ),
-        'H' => 
+        'H' =>
         array (
             'Http\\Promise\\' => 13,
             'Http\\Client\\' => 12,
         ),
-        'G' => 
+        'G' =>
         array (
             'GuzzleHttp\\Psr7\\' => 16,
             'GuzzleHttp\\Promise\\' => 19,
             'GuzzleHttp\\' => 11,
             'GO\\' => 3,
         ),
-        'F' => 
+        'F' =>
         array (
             'Fig\\Http\\Message\\' => 17,
             'FastRoute\\' => 10,
         ),
-        'D' => 
+        'D' =>
         array (
             'Doctrine\\Common\\Lexer\\' => 22,
             'Doctrine\\Common\\Collections\\' => 28,
             'Doctrine\\Common\\Annotations\\' => 28,
         ),
-        'C' => 
+        'C' =>
         array (
             'Cron\\' => 5,
             'Composer\\Semver\\' => 16,
         ),
-        'B' => 
+        'B' =>
         array (
             'Brick\\Math\\' => 11,
             'Bogstag\\OAuth2\\Client\\' => 22,
             'Bcremer\\LineReader\\' => 19,
         ),
-        'A' => 
+        'A' =>
         array (
             'Adldap\\' => 7,
         ),
     );
 
     public static $prefixDirsPsr4 = array (
-        'Webmozart\\Assert\\' => 
+        'Webmozart\\Assert\\' =>
         array (
             0 => __DIR__ . '/..' . '/webmozart/assert/src',
         ),
-        'Tightenco\\Collect\\' => 
+        'Tightenco\\Collect\\' =>
         array (
             0 => __DIR__ . '/..' . '/tightenco/collect/src/Collect',
         ),
-        'Symfony\\Polyfill\\Util\\' => 
+        'Symfony\\Polyfill\\Util\\' =>
         array (
             0 => __DIR__ . '/..' . '/symfony/polyfill-util',
         ),
-        'Symfony\\Polyfill\\Php81\\' => 
+        'Symfony\\Polyfill\\Php81\\' =>
         array (
             0 => __DIR__ . '/..' . '/symfony/polyfill-php81',
         ),
-        'Symfony\\Polyfill\\Php80\\' => 
+        'Symfony\\Polyfill\\Php80\\' =>
         array (
             0 => __DIR__ . '/..' . '/symfony/polyfill-php80',
         ),
-        'Symfony\\Polyfill\\Php72\\' => 
+        'Symfony\\Polyfill\\Php72\\' =>
         array (
             0 => __DIR__ . '/..' . '/symfony/polyfill-php72',
         ),
-        'Symfony\\Polyfill\\Php56\\' => 
+        'Symfony\\Polyfill\\Php56\\' =>
         array (
             0 => __DIR__ . '/..' . '/symfony/polyfill-php56',
         ),
-        'Symfony\\Polyfill\\Mbstring\\' => 
+        'Symfony\\Polyfill\\Mbstring\\' =>
         array (
             0 => __DIR__ . '/..' . '/symfony/polyfill-mbstring',
         ),
-        'Symfony\\Polyfill\\Ctype\\' => 
+        'Symfony\\Polyfill\\Ctype\\' =>
         array (
             0 => __DIR__ . '/..' . '/symfony/polyfill-ctype',
         ),
-        'Symfony\\Component\\Yaml\\' => 
+        'Symfony\\Component\\Yaml\\' =>
         array (
             0 => __DIR__ . '/..' . '/symfony/yaml',
         ),
-        'Symfony\\Component\\VarDumper\\' => 
+        'Symfony\\Component\\VarDumper\\' =>
         array (
             0 => __DIR__ . '/..' . '/symfony/var-dumper',
         ),
-        'Symfony\\Component\\Finder\\' => 
+        'Symfony\\Component\\Finder\\' =>
         array (
             0 => __DIR__ . '/..' . '/symfony/finder',
         ),
-        'Stripe\\' => 
+        'Stripe\\' =>
         array (
             0 => __DIR__ . '/..' . '/stripe/stripe-php/lib',
         ),
-        'Slim\\Psr7\\' => 
+        'Slim\\Psr7\\' =>
         array (
             0 => __DIR__ . '/..' . '/slim/psr7/src',
         ),
-        'Slim\\' => 
+        'Slim\\' =>
         array (
             0 => __DIR__ . '/..' . '/slim/slim/Slim',
         ),
-        'Recurr\\' => 
+        'Recurr\\' =>
         array (
             0 => __DIR__ . '/..' . '/simshaun/recurr/src/Recurr',
         ),
-        'Ramsey\\Uuid\\' => 
+        'Ramsey\\Uuid\\' =>
         array (
             0 => __DIR__ . '/..' . '/ramsey/uuid/src',
         ),
-        'Ramsey\\Collection\\' => 
+        'Ramsey\\Collection\\' =>
         array (
             0 => __DIR__ . '/..' . '/ramsey/collection/src',
         ),
-        'Pusher\\' => 
+        'Pusher\\' =>
         array (
             0 => __DIR__ . '/..' . '/pusher/pusher-php-server/src',
         ),
-        'Psr\\SimpleCache\\' => 
+        'Psr\\SimpleCache\\' =>
         array (
             0 => __DIR__ . '/..' . '/psr/simple-cache/src',
         ),
-        'Psr\\Log\\' => 
+        'Psr\\Log\\' =>
         array (
             0 => __DIR__ . '/..' . '/psr/log/Psr/Log',
         ),
-        'Psr\\Http\\Server\\' => 
+        'Psr\\Http\\Server\\' =>
         array (
-            0 => __DIR__ . '/..' . '/psr/http-server-handler/src',
-            1 => __DIR__ . '/..' . '/psr/http-server-middleware/src',
+            0 => __DIR__ . '/..' . '/psr/http-server-middleware/src',
+            1 => __DIR__ . '/..' . '/psr/http-server-handler/src',
         ),
-        'Psr\\Http\\Message\\' => 
+        'Psr\\Http\\Message\\' =>
         array (
             0 => __DIR__ . '/..' . '/psr/http-factory/src',
             1 => __DIR__ . '/..' . '/psr/http-message/src',
         ),
-        'Psr\\Http\\Client\\' => 
+        'Psr\\Http\\Client\\' =>
         array (
             0 => __DIR__ . '/..' . '/psr/http-client/src',
         ),
-        'Psr\\Container\\' => 
+        'Psr\\Container\\' =>
         array (
             0 => __DIR__ . '/..' . '/psr/container/src',
         ),
-        'PragmaRX\\Google2FA\\Tests\\' => 
+        'PragmaRX\\Google2FA\\Tests\\' =>
         array (
             0 => __DIR__ . '/..' . '/pragmarx/google2fa/tests',
         ),
-        'PragmaRX\\Google2FA\\' => 
+        'PragmaRX\\Google2FA\\' =>
         array (
             0 => __DIR__ . '/..' . '/pragmarx/google2fa/src',
         ),
-        'ParagonIE\\ConstantTime\\' => 
+        'ParagonIE\\ConstantTime\\' =>
         array (
             0 => __DIR__ . '/..' . '/paragonie/constant_time_encoding/src',
         ),
-        'PHPMailer\\PHPMailer\\' => 
+        'PHPMailer\\PHPMailer\\' =>
         array (
             0 => __DIR__ . '/..' . '/phpmailer/phpmailer/src',
         ),
-        'PHPHtmlParser\\' => 
+        'PHPHtmlParser\\' =>
         array (
             0 => __DIR__ . '/..' . '/paquettg/php-html-parser/src/PHPHtmlParser',
         ),
-        'OpenApi\\' => 
+        'OpenApi\\' =>
         array (
             0 => __DIR__ . '/..' . '/zircote/swagger-php/src',
         ),
-        'Nekonomokochan\\PhpJsonLogger\\' => 
+        'Nekonomokochan\\PhpJsonLogger\\' =>
         array (
             0 => __DIR__ . '/..' . '/nekonomokochan/php-json-logger/src/PhpJsonLogger',
         ),
-        'MyCLabs\\Enum\\' => 
+        'MyCLabs\\Enum\\' =>
         array (
             0 => __DIR__ . '/..' . '/myclabs/php-enum/src',
         ),
-        'Monolog\\' => 
+        'Monolog\\' =>
         array (
             0 => __DIR__ . '/..' . '/monolog/monolog/src/Monolog',
         ),
-        'League\\OAuth2\\Client\\' => 
+        'League\\OAuth2\\Client\\' =>
         array (
             0 => __DIR__ . '/..' . '/league/oauth2-client/src',
         ),
-        'Lcobucci\\JWT\\' => 
+        'Lcobucci\\JWT\\' =>
         array (
             0 => __DIR__ . '/..' . '/lcobucci/jwt/src',
         ),
-        'Lcobucci\\Clock\\' => 
+        'Lcobucci\\Clock\\' =>
         array (
             0 => __DIR__ . '/..' . '/lcobucci/clock/src',
         ),
-        'Kryptonit3\\Sonarr\\' => 
+        'Kryptonit3\\Sonarr\\' =>
         array (
             0 => __DIR__ . '/..' . '/kryptonit3/sonarr/src',
         ),
-        'Kryptonit3\\SickRage\\' => 
+        'Kryptonit3\\SickRage\\' =>
         array (
             0 => __DIR__ . '/..' . '/kryptonit3/sickrage/src',
         ),
-        'Kryptonit3\\CouchPotato\\' => 
+        'Kryptonit3\\CouchPotato\\' =>
         array (
             0 => __DIR__ . '/..' . '/kryptonit3/couchpotato/src',
         ),
-        'Illuminate\\Contracts\\' => 
+        'Illuminate\\Contracts\\' =>
         array (
             0 => __DIR__ . '/..' . '/illuminate/contracts',
         ),
-        'Http\\Promise\\' => 
+        'Http\\Promise\\' =>
         array (
             0 => __DIR__ . '/..' . '/php-http/promise/src',
         ),
-        'Http\\Client\\' => 
+        'Http\\Client\\' =>
         array (
             0 => __DIR__ . '/..' . '/php-http/httplug/src',
         ),
-        'GuzzleHttp\\Psr7\\' => 
+        'GuzzleHttp\\Psr7\\' =>
         array (
             0 => __DIR__ . '/..' . '/guzzlehttp/psr7/src',
         ),
-        'GuzzleHttp\\Promise\\' => 
+        'GuzzleHttp\\Promise\\' =>
         array (
             0 => __DIR__ . '/..' . '/guzzlehttp/promises/src',
         ),
-        'GuzzleHttp\\' => 
+        'GuzzleHttp\\' =>
         array (
             0 => __DIR__ . '/..' . '/guzzlehttp/guzzle/src',
         ),
-        'GO\\' => 
+        'GO\\' =>
         array (
             0 => __DIR__ . '/..' . '/peppeocchi/php-cron-scheduler/src/GO',
         ),
-        'Fig\\Http\\Message\\' => 
+        'Fig\\Http\\Message\\' =>
         array (
             0 => __DIR__ . '/..' . '/fig/http-message-util/src',
         ),
-        'FastRoute\\' => 
+        'FastRoute\\' =>
         array (
             0 => __DIR__ . '/..' . '/nikic/fast-route/src',
         ),
-        'Doctrine\\Common\\Lexer\\' => 
+        'Doctrine\\Common\\Lexer\\' =>
         array (
             0 => __DIR__ . '/..' . '/doctrine/lexer/lib/Doctrine/Common/Lexer',
         ),
-        'Doctrine\\Common\\Collections\\' => 
+        'Doctrine\\Common\\Collections\\' =>
         array (
             0 => __DIR__ . '/..' . '/doctrine/collections/lib/Doctrine/Common/Collections',
         ),
-        'Doctrine\\Common\\Annotations\\' => 
+        'Doctrine\\Common\\Annotations\\' =>
         array (
             0 => __DIR__ . '/..' . '/doctrine/annotations/lib/Doctrine/Common/Annotations',
         ),
-        'Cron\\' => 
+        'Cron\\' =>
         array (
             0 => __DIR__ . '/..' . '/dragonmantank/cron-expression/src/Cron',
         ),
-        'Composer\\Semver\\' => 
+        'Composer\\Semver\\' =>
         array (
             0 => __DIR__ . '/..' . '/composer/semver/src',
         ),
-        'Brick\\Math\\' => 
+        'Brick\\Math\\' =>
         array (
             0 => __DIR__ . '/..' . '/brick/math/src',
         ),
-        'Bogstag\\OAuth2\\Client\\' => 
+        'Bogstag\\OAuth2\\Client\\' =>
         array (
             0 => __DIR__ . '/..' . '/bogstag/oauth2-trakt/src',
         ),
-        'Bcremer\\LineReader\\' => 
+        'Bcremer\\LineReader\\' =>
         array (
             0 => __DIR__ . '/..' . '/bcremer/line-reader/src',
         ),
-        'Adldap\\' => 
+        'Adldap\\' =>
         array (
             0 => __DIR__ . '/..' . '/adldap2/adldap2/src',
         ),
     );
 
     public static $prefixesPsr0 = array (
-        's' => 
+        's' =>
         array (
-            'stringEncode' => 
+            'stringEncode' =>
             array (
                 0 => __DIR__ . '/..' . '/paquettg/string-encode/src',
             ),
         ),
-        'R' => 
+        'R' =>
         array (
-            'Requests' => 
+            'Requests' =>
             array (
                 0 => __DIR__ . '/..' . '/rmccue/requests/library',
             ),

+ 10 - 10
api/vendor/composer/installed.json

@@ -1269,17 +1269,17 @@
         },
         {
             "name": "league/oauth2-client",
-            "version": "2.6.0",
-            "version_normalized": "2.6.0.0",
+            "version": "2.7.0",
+            "version_normalized": "2.7.0.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/thephpleague/oauth2-client.git",
-                "reference": "badb01e62383430706433191b82506b6df24ad98"
+                "reference": "160d6274b03562ebeb55ed18399281d8118b76c8"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/badb01e62383430706433191b82506b6df24ad98",
-                "reference": "badb01e62383430706433191b82506b6df24ad98",
+                "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/160d6274b03562ebeb55ed18399281d8118b76c8",
+                "reference": "160d6274b03562ebeb55ed18399281d8118b76c8",
                 "shasum": ""
             },
             "require": {
@@ -1288,12 +1288,12 @@
                 "php": "^5.6 || ^7.0 || ^8.0"
             },
             "require-dev": {
-                "mockery/mockery": "^1.3",
-                "php-parallel-lint/php-parallel-lint": "^1.2",
-                "phpunit/phpunit": "^5.7 || ^6.0 || ^9.3",
+                "mockery/mockery": "^1.3.5",
+                "php-parallel-lint/php-parallel-lint": "^1.3.1",
+                "phpunit/phpunit": "^5.7 || ^6.0 || ^9.5",
                 "squizlabs/php_codesniffer": "^2.3 || ^3.0"
             },
-            "time": "2020-10-28T02:03:40+00:00",
+            "time": "2023-04-16T18:19:15+00:00",
             "type": "library",
             "extra": {
                 "branch-alias": {
@@ -1336,7 +1336,7 @@
             ],
             "support": {
                 "issues": "https://github.com/thephpleague/oauth2-client/issues",
-                "source": "https://github.com/thephpleague/oauth2-client/tree/2.6.0"
+                "source": "https://github.com/thephpleague/oauth2-client/tree/2.7.0"
             },
             "install-path": "../league/oauth2-client"
         },

+ 71 - 71
api/vendor/composer/installed.php

@@ -1,67 +1,67 @@
 <?php return array(
     'root' => array(
+        'name' => '__root__',
         'pretty_version' => 'dev-master',
         'version' => 'dev-master',
+        'reference' => '3ecd8fb1e5f285e2ac49f681c82131b6b08bac50',
         'type' => 'library',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
-        'reference' => '1ae02fda50382bb86ea50b76579b5e773c4a1ff1',
-        'name' => '__root__',
         'dev' => true,
     ),
     'versions' => array(
         '__root__' => array(
             'pretty_version' => 'dev-master',
             'version' => 'dev-master',
+            'reference' => '3ecd8fb1e5f285e2ac49f681c82131b6b08bac50',
             'type' => 'library',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),
-            'reference' => '1ae02fda50382bb86ea50b76579b5e773c4a1ff1',
             'dev_requirement' => false,
         ),
         'adldap2/adldap2' => array(
             'pretty_version' => 'v10.4.1',
             'version' => '10.4.1.0',
+            'reference' => '81aeb283f56e216ae925a9cc4241de56b1fd4453',
             'type' => 'library',
             'install_path' => __DIR__ . '/../adldap2/adldap2',
             'aliases' => array(),
-            'reference' => '81aeb283f56e216ae925a9cc4241de56b1fd4453',
             'dev_requirement' => false,
         ),
         'bcremer/line-reader' => array(
             'pretty_version' => '1.1.0',
             'version' => '1.1.0.0',
+            'reference' => '3ec3e200577630f1e58d30b4c1c468b877d8d0a7',
             'type' => 'library',
             'install_path' => __DIR__ . '/../bcremer/line-reader',
             'aliases' => array(),
-            'reference' => '3ec3e200577630f1e58d30b4c1c468b877d8d0a7',
             'dev_requirement' => false,
         ),
         'bogstag/oauth2-trakt' => array(
             'pretty_version' => 'v1.0.1',
             'version' => '1.0.1.0',
+            'reference' => 'fbb9253d9e317e84dc2b3f1253afc1dcbb4414a2',
             'type' => 'library',
             'install_path' => __DIR__ . '/../bogstag/oauth2-trakt',
             'aliases' => array(),
-            'reference' => 'fbb9253d9e317e84dc2b3f1253afc1dcbb4414a2',
             'dev_requirement' => false,
         ),
         'brick/math' => array(
             'pretty_version' => '0.9.3',
             'version' => '0.9.3.0',
+            'reference' => 'ca57d18f028f84f777b2168cd1911b0dee2343ae',
             'type' => 'library',
             'install_path' => __DIR__ . '/../brick/math',
             'aliases' => array(),
-            'reference' => 'ca57d18f028f84f777b2168cd1911b0dee2343ae',
             'dev_requirement' => false,
         ),
         'composer/semver' => array(
             'pretty_version' => '1.7.2',
             'version' => '1.7.2.0',
+            'reference' => '647490bbcaf7fc4891c58f47b825eb99d19c377a',
             'type' => 'library',
             'install_path' => __DIR__ . '/./semver',
             'aliases' => array(),
-            'reference' => '647490bbcaf7fc4891c58f47b825eb99d19c377a',
             'dev_requirement' => false,
         ),
         'dg/dibi' => array(
@@ -73,154 +73,154 @@
         'dibi/dibi' => array(
             'pretty_version' => 'v4.2.6',
             'version' => '4.2.6.0',
+            'reference' => '9d5d430d3d04ad8aff0e1570390e9cfbb7f3c538',
             'type' => 'library',
             'install_path' => __DIR__ . '/../dibi/dibi',
             'aliases' => array(),
-            'reference' => '9d5d430d3d04ad8aff0e1570390e9cfbb7f3c538',
             'dev_requirement' => false,
         ),
         'doctrine/annotations' => array(
             'pretty_version' => '1.10.3',
             'version' => '1.10.3.0',
+            'reference' => '5db60a4969eba0e0c197a19c077780aadbc43c5d',
             'type' => 'library',
             'install_path' => __DIR__ . '/../doctrine/annotations',
             'aliases' => array(),
-            'reference' => '5db60a4969eba0e0c197a19c077780aadbc43c5d',
             'dev_requirement' => false,
         ),
         'doctrine/collections' => array(
             'pretty_version' => '1.6.8',
             'version' => '1.6.8.0',
+            'reference' => '1958a744696c6bb3bb0d28db2611dc11610e78af',
             'type' => 'library',
             'install_path' => __DIR__ . '/../doctrine/collections',
             'aliases' => array(),
-            'reference' => '1958a744696c6bb3bb0d28db2611dc11610e78af',
             'dev_requirement' => false,
         ),
         'doctrine/lexer' => array(
             'pretty_version' => '1.2.1',
             'version' => '1.2.1.0',
+            'reference' => 'e864bbf5904cb8f5bb334f99209b48018522f042',
             'type' => 'library',
             'install_path' => __DIR__ . '/../doctrine/lexer',
             'aliases' => array(),
-            'reference' => 'e864bbf5904cb8f5bb334f99209b48018522f042',
             'dev_requirement' => false,
         ),
         'dragonmantank/cron-expression' => array(
             'pretty_version' => 'v3.1.0',
             'version' => '3.1.0.0',
+            'reference' => '7a8c6e56ab3ffcc538d05e8155bb42269abf1a0c',
             'type' => 'library',
             'install_path' => __DIR__ . '/../dragonmantank/cron-expression',
             'aliases' => array(),
-            'reference' => '7a8c6e56ab3ffcc538d05e8155bb42269abf1a0c',
             'dev_requirement' => false,
         ),
         'fig/http-message-util' => array(
             'pretty_version' => '1.1.4',
             'version' => '1.1.4.0',
+            'reference' => '3242caa9da7221a304b8f84eb9eaddae0a7cf422',
             'type' => 'library',
             'install_path' => __DIR__ . '/../fig/http-message-util',
             'aliases' => array(),
-            'reference' => '3242caa9da7221a304b8f84eb9eaddae0a7cf422',
             'dev_requirement' => false,
         ),
         'guzzlehttp/guzzle' => array(
             'pretty_version' => '7.3.0',
             'version' => '7.3.0.0',
+            'reference' => '7008573787b430c1c1f650e3722d9bba59967628',
             'type' => 'library',
             'install_path' => __DIR__ . '/../guzzlehttp/guzzle',
             'aliases' => array(),
-            'reference' => '7008573787b430c1c1f650e3722d9bba59967628',
             'dev_requirement' => false,
         ),
         'guzzlehttp/promises' => array(
             'pretty_version' => '1.4.1',
             'version' => '1.4.1.0',
+            'reference' => '8e7d04f1f6450fef59366c399cfad4b9383aa30d',
             'type' => 'library',
             'install_path' => __DIR__ . '/../guzzlehttp/promises',
             'aliases' => array(),
-            'reference' => '8e7d04f1f6450fef59366c399cfad4b9383aa30d',
             'dev_requirement' => false,
         ),
         'guzzlehttp/psr7' => array(
             'pretty_version' => '1.8.2',
             'version' => '1.8.2.0',
+            'reference' => 'dc960a912984efb74d0a90222870c72c87f10c91',
             'type' => 'library',
             'install_path' => __DIR__ . '/../guzzlehttp/psr7',
             'aliases' => array(),
-            'reference' => 'dc960a912984efb74d0a90222870c72c87f10c91',
             'dev_requirement' => false,
         ),
         'illuminate/contracts' => array(
             'pretty_version' => 'v5.8.0',
             'version' => '5.8.0.0',
+            'reference' => '3e3a9a654adbf798e05491a5dbf90112df1effde',
             'type' => 'library',
             'install_path' => __DIR__ . '/../illuminate/contracts',
             'aliases' => array(),
-            'reference' => '3e3a9a654adbf798e05491a5dbf90112df1effde',
             'dev_requirement' => false,
         ),
         'kryptonit3/couchpotato' => array(
             'pretty_version' => '1.0.0',
             'version' => '1.0.0.0',
+            'reference' => '7a1fc892f70f120f74ff005850e923a0f1566376',
             'type' => 'library',
             'install_path' => __DIR__ . '/../kryptonit3/couchpotato',
             'aliases' => array(),
-            'reference' => '7a1fc892f70f120f74ff005850e923a0f1566376',
             'dev_requirement' => false,
         ),
         'kryptonit3/sickrage' => array(
             'pretty_version' => '1.0.1',
             'version' => '1.0.1.0',
+            'reference' => '441a293b5c219c3cdd1ebebd2bcf4518598f84aa',
             'type' => 'library',
             'install_path' => __DIR__ . '/../kryptonit3/sickrage',
             'aliases' => array(),
-            'reference' => '441a293b5c219c3cdd1ebebd2bcf4518598f84aa',
             'dev_requirement' => false,
         ),
         'kryptonit3/sonarr' => array(
             'pretty_version' => '1.0.6.1',
             'version' => '1.0.6.1',
+            'reference' => 'e30c5c783a837270bcef81571ca9b95909c52e5e',
             'type' => 'library',
             'install_path' => __DIR__ . '/../kryptonit3/sonarr',
             'aliases' => array(),
-            'reference' => 'e30c5c783a837270bcef81571ca9b95909c52e5e',
             'dev_requirement' => false,
         ),
         'lcobucci/clock' => array(
             'pretty_version' => '2.0.0',
             'version' => '2.0.0.0',
+            'reference' => '353d83fe2e6ae95745b16b3d911813df6a05bfb3',
             'type' => 'library',
             'install_path' => __DIR__ . '/../lcobucci/clock',
             'aliases' => array(),
-            'reference' => '353d83fe2e6ae95745b16b3d911813df6a05bfb3',
             'dev_requirement' => false,
         ),
         'lcobucci/jwt' => array(
             'pretty_version' => '4.1.5',
             'version' => '4.1.5.0',
+            'reference' => 'fe2d89f2eaa7087af4aa166c6f480ef04e000582',
             'type' => 'library',
             'install_path' => __DIR__ . '/../lcobucci/jwt',
             'aliases' => array(),
-            'reference' => 'fe2d89f2eaa7087af4aa166c6f480ef04e000582',
             'dev_requirement' => false,
         ),
         'league/oauth2-client' => array(
-            'pretty_version' => '2.6.0',
-            'version' => '2.6.0.0',
+            'pretty_version' => '2.7.0',
+            'version' => '2.7.0.0',
+            'reference' => '160d6274b03562ebeb55ed18399281d8118b76c8',
             'type' => 'library',
             'install_path' => __DIR__ . '/../league/oauth2-client',
             'aliases' => array(),
-            'reference' => 'badb01e62383430706433191b82506b6df24ad98',
             'dev_requirement' => false,
         ),
         'monolog/monolog' => array(
             'pretty_version' => '1.26.1',
             'version' => '1.26.1.0',
+            'reference' => 'c6b00f05152ae2c9b04a448f99c7590beb6042f5',
             'type' => 'library',
             'install_path' => __DIR__ . '/../monolog/monolog',
             'aliases' => array(),
-            'reference' => 'c6b00f05152ae2c9b04a448f99c7590beb6042f5',
             'dev_requirement' => false,
         ),
         'mtdowling/cron-expression' => array(
@@ -232,136 +232,136 @@
         'myclabs/php-enum' => array(
             'pretty_version' => '1.8.0',
             'version' => '1.8.0.0',
+            'reference' => '46cf3d8498b095bd33727b13fd5707263af99421',
             'type' => 'library',
             'install_path' => __DIR__ . '/../myclabs/php-enum',
             'aliases' => array(),
-            'reference' => '46cf3d8498b095bd33727b13fd5707263af99421',
             'dev_requirement' => false,
         ),
         'nekonomokochan/php-json-logger' => array(
             'pretty_version' => 'v1.3.1',
             'version' => '1.3.1.0',
+            'reference' => '6df126a82940a00d8ea2da6e0b7c58e3e57eb132',
             'type' => 'library',
             'install_path' => __DIR__ . '/../nekonomokochan/php-json-logger',
             'aliases' => array(),
-            'reference' => '6df126a82940a00d8ea2da6e0b7c58e3e57eb132',
             'dev_requirement' => false,
         ),
         'nikic/fast-route' => array(
             'pretty_version' => 'v1.3.0',
             'version' => '1.3.0.0',
+            'reference' => '181d480e08d9476e61381e04a71b34dc0432e812',
             'type' => 'library',
             'install_path' => __DIR__ . '/../nikic/fast-route',
             'aliases' => array(),
-            'reference' => '181d480e08d9476e61381e04a71b34dc0432e812',
             'dev_requirement' => false,
         ),
         'paquettg/php-html-parser' => array(
             'pretty_version' => '3.1.1',
             'version' => '3.1.1.0',
+            'reference' => '4e01a438ad5961cc2d7427eb9798d213c8a12629',
             'type' => 'library',
             'install_path' => __DIR__ . '/../paquettg/php-html-parser',
             'aliases' => array(),
-            'reference' => '4e01a438ad5961cc2d7427eb9798d213c8a12629',
             'dev_requirement' => false,
         ),
         'paquettg/string-encode' => array(
             'pretty_version' => '1.0.1',
             'version' => '1.0.1.0',
+            'reference' => 'a8708e9fac9d5ddfc8fc2aac6004e2cd05d80fee',
             'type' => 'library',
             'install_path' => __DIR__ . '/../paquettg/string-encode',
             'aliases' => array(),
-            'reference' => 'a8708e9fac9d5ddfc8fc2aac6004e2cd05d80fee',
             'dev_requirement' => false,
         ),
         'paragonie/constant_time_encoding' => array(
             'pretty_version' => 'v2.2.2',
             'version' => '2.2.2.0',
+            'reference' => 'eccf915f45f911bfb189d1d1638d940ec6ee6e33',
             'type' => 'library',
             'install_path' => __DIR__ . '/../paragonie/constant_time_encoding',
             'aliases' => array(),
-            'reference' => 'eccf915f45f911bfb189d1d1638d940ec6ee6e33',
             'dev_requirement' => false,
         ),
         'paragonie/random_compat' => array(
             'pretty_version' => 'v9.99.100',
             'version' => '9.99.100.0',
+            'reference' => '996434e5492cb4c3edcb9168db6fbb1359ef965a',
             'type' => 'library',
             'install_path' => __DIR__ . '/../paragonie/random_compat',
             'aliases' => array(),
-            'reference' => '996434e5492cb4c3edcb9168db6fbb1359ef965a',
             'dev_requirement' => false,
         ),
         'paragonie/sodium_compat' => array(
             'pretty_version' => 'v1.6.4',
             'version' => '1.6.4.0',
+            'reference' => '3f2fd07977541b4d630ea0365ad0eceddee5179c',
             'type' => 'library',
             'install_path' => __DIR__ . '/../paragonie/sodium_compat',
             'aliases' => array(),
-            'reference' => '3f2fd07977541b4d630ea0365ad0eceddee5179c',
             'dev_requirement' => false,
         ),
         'peppeocchi/php-cron-scheduler' => array(
             'pretty_version' => 'v4.0',
             'version' => '4.0.0.0',
+            'reference' => '0acfa032e60f0ea22a27b96a6b15a673a31d3448',
             'type' => 'library',
             'install_path' => __DIR__ . '/../peppeocchi/php-cron-scheduler',
             'aliases' => array(),
-            'reference' => '0acfa032e60f0ea22a27b96a6b15a673a31d3448',
             'dev_requirement' => false,
         ),
         'php-http/httplug' => array(
             'pretty_version' => '2.2.0',
             'version' => '2.2.0.0',
+            'reference' => '191a0a1b41ed026b717421931f8d3bd2514ffbf9',
             'type' => 'library',
             'install_path' => __DIR__ . '/../php-http/httplug',
             'aliases' => array(),
-            'reference' => '191a0a1b41ed026b717421931f8d3bd2514ffbf9',
             'dev_requirement' => false,
         ),
         'php-http/promise' => array(
             'pretty_version' => '1.1.0',
             'version' => '1.1.0.0',
+            'reference' => '4c4c1f9b7289a2ec57cde7f1e9762a5789506f88',
             'type' => 'library',
             'install_path' => __DIR__ . '/../php-http/promise',
             'aliases' => array(),
-            'reference' => '4c4c1f9b7289a2ec57cde7f1e9762a5789506f88',
             'dev_requirement' => false,
         ),
         'phpmailer/phpmailer' => array(
             'pretty_version' => 'v6.5.0',
             'version' => '6.5.0.0',
+            'reference' => 'a5b5c43e50b7fba655f793ad27303cd74c57363c',
             'type' => 'library',
             'install_path' => __DIR__ . '/../phpmailer/phpmailer',
             'aliases' => array(),
-            'reference' => 'a5b5c43e50b7fba655f793ad27303cd74c57363c',
             'dev_requirement' => false,
         ),
         'pragmarx/google2fa' => array(
             'pretty_version' => 'v3.0.3',
             'version' => '3.0.3.0',
+            'reference' => '6949226739e4424f40031e6f1c96b1fd64047335',
             'type' => 'library',
             'install_path' => __DIR__ . '/../pragmarx/google2fa',
             'aliases' => array(),
-            'reference' => '6949226739e4424f40031e6f1c96b1fd64047335',
             'dev_requirement' => false,
         ),
         'psr/container' => array(
             'pretty_version' => '1.1.1',
             'version' => '1.1.1.0',
+            'reference' => '8622567409010282b7aeebe4bb841fe98b58dcaf',
             'type' => 'library',
             'install_path' => __DIR__ . '/../psr/container',
             'aliases' => array(),
-            'reference' => '8622567409010282b7aeebe4bb841fe98b58dcaf',
             'dev_requirement' => false,
         ),
         'psr/http-client' => array(
             'pretty_version' => '1.0.1',
             'version' => '1.0.1.0',
+            'reference' => '2dfb5f6c5eff0e91e20e913f8c5452ed95b86621',
             'type' => 'library',
             'install_path' => __DIR__ . '/../psr/http-client',
             'aliases' => array(),
-            'reference' => '2dfb5f6c5eff0e91e20e913f8c5452ed95b86621',
             'dev_requirement' => false,
         ),
         'psr/http-client-implementation' => array(
@@ -373,10 +373,10 @@
         'psr/http-factory' => array(
             'pretty_version' => '1.0.1',
             'version' => '1.0.1.0',
+            'reference' => '12ac7fcd07e5b077433f5f2bee95b3a771bf61be',
             'type' => 'library',
             'install_path' => __DIR__ . '/../psr/http-factory',
             'aliases' => array(),
-            'reference' => '12ac7fcd07e5b077433f5f2bee95b3a771bf61be',
             'dev_requirement' => false,
         ),
         'psr/http-factory-implementation' => array(
@@ -388,10 +388,10 @@
         'psr/http-message' => array(
             'pretty_version' => '1.0.1',
             'version' => '1.0.1.0',
+            'reference' => 'f6561bf28d520154e4b0ec72be95418abe6d9363',
             'type' => 'library',
             'install_path' => __DIR__ . '/../psr/http-message',
             'aliases' => array(),
-            'reference' => 'f6561bf28d520154e4b0ec72be95418abe6d9363',
             'dev_requirement' => false,
         ),
         'psr/http-message-implementation' => array(
@@ -403,28 +403,28 @@
         'psr/http-server-handler' => array(
             'pretty_version' => '1.0.1',
             'version' => '1.0.1.0',
+            'reference' => 'aff2f80e33b7f026ec96bb42f63242dc50ffcae7',
             'type' => 'library',
             'install_path' => __DIR__ . '/../psr/http-server-handler',
             'aliases' => array(),
-            'reference' => 'aff2f80e33b7f026ec96bb42f63242dc50ffcae7',
             'dev_requirement' => false,
         ),
         'psr/http-server-middleware' => array(
             'pretty_version' => '1.0.1',
             'version' => '1.0.1.0',
+            'reference' => '2296f45510945530b9dceb8bcedb5cb84d40c5f5',
             'type' => 'library',
             'install_path' => __DIR__ . '/../psr/http-server-middleware',
             'aliases' => array(),
-            'reference' => '2296f45510945530b9dceb8bcedb5cb84d40c5f5',
             'dev_requirement' => false,
         ),
         'psr/log' => array(
             'pretty_version' => '1.1.4',
             'version' => '1.1.4.0',
+            'reference' => 'd49695b909c3b7628b6289db5479a1c204601f11',
             'type' => 'library',
             'install_path' => __DIR__ . '/../psr/log',
             'aliases' => array(),
-            'reference' => 'd49695b909c3b7628b6289db5479a1c204601f11',
             'dev_requirement' => false,
         ),
         'psr/log-implementation' => array(
@@ -436,46 +436,46 @@
         'psr/simple-cache' => array(
             'pretty_version' => '1.0.1',
             'version' => '1.0.1.0',
+            'reference' => '408d5eafb83c57f6365a3ca330ff23aa4a5fa39b',
             'type' => 'library',
             'install_path' => __DIR__ . '/../psr/simple-cache',
             'aliases' => array(),
-            'reference' => '408d5eafb83c57f6365a3ca330ff23aa4a5fa39b',
             'dev_requirement' => false,
         ),
         'pusher/pusher-php-server' => array(
             'pretty_version' => 'v4.1.5',
             'version' => '4.1.5.0',
+            'reference' => '251f22602320c1b1aff84798fe74f3f7ee0504a9',
             'type' => 'library',
             'install_path' => __DIR__ . '/../pusher/pusher-php-server',
             'aliases' => array(),
-            'reference' => '251f22602320c1b1aff84798fe74f3f7ee0504a9',
             'dev_requirement' => false,
         ),
         'ralouphie/getallheaders' => array(
             'pretty_version' => '3.0.3',
             'version' => '3.0.3.0',
+            'reference' => '120b605dfeb996808c31b6477290a714d356e822',
             'type' => 'library',
             'install_path' => __DIR__ . '/../ralouphie/getallheaders',
             'aliases' => array(),
-            'reference' => '120b605dfeb996808c31b6477290a714d356e822',
             'dev_requirement' => false,
         ),
         'ramsey/collection' => array(
             'pretty_version' => '1.2.2',
             'version' => '1.2.2.0',
+            'reference' => 'cccc74ee5e328031b15640b51056ee8d3bb66c0a',
             'type' => 'library',
             'install_path' => __DIR__ . '/../ramsey/collection',
             'aliases' => array(),
-            'reference' => 'cccc74ee5e328031b15640b51056ee8d3bb66c0a',
             'dev_requirement' => false,
         ),
         'ramsey/uuid' => array(
             'pretty_version' => '4.2.3',
             'version' => '4.2.3.0',
+            'reference' => 'fc9bb7fb5388691fd7373cd44dcb4d63bbcf24df',
             'type' => 'library',
             'install_path' => __DIR__ . '/../ramsey/uuid',
             'aliases' => array(),
-            'reference' => 'fc9bb7fb5388691fd7373cd44dcb4d63bbcf24df',
             'dev_requirement' => false,
         ),
         'rhumsaa/uuid' => array(
@@ -487,172 +487,172 @@
         'rmccue/requests' => array(
             'pretty_version' => 'v1.8.0',
             'version' => '1.8.0.0',
+            'reference' => 'afbe4790e4def03581c4a0963a1e8aa01f6030f1',
             'type' => 'library',
             'install_path' => __DIR__ . '/../rmccue/requests',
             'aliases' => array(),
-            'reference' => 'afbe4790e4def03581c4a0963a1e8aa01f6030f1',
             'dev_requirement' => false,
         ),
         'simshaun/recurr' => array(
             'pretty_version' => 'v5.0.0',
             'version' => '5.0.0.0',
+            'reference' => 'b5aa5b07a595023b67a558b810390dfa7160e3f5',
             'type' => 'library',
             'install_path' => __DIR__ . '/../simshaun/recurr',
             'aliases' => array(),
-            'reference' => 'b5aa5b07a595023b67a558b810390dfa7160e3f5',
             'dev_requirement' => false,
         ),
         'slim/psr7' => array(
             'pretty_version' => '1.3.0',
             'version' => '1.3.0.0',
+            'reference' => '235d2e5a5ee1ad4b97b96870f37f3091b22fffd7',
             'type' => 'library',
             'install_path' => __DIR__ . '/../slim/psr7',
             'aliases' => array(),
-            'reference' => '235d2e5a5ee1ad4b97b96870f37f3091b22fffd7',
             'dev_requirement' => false,
         ),
         'slim/slim' => array(
             'pretty_version' => '4.7.1',
             'version' => '4.7.1.0',
+            'reference' => '0905e0775f8c1cfb3bbcfabeb6588dcfd8b82d3f',
             'type' => 'library',
             'install_path' => __DIR__ . '/../slim/slim',
             'aliases' => array(),
-            'reference' => '0905e0775f8c1cfb3bbcfabeb6588dcfd8b82d3f',
             'dev_requirement' => false,
         ),
         'stripe/stripe-php' => array(
             'pretty_version' => 'v7.116.0',
             'version' => '7.116.0.0',
+            'reference' => '7a39f594f213ed3f443a95adf769d1ecbc8393e7',
             'type' => 'library',
             'install_path' => __DIR__ . '/../stripe/stripe-php',
             'aliases' => array(),
-            'reference' => '7a39f594f213ed3f443a95adf769d1ecbc8393e7',
             'dev_requirement' => false,
         ),
         'symfony/deprecation-contracts' => array(
             'pretty_version' => 'v2.1.3',
             'version' => '2.1.3.0',
+            'reference' => '5e20b83385a77593259c9f8beb2c43cd03b2ac14',
             'type' => 'library',
             'install_path' => __DIR__ . '/../symfony/deprecation-contracts',
             'aliases' => array(),
-            'reference' => '5e20b83385a77593259c9f8beb2c43cd03b2ac14',
             'dev_requirement' => false,
         ),
         'symfony/finder' => array(
             'pretty_version' => 'v5.1.3',
             'version' => '5.1.3.0',
+            'reference' => '4298870062bfc667cb78d2b379be4bf5dec5f187',
             'type' => 'library',
             'install_path' => __DIR__ . '/../symfony/finder',
             'aliases' => array(),
-            'reference' => '4298870062bfc667cb78d2b379be4bf5dec5f187',
             'dev_requirement' => false,
         ),
         'symfony/polyfill-ctype' => array(
             'pretty_version' => 'v1.22.1',
             'version' => '1.22.1.0',
+            'reference' => 'c6c942b1ac76c82448322025e084cadc56048b4e',
             'type' => 'library',
             'install_path' => __DIR__ . '/../symfony/polyfill-ctype',
             'aliases' => array(),
-            'reference' => 'c6c942b1ac76c82448322025e084cadc56048b4e',
             'dev_requirement' => false,
         ),
         'symfony/polyfill-mbstring' => array(
             'pretty_version' => 'v1.22.1',
             'version' => '1.22.1.0',
+            'reference' => '5232de97ee3b75b0360528dae24e73db49566ab1',
             'type' => 'library',
             'install_path' => __DIR__ . '/../symfony/polyfill-mbstring',
             'aliases' => array(),
-            'reference' => '5232de97ee3b75b0360528dae24e73db49566ab1',
             'dev_requirement' => false,
         ),
         'symfony/polyfill-php56' => array(
             'pretty_version' => 'v1.9.0',
             'version' => '1.9.0.0',
+            'reference' => '7b4fc009172cc0196535b0328bd1226284a28000',
             'type' => 'library',
             'install_path' => __DIR__ . '/../symfony/polyfill-php56',
             'aliases' => array(),
-            'reference' => '7b4fc009172cc0196535b0328bd1226284a28000',
             'dev_requirement' => false,
         ),
         'symfony/polyfill-php72' => array(
             'pretty_version' => 'v1.22.1',
             'version' => '1.22.1.0',
+            'reference' => 'cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9',
             'type' => 'library',
             'install_path' => __DIR__ . '/../symfony/polyfill-php72',
             'aliases' => array(),
-            'reference' => 'cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9',
             'dev_requirement' => false,
         ),
         'symfony/polyfill-php80' => array(
             'pretty_version' => 'v1.22.1',
             'version' => '1.22.1.0',
+            'reference' => 'dc3063ba22c2a1fd2f45ed856374d79114998f91',
             'type' => 'library',
             'install_path' => __DIR__ . '/../symfony/polyfill-php80',
             'aliases' => array(),
-            'reference' => 'dc3063ba22c2a1fd2f45ed856374d79114998f91',
             'dev_requirement' => false,
         ),
         'symfony/polyfill-php81' => array(
             'pretty_version' => 'v1.25.0',
             'version' => '1.25.0.0',
+            'reference' => '5de4ba2d41b15f9bd0e19b2ab9674135813ec98f',
             'type' => 'library',
             'install_path' => __DIR__ . '/../symfony/polyfill-php81',
             'aliases' => array(),
-            'reference' => '5de4ba2d41b15f9bd0e19b2ab9674135813ec98f',
             'dev_requirement' => false,
         ),
         'symfony/polyfill-util' => array(
             'pretty_version' => 'v1.9.0',
             'version' => '1.9.0.0',
+            'reference' => '8e15d04ba3440984d23e7964b2ee1d25c8de1581',
             'type' => 'library',
             'install_path' => __DIR__ . '/../symfony/polyfill-util',
             'aliases' => array(),
-            'reference' => '8e15d04ba3440984d23e7964b2ee1d25c8de1581',
             'dev_requirement' => false,
         ),
         'symfony/var-dumper' => array(
             'pretty_version' => 'v4.2.3',
             'version' => '4.2.3.0',
+            'reference' => '223bda89f9be41cf7033eeaf11bc61a280489c17',
             'type' => 'library',
             'install_path' => __DIR__ . '/../symfony/var-dumper',
             'aliases' => array(),
-            'reference' => '223bda89f9be41cf7033eeaf11bc61a280489c17',
             'dev_requirement' => false,
         ),
         'symfony/yaml' => array(
             'pretty_version' => 'v5.1.3',
             'version' => '5.1.3.0',
+            'reference' => 'ea342353a3ef4f453809acc4ebc55382231d4d23',
             'type' => 'library',
             'install_path' => __DIR__ . '/../symfony/yaml',
             'aliases' => array(),
-            'reference' => 'ea342353a3ef4f453809acc4ebc55382231d4d23',
             'dev_requirement' => false,
         ),
         'tightenco/collect' => array(
             'pretty_version' => 'v5.7.27',
             'version' => '5.7.27.0',
+            'reference' => 'c1a36a2a8a0aa731c1acdcd83f57724ffe630d00',
             'type' => 'library',
             'install_path' => __DIR__ . '/../tightenco/collect',
             'aliases' => array(),
-            'reference' => 'c1a36a2a8a0aa731c1acdcd83f57724ffe630d00',
             'dev_requirement' => false,
         ),
         'webmozart/assert' => array(
             'pretty_version' => '1.10.0',
             'version' => '1.10.0.0',
+            'reference' => '6964c76c7804814a842473e0c8fd15bab0f18e25',
             'type' => 'library',
             'install_path' => __DIR__ . '/../webmozart/assert',
             'aliases' => array(),
-            'reference' => '6964c76c7804814a842473e0c8fd15bab0f18e25',
             'dev_requirement' => false,
         ),
         'zircote/swagger-php' => array(
             'pretty_version' => '3.0.4',
             'version' => '3.0.4.0',
+            'reference' => 'fa47d62c22c95272625624fbf8109fa46ffac43b',
             'type' => 'library',
             'install_path' => __DIR__ . '/../zircote/swagger-php',
             'aliases' => array(),
-            'reference' => 'fa47d62c22c95272625624fbf8109fa46ffac43b',
             'dev_requirement' => false,
         ),
     ),

+ 2 - 3
api/vendor/composer/platform_check.php

@@ -19,8 +19,7 @@ if ($issues) {
             echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
         }
     }
-    trigger_error(
-        'Composer detected issues in your platform: ' . implode(' ', $issues),
-        E_USER_ERROR
+    throw new \RuntimeException(
+        'Composer detected issues in your platform: ' . implode(' ', $issues)
     );
 }

+ 20 - 233
api/vendor/league/oauth2-client/README.md

@@ -1,268 +1,55 @@
 # OAuth 2.0 Client
 
-This package makes it simple to integrate your application with [OAuth 2.0](http://oauth.net/2/) service providers.
+This package provides a base for integrating with [OAuth 2.0](http://oauth.net/2/) service providers.
 
 [![Gitter Chat](https://img.shields.io/badge/gitter-join_chat-brightgreen.svg?style=flat-square)](https://gitter.im/thephpleague/oauth2-client)
 [![Source Code](https://img.shields.io/badge/source-thephpleague/oauth2--client-blue.svg?style=flat-square)](https://github.com/thephpleague/oauth2-client)
 [![Latest Version](https://img.shields.io/github/release/thephpleague/oauth2-client.svg?style=flat-square)](https://github.com/thephpleague/oauth2-client/releases)
 [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](https://github.com/thephpleague/oauth2-client/blob/master/LICENSE)
-[![Build Status](https://img.shields.io/github/workflow/status/thephpleague/oauth2-client/CI?label=CI&logo=github&style=flat-square)](https://github.com/thephpleague/oauth2-client/actions?query=workflow%3ACI)
+[![Build Status](https://img.shields.io/github/actions/workflow/status/thephpleague/oauth2-client/continuous-integration.yml?label=CI&logo=github&style=flat-square)](https://github.com/thephpleague/oauth2-client/actions?query=workflow%3ACI)
 [![Codecov Code Coverage](https://img.shields.io/codecov/c/gh/thephpleague/oauth2-client?label=codecov&logo=codecov&style=flat-square)](https://codecov.io/gh/thephpleague/oauth2-client)
 [![Total Downloads](https://img.shields.io/packagist/dt/league/oauth2-client.svg?style=flat-square)](https://packagist.org/packages/league/oauth2-client)
 
 ---
 
-We are all used to seeing those "Connect with Facebook/Google/etc." buttons around the internet, and social network integration is an important feature of most web applications these days. Many of these sites use an authentication and authorization standard called OAuth 2.0 ([RFC 6749](http://tools.ietf.org/html/rfc6749)).
+The OAuth 2.0 login flow, seen commonly around the web in the form of "Connect with Facebook/Google/etc." buttons, is a common integration added to web applications, but it can be tricky and tedious to do right. To help, we've created the `league/oauth2-client` package, which provides a base for integrating with various OAuth 2.0 providers, without overburdening your application with the concerns of [RFC 6749](http://tools.ietf.org/html/rfc6749).
 
-This OAuth 2.0 client library will work with any OAuth provider that conforms to the OAuth 2.0 standard. Out-of-the-box, we provide a `GenericProvider` that may be used to connect to any service provider that uses [Bearer tokens](http://tools.ietf.org/html/rfc6750) (see example below).
+This OAuth 2.0 client library will work with any OAuth 2.0 provider that conforms to the OAuth 2.0 Authorization Framework. Out-of-the-box, we provide a `GenericProvider` class to connect to any service provider that uses [Bearer tokens](http://tools.ietf.org/html/rfc6750). See our [basic usage guide](https://oauth2-client.thephpleague.com/usage/) for examples using `GenericProvider`.
 
-Many service providers provide additional functionality above and beyond the OAuth 2.0 standard. For this reason, this library may be easily extended and wrapped to support this additional behavior. We provide links to [all known provider clients extending this library](docs/providers/thirdparty.md) (i.e. Facebook, GitHub, Google, Instagram, LinkedIn, etc.). If your provider isn't in the list, feel free to add it.
+Many service providers provide additional functionality above and beyond the OAuth 2.0 specification. For this reason, you may extend and wrap this library to support additional behavior. There are already many [official](https://oauth2-client.thephpleague.com/providers/league/) and [third-party](https://oauth2-client.thephpleague.com/providers/thirdparty/) provider clients available (e.g., Facebook, GitHub, Google, Instagram, LinkedIn, etc.). If your provider isn't in the list, feel free to add it.
 
-This package is compliant with [PSR-1][], [PSR-2][], [PSR-4][], and [PSR-7][]. If you notice compliance oversights, please send a patch via pull request. If you're interesting in contributing to this library, please take a look at our [contributing guidelines](CONTRIBUTING.md).
+This package is compliant with [PSR-1][], [PSR-2][], [PSR-4][], and [PSR-7][]. If you notice compliance oversights, please send a patch via pull request. If you're interested in contributing to this library, please take a look at our [contributing guidelines](https://github.com/thephpleague/oauth2-client/blob/master/CONTRIBUTING.md).
 
 ## Requirements
 
-The following versions of PHP are supported.
+We support the following versions of PHP:
 
-* PHP 5.6
-* PHP 7.0
-* PHP 7.1
-* PHP 7.2
-* PHP 7.3
-* PHP 7.4
+* PHP 8.1
 * PHP 8.0
+* PHP 7.4
+* PHP 7.3
+* PHP 7.2
+* PHP 7.1
+* PHP 7.0
+* PHP 5.6
 
-## Providers
+## Provider Clients
 
-A list of official PHP League providers, as well as third-party providers, may be found in the [providers list README](docs/providers/thirdparty.md).
+We provide a list of [official PHP League provider clients](https://oauth2-client.thephpleague.com/providers/league/), as well as [third-party provider clients](https://oauth2-client.thephpleague.com/providers/thirdparty/).
 
-To build your own provider, please refer to the [provider guide README](README.PROVIDER-GUIDE.md).
+To build your own provider client, please refer to "[Implementing a Provider Client](https://oauth2-client.thephpleague.com/providers/implementing/)."
 
 ## Usage
 
-**In most cases, you'll want to use a specific provider client library rather than this base library.**
-
-Take a look at [providers list README](docs/providers/thirdparty.md) to see a list of provider client libraries.
-
-If using Composer to require a specific provider client library, you **do not need to also require this library**. Composer will handle the dependencies for you.
-
-### Authorization Code Grant
-
-The following example uses the out-of-the-box `GenericProvider` provided by this library. If you're looking for a specific provider (i.e. Facebook, Google, GitHub, etc.), take a look at our [list of provider client libraries](docs/providers/thirdparty.md). **HINT: You're probably looking for a specific provider.**
-
-The authorization code grant type is the most common grant type used when authenticating users with a third-party service. This grant type utilizes a client (this library), a server (the service provider), and a resource owner (the user with credentials to a protected—or owned—resource) to request access to resources owned by the user. This is often referred to as _3-legged OAuth_, since there are three parties involved.
-
-The following example illustrates this using [Brent Shaffer's](https://github.com/bshaffer) demo OAuth 2.0 application named **Lock'd In**. When running this code, you will be redirected to Lock'd In, where you'll be prompted to authorize the client to make requests to a resource on your behalf.
-
-Now, you don't really have an account on Lock'd In, but for the sake of this example, imagine that you are already logged in on Lock'd In when you are redirected there.
-
-```php
-$provider = new \League\OAuth2\Client\Provider\GenericProvider([
-    'clientId'                => 'demoapp',    // The client ID assigned to you by the provider
-    'clientSecret'            => 'demopass',   // The client password assigned to you by the provider
-    'redirectUri'             => 'http://example.com/your-redirect-url/',
-    'urlAuthorize'            => 'http://brentertainment.com/oauth2/lockdin/authorize',
-    'urlAccessToken'          => 'http://brentertainment.com/oauth2/lockdin/token',
-    'urlResourceOwnerDetails' => 'http://brentertainment.com/oauth2/lockdin/resource'
-]);
-
-// If we don't have an authorization code then get one
-if (!isset($_GET['code'])) {
-
-    // Fetch the authorization URL from the provider; this returns the
-    // urlAuthorize option and generates and applies any necessary parameters
-    // (e.g. state).
-    $authorizationUrl = $provider->getAuthorizationUrl();
-
-    // Get the state generated for you and store it to the session.
-    $_SESSION['oauth2state'] = $provider->getState();
-
-    // Redirect the user to the authorization URL.
-    header('Location: ' . $authorizationUrl);
-    exit;
-
-// Check given state against previously stored one to mitigate CSRF attack
-} elseif (empty($_GET['state']) || (isset($_SESSION['oauth2state']) && $_GET['state'] !== $_SESSION['oauth2state'])) {
-
-    if (isset($_SESSION['oauth2state'])) {
-        unset($_SESSION['oauth2state']);
-    }
-    
-    exit('Invalid state');
-
-} else {
-
-    try {
-
-        // Try to get an access token using the authorization code grant.
-        $accessToken = $provider->getAccessToken('authorization_code', [
-            'code' => $_GET['code']
-        ]);
-
-        // We have an access token, which we may use in authenticated
-        // requests against the service provider's API.
-        echo 'Access Token: ' . $accessToken->getToken() . "<br>";
-        echo 'Refresh Token: ' . $accessToken->getRefreshToken() . "<br>";
-        echo 'Expired in: ' . $accessToken->getExpires() . "<br>";
-        echo 'Already expired? ' . ($accessToken->hasExpired() ? 'expired' : 'not expired') . "<br>";
-
-        // Using the access token, we may look up details about the
-        // resource owner.
-        $resourceOwner = $provider->getResourceOwner($accessToken);
-
-        var_export($resourceOwner->toArray());
-
-        // The provider provides a way to get an authenticated API request for
-        // the service, using the access token; it returns an object conforming
-        // to Psr\Http\Message\RequestInterface.
-        $request = $provider->getAuthenticatedRequest(
-            'GET',
-            'http://brentertainment.com/oauth2/lockdin/resource',
-            $accessToken
-        );
-
-    } catch (\League\OAuth2\Client\Provider\Exception\IdentityProviderException $e) {
-
-        // Failed to get the access token or user details.
-        exit($e->getMessage());
-
-    }
-
-}
-```
-
-### Refreshing a Token
-
-Once your application is authorized, you can refresh an expired token using a refresh token rather than going through the entire process of obtaining a brand new token. To do so, simply reuse this refresh token from your data store to request a refresh.
-
-_This example uses [Brent Shaffer's](https://github.com/bshaffer) demo OAuth 2.0 application named **Lock'd In**. See authorization code example above, for more details._
-
-```php
-$provider = new \League\OAuth2\Client\Provider\GenericProvider([
-    'clientId'                => 'demoapp',    // The client ID assigned to you by the provider
-    'clientSecret'            => 'demopass',   // The client password assigned to you by the provider
-    'redirectUri'             => 'http://example.com/your-redirect-url/',
-    'urlAuthorize'            => 'http://brentertainment.com/oauth2/lockdin/authorize',
-    'urlAccessToken'          => 'http://brentertainment.com/oauth2/lockdin/token',
-    'urlResourceOwnerDetails' => 'http://brentertainment.com/oauth2/lockdin/resource'
-]);
-
-$existingAccessToken = getAccessTokenFromYourDataStore();
-
-if ($existingAccessToken->hasExpired()) {
-    $newAccessToken = $provider->getAccessToken('refresh_token', [
-        'refresh_token' => $existingAccessToken->getRefreshToken()
-    ]);
-
-    // Purge old access token and store new access token to your data store.
-}
-```
-
-### Resource Owner Password Credentials Grant
-
-Some service providers allow you to skip the authorization code step to exchange a user's credentials (username and password) for an access token. This is referred to as the "resource owner password credentials" grant type.
-
-According to [section 1.3.3](http://tools.ietf.org/html/rfc6749#section-1.3.3) of the OAuth 2.0 standard (emphasis added):
-
-> The credentials **should only be used when there is a high degree of trust**
-> between the resource owner and the client (e.g., the client is part of the
-> device operating system or a highly privileged application), and when other
-> authorization grant types are not available (such as an authorization code).
-
-**We do not advise using this grant type if the service provider supports the authorization code grant type (see above), as this reinforces the [password anti-pattern](https://agentile.com/the-password-anti-pattern) by allowing users to think it's okay to trust third-party applications with their usernames and passwords.**
-
-That said, there are use-cases where the resource owner password credentials grant is acceptable and useful. Here's an example using it with [Brent Shaffer's](https://github.com/bshaffer) demo OAuth 2.0 application named **Lock'd In**. See authorization code example above, for more details about the Lock'd In demo application.
-
-``` php
-$provider = new \League\OAuth2\Client\Provider\GenericProvider([
-    'clientId'                => 'demoapp',    // The client ID assigned to you by the provider
-    'clientSecret'            => 'demopass',   // The client password assigned to you by the provider
-    'redirectUri'             => 'http://example.com/your-redirect-url/',
-    'urlAuthorize'            => 'http://brentertainment.com/oauth2/lockdin/authorize',
-    'urlAccessToken'          => 'http://brentertainment.com/oauth2/lockdin/token',
-    'urlResourceOwnerDetails' => 'http://brentertainment.com/oauth2/lockdin/resource'
-]);
-
-try {
-
-    // Try to get an access token using the resource owner password credentials grant.
-    $accessToken = $provider->getAccessToken('password', [
-        'username' => 'demouser',
-        'password' => 'testpass'
-    ]);
-
-} catch (\League\OAuth2\Client\Provider\Exception\IdentityProviderException $e) {
-
-    // Failed to get the access token
-    exit($e->getMessage());
-
-}
-```
-
-### Client Credentials Grant
-
-When your application is acting on its own behalf to access resources it controls/owns in a service provider, it may use the client credentials grant type. This is best used when the credentials for your application are stored privately and never exposed (e.g. through the web browser, etc.) to end-users. This grant type functions similarly to the resource owner password credentials grant type, but it does not request a user's username or password. It uses only the client ID and secret issued to your client by the service provider.
-
-Unlike earlier examples, the following does not work against a functioning demo service provider. It is provided for the sake of example only.
-
-``` php
-// Note: the GenericProvider requires the `urlAuthorize` option, even though
-// it's not used in the OAuth 2.0 client credentials grant type.
-
-$provider = new \League\OAuth2\Client\Provider\GenericProvider([
-    'clientId'                => 'XXXXXX',    // The client ID assigned to you by the provider
-    'clientSecret'            => 'XXXXXX',    // The client password assigned to you by the provider
-    'redirectUri'             => 'http://my.example.com/your-redirect-url/',
-    'urlAuthorize'            => 'http://service.example.com/authorize',
-    'urlAccessToken'          => 'http://service.example.com/token',
-    'urlResourceOwnerDetails' => 'http://service.example.com/resource'
-]);
-
-try {
-
-    // Try to get an access token using the client credentials grant.
-    $accessToken = $provider->getAccessToken('client_credentials');
-
-} catch (\League\OAuth2\Client\Provider\Exception\IdentityProviderException $e) {
-
-    // Failed to get the access token
-    exit($e->getMessage());
-
-}
-```
-
-### Using a proxy
-
-It is possible to use a proxy to debug HTTP calls made to a provider. All you need to do is set the `proxy` and `verify` options when creating your Provider instance. Make sure you enable SSL proxying in your proxy.
-
-``` php
-$provider = new \League\OAuth2\Client\Provider\GenericProvider([
-    'clientId'                => 'XXXXXX',    // The client ID assigned to you by the provider
-    'clientSecret'            => 'XXXXXX',    // The client password assigned to you by the provider
-    'redirectUri'             => 'http://my.example.com/your-redirect-url/',
-    'urlAuthorize'            => 'http://service.example.com/authorize',
-    'urlAccessToken'          => 'http://service.example.com/token',
-    'urlResourceOwnerDetails' => 'http://service.example.com/resource',
-    'proxy'                   => '192.168.0.1:8888',
-    'verify'                  => false
-]);
-```
-
-## Install
-
-Via Composer
-
-``` bash
-$ composer require league/oauth2-client
-```
+For usage and code examples, check out our [basic usage guide](https://oauth2-client.thephpleague.com/usage/).
 
 ## Contributing
 
-Please see [CONTRIBUTING](https://github.com/thephpleague/oauth2-client/blob/master/CONTRIBUTING.md) for details.
+Please see [our contributing guidelines](https://github.com/thephpleague/oauth2-client/blob/master/CONTRIBUTING.md) for details.
 
 ## License
 
-The MIT License (MIT). Please see [License File](https://github.com/thephpleague/oauth2-client/blob/master/LICENSE) for more information.
+The MIT License (MIT). Please see [LICENSE](https://github.com/thephpleague/oauth2-client/blob/master/LICENSE) for more information.
 
 
 [PSR-1]: https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-1-basic-coding-standard.md

+ 3 - 3
api/vendor/league/oauth2-client/composer.json

@@ -11,9 +11,9 @@
         "paragonie/random_compat": "^1 || ^2 || ^9.99"
     },
     "require-dev": {
-        "mockery/mockery": "^1.3",
-        "php-parallel-lint/php-parallel-lint": "^1.2",
-        "phpunit/phpunit": "^5.7 || ^6.0 || ^9.3",
+        "mockery/mockery": "^1.3.5",
+        "php-parallel-lint/php-parallel-lint": "^1.3.1",
+        "phpunit/phpunit": "^5.7 || ^6.0 || ^9.5",
         "squizlabs/php_codesniffer": "^2.3 || ^3.0"
     },
     "keywords": [

+ 102 - 4
api/vendor/league/oauth2-client/src/Provider/AbstractProvider.php

@@ -17,6 +17,7 @@ namespace League\OAuth2\Client\Provider;
 use GuzzleHttp\Client as HttpClient;
 use GuzzleHttp\ClientInterface as HttpClientInterface;
 use GuzzleHttp\Exception\BadResponseException;
+use InvalidArgumentException;
 use League\OAuth2\Client\Grant\AbstractGrant;
 use League\OAuth2\Client\Grant\GrantFactory;
 use League\OAuth2\Client\OptionProvider\OptionProviderInterface;
@@ -44,7 +45,7 @@ abstract class AbstractProvider
     use QueryBuilderTrait;
 
     /**
-     * @var string Key used in a token response to identify the resource owner.
+     * @var string|null Key used in a token response to identify the resource owner.
      */
     const ACCESS_TOKEN_RESOURCE_OWNER_ID = null;
 
@@ -58,6 +59,19 @@ abstract class AbstractProvider
      */
     const METHOD_POST = 'POST';
 
+    /**
+     * @var string PKCE method used to fetch authorization token.
+     * The PKCE code challenge will be hashed with sha256 (recommended).
+     */
+    const PKCE_METHOD_S256 = 'S256';
+
+    /**
+     * @var string PKCE method used to fetch authorization token.
+     * The PKCE code challenge will be sent as plain text, this is NOT recommended.
+     * Only use `plain` if no other option is possible.
+     */
+    const PKCE_METHOD_PLAIN = 'plain';
+
     /**
      * @var string
      */
@@ -78,6 +92,11 @@ abstract class AbstractProvider
      */
     protected $state;
 
+    /**
+     * @var string|null
+     */
+    protected $pkceCode = null;
+
     /**
      * @var GrantFactory
      */
@@ -264,6 +283,32 @@ abstract class AbstractProvider
         return $this->state;
     }
 
+    /**
+     * Set the value of the pkceCode parameter.
+     *
+     * When using PKCE this should be set before requesting an access token.
+     *
+     * @param string $pkceCode
+     * @return self
+     */
+    public function setPkceCode($pkceCode)
+    {
+        $this->pkceCode = $pkceCode;
+        return $this;
+    }
+
+    /**
+     * Returns the current value of the pkceCode parameter.
+     *
+     * This can be accessed by the redirect handler during authorization.
+     *
+     * @return string|null
+     */
+    public function getPkceCode()
+    {
+        return $this->pkceCode;
+    }
+
     /**
      * Returns the base URL for authorizing a client.
      *
@@ -305,6 +350,27 @@ abstract class AbstractProvider
         return bin2hex(random_bytes($length / 2));
     }
 
+    /**
+     * Returns a new random string to use as PKCE code_verifier and
+     * hashed as code_challenge parameters in an authorization flow.
+     * Must be between 43 and 128 characters long.
+     *
+     * @param  int $length Length of the random string to be generated.
+     * @return string
+     */
+    protected function getRandomPkceCode($length = 64)
+    {
+        return substr(
+            strtr(
+                base64_encode(random_bytes($length)),
+                '+/',
+                '-_'
+            ),
+            0,
+            $length
+        );
+    }
+
     /**
      * Returns the default scopes used by this provider.
      *
@@ -326,6 +392,14 @@ abstract class AbstractProvider
         return ',';
     }
 
+    /**
+     * @return string|null
+     */
+    protected function getPkceMethod()
+    {
+        return null;
+    }
+
     /**
      * Returns authorization parameters based on provided options.
      *
@@ -355,6 +429,26 @@ abstract class AbstractProvider
         // Store the state as it may need to be accessed later on.
         $this->state = $options['state'];
 
+        $pkceMethod = $this->getPkceMethod();
+        if (!empty($pkceMethod)) {
+            $this->pkceCode = $this->getRandomPkceCode();
+            if ($pkceMethod === static::PKCE_METHOD_S256) {
+                $options['code_challenge'] = trim(
+                    strtr(
+                        base64_encode(hash('sha256', $this->pkceCode, true)),
+                        '+/',
+                        '-_'
+                    ),
+                    '='
+                );
+            } elseif ($pkceMethod === static::PKCE_METHOD_PLAIN) {
+                $options['code_challenge'] = $this->pkceCode;
+            } else {
+                throw new InvalidArgumentException('Unknown PKCE method "' . $pkceMethod . '".');
+            }
+            $options['code_challenge_method'] = $pkceMethod;
+        }
+
         // Business code layer might set a different redirect_uri parameter
         // depending on the context, leave it as-is
         if (!isset($options['redirect_uri'])) {
@@ -517,8 +611,8 @@ abstract class AbstractProvider
     /**
      * Requests an access token using a specified grant and option set.
      *
-     * @param  mixed $grant
-     * @param  array $options
+     * @param  mixed                $grant
+     * @param  array<string, mixed> $options
      * @throws IdentityProviderException
      * @return AccessTokenInterface
      */
@@ -532,6 +626,10 @@ abstract class AbstractProvider
             'redirect_uri'  => $this->redirectUri,
         ];
 
+        if (!empty($this->pkceCode)) {
+            $params['code_verifier'] = $this->pkceCode;
+        }
+
         $params   = $grant->prepareRequestParameters($params, $options);
         $request  = $this->getAccessTokenRequest($params);
         $response = $this->getParsedResponse($request);
@@ -564,7 +662,7 @@ abstract class AbstractProvider
      *
      * @param  string $method
      * @param  string $url
-     * @param  AccessTokenInterface|string $token
+     * @param  AccessTokenInterface|string|null $token
      * @param  array $options Any of "headers", "body", and "protocolVersion".
      * @return RequestInterface
      */

+ 2 - 2
api/vendor/league/oauth2-client/src/Provider/Exception/IdentityProviderException.php

@@ -27,7 +27,7 @@ class IdentityProviderException extends \Exception
     /**
      * @param string $message
      * @param int $code
-     * @param array|string $response The response body
+     * @param mixed $response The response body
      */
     public function __construct($message, $code, $response)
     {
@@ -39,7 +39,7 @@ class IdentityProviderException extends \Exception
     /**
      * Returns the exception's response body.
      *
-     * @return array|string
+     * @return mixed
      */
     public function getResponseBody()
     {

+ 14 - 0
api/vendor/league/oauth2-client/src/Provider/GenericProvider.php

@@ -78,6 +78,11 @@ class GenericProvider extends AbstractProvider
      */
     private $responseResourceOwnerId = 'id';
 
+    /**
+     * @var string|null
+     */
+    private $pkceMethod = null;
+
     /**
      * @param array $options
      * @param array $collaborators
@@ -114,6 +119,7 @@ class GenericProvider extends AbstractProvider
             'responseCode',
             'responseResourceOwnerId',
             'scopes',
+            'pkceMethod',
         ]);
     }
 
@@ -205,6 +211,14 @@ class GenericProvider extends AbstractProvider
         return $this->scopeSeparator ?: parent::getScopeSeparator();
     }
 
+    /**
+     * @inheritdoc
+     */
+    protected function getPkceMethod()
+    {
+        return $this->pkceMethod ?: parent::getPkceMethod();
+    }
+
     /**
      * @inheritdoc
      */

+ 2 - 0
api/vendor/league/oauth2-client/src/Token/AccessTokenInterface.php

@@ -15,6 +15,7 @@
 namespace League\OAuth2\Client\Token;
 
 use JsonSerializable;
+use ReturnTypeWillChange;
 use RuntimeException;
 
 interface AccessTokenInterface extends JsonSerializable
@@ -68,5 +69,6 @@ interface AccessTokenInterface extends JsonSerializable
      *
      * @return array
      */
+    #[ReturnTypeWillChange]
     public function jsonSerialize();
 }

+ 1 - 1
api/vendor/league/oauth2-client/src/Tool/QueryBuilderTrait.php

@@ -28,6 +28,6 @@ trait QueryBuilderTrait
      */
     protected function buildQueryString(array $params)
     {
-        return http_build_query($params, null, '&', \PHP_QUERY_RFC3986);
+        return http_build_query($params, '', '&', \PHP_QUERY_RFC3986);
     }
 }

Разница между файлами не показана из-за своего большого размера
+ 681 - 550
css/organizr.css


+ 0 - 139
debug_jellystat_metadata.php

@@ -1,139 +0,0 @@
-<?php
-/**
- * Debug script to test JellyStat metadata functionality
- * Run this to troubleshoot why metadata returns "Unknown Item"
- */
-
-require_once 'api/config/config.php';
-require_once 'api/classes/organizr.php';
-
-// Create Organizr instance (this will load config)
-$organizr = new Organizr();
-
-echo "=== JellyStat Metadata Debug Tool ===\n\n";
-
-// Check configuration
-$jellyStatURL = $organizr->config['jellyStatURL'] ?? '';
-$jellyStatInternalURL = $organizr->config['jellyStatInternalURL'] ?? '';
-$jellyStatApikey = $organizr->config['jellyStatApikey'] ?? '';
-$enabled = $organizr->config['homepageJellyStatEnabled'] ?? false;
-
-echo "Configuration:\n";
-echo "- JellyStat URL: " . ($jellyStatURL ?: 'NOT SET') . "\n";
-echo "- Internal URL: " . ($jellyStatInternalURL ?: 'NOT SET') . "\n";  
-echo "- API Key: " . ($jellyStatApikey ? 'SET (****)' : 'NOT SET') . "\n";
-echo "- Plugin Enabled: " . ($enabled ? 'YES' : 'NO') . "\n\n";
-
-if (!$enabled) {
-    echo "❌ JellyStat plugin is not enabled!\n";
-    echo "Enable it in Organizr settings first.\n";
-    exit(1);
-}
-
-if (!$jellyStatURL) {
-    echo "❌ JellyStat URL is not configured!\n";
-    echo "Set jellyStatURL in Organizr settings first.\n";
-    exit(1);
-}
-
-// Test basic connectivity
-$testUrl = !empty($jellyStatInternalURL) ? $jellyStatInternalURL : $jellyStatURL;
-$testUrl = rtrim($organizr->qualifyURL($testUrl), '/');
-
-echo "Testing connectivity to: {$testUrl}\n\n";
-
-// Test endpoints that the metadata function would try
-$testKey = 'test-item-id';
-$endpoints = [];
-
-if ($jellyStatApikey) {
-    $endpoints['JellyStat API (getItem)'] = $testUrl . '/api/getItem?apiKey=' . urlencode($jellyStatApikey) . '&id=' . urlencode($testKey);
-    $endpoints['JellyStat API (getItemById)'] = $testUrl . '/api/getItemById?apiKey=' . urlencode($jellyStatApikey) . '&id=' . urlencode($testKey);
-}
-
-$endpoints['Jellyfin Proxy (basic)'] = $testUrl . '/proxy/Items/' . rawurlencode($testKey);
-$endpoints['Jellyfin Proxy (with fields)'] = $testUrl . '/proxy/Items/' . rawurlencode($testKey) . '?Fields=Overview,People,Genres,CommunityRating,CriticRating,Studios,Taglines,ProductionYear,PremiereDate';
-
-// Also test a real item ID if provided via command line
-if (isset($argv[1])) {
-    $realKey = $argv[1];
-    echo "Testing with real item ID: {$realKey}\n\n";
-    
-    if ($jellyStatApikey) {
-        $endpoints['Real Item - JellyStat API'] = $testUrl . '/api/getItem?apiKey=' . urlencode($jellyStatApikey) . '&id=' . urlencode($realKey);
-    }
-    $endpoints['Real Item - Jellyfin Proxy'] = $testUrl . '/proxy/Items/' . rawurlencode($realKey) . '?Fields=Overview,People,Genres,CommunityRating,CriticRating,Studios,Taglines,ProductionYear,PremiereDate';
-}
-
-foreach ($endpoints as $name => $url) {
-    echo "Testing {$name}:\n";
-    echo "URL: {$url}\n";
-    
-    try {
-        $options = $organizr->requestOptions($url, null, 
-            $organizr->config['jellyStatDisableCertCheck'] ?? false,
-            $organizr->config['jellyStatUseCustomCertificate'] ?? false
-        );
-        
-        $response = Requests::get($url, [], $options);
-        
-        if ($response->success) {
-            $data = json_decode($response->body, true);
-            
-            if (json_last_error() === JSON_ERROR_NONE) {
-                echo "✅ SUCCESS - Status: {$response->status_code}\n";
-                
-                if (is_array($data)) {
-                    $keys = array_keys($data);
-                    echo "Response has keys: " . implode(', ', array_slice($keys, 0, 10)) . 
-                         (count($keys) > 10 ? '... (' . count($keys) . ' total)' : '') . "\n";
-                    
-                    // Look for typical media metadata fields
-                    $mediaFields = ['Name', 'Id', 'Type', 'Overview', 'Genres', 'People', 'RunTimeTicks', 'ProductionYear'];
-                    $foundFields = array_intersect($keys, $mediaFields);
-                    if (!empty($foundFields)) {
-                        echo "Media fields found: " . implode(', ', $foundFields) . "\n";
-                        
-                        // Show sample values
-                        foreach (['Name', 'Type', 'Overview'] as $field) {
-                            if (isset($data[$field])) {
-                                $value = $data[$field];
-                                if (is_string($value) && strlen($value) > 50) {
-                                    $value = substr($value, 0, 47) . '...';
-                                }
-                                echo "{$field}: {$value}\n";
-                            }
-                        }
-                    }
-                } else {
-                    echo "Response is not an array: " . gettype($data) . "\n";
-                }
-            } else {
-                echo "❌ Invalid JSON response\n";
-                echo "Raw response: " . substr($response->body, 0, 200) . "...\n";
-            }
-        } else {
-            echo "❌ HTTP Error: {$response->status_code}\n";
-            if (!empty($response->body)) {
-                echo "Error body: " . substr($response->body, 0, 200) . "...\n";
-            }
-        }
-        
-    } catch (Exception $e) {
-        echo "❌ Exception: " . $e->getMessage() . "\n";
-    }
-    
-    echo "\n" . str_repeat('-', 60) . "\n\n";
-}
-
-echo "=== Debug Complete ===\n\n";
-
-echo "Next steps if endpoints are failing:\n";
-echo "1. Verify JellyStat is running and accessible\n";  
-echo "2. Check if API key is correct and has permissions\n";
-echo "3. Test URLs directly in browser/curl\n";
-echo "4. Check JellyStat logs for errors\n";
-echo "5. Verify firewall/network connectivity\n\n";
-
-echo "If you have a working item ID from JellyStat, run:\n";
-echo "php debug_jellystat_metadata.php YOUR-ITEM-ID\n";

+ 55 - 0
docs/api.json

@@ -3087,6 +3087,61 @@
                 ]
             }
         },
+        "/api/v2/test/jellystat": {
+            "post": {
+                "tags": [
+                    "test connection"
+                ],
+                "summary": "Test connection to JellyStat",
+                "responses": {
+                    "200": {
+                        "description": "Success",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/success-message"
+                                }
+                            }
+                        }
+                    },
+                    "401": {
+                        "description": "Unauthorized",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/unauthorized-message"
+                                }
+                            }
+                        }
+                    },
+                    "422": {
+                        "description": "Error",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/error-message"
+                                }
+                            }
+                        }
+                    },
+                    "500": {
+                        "description": "Error",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/error-message"
+                                }
+                            }
+                        }
+                    }
+                },
+                "security": [
+                    {
+                        "api_key": []
+                    }
+                ]
+            }
+        },
         "/api/v2/emby/register": {
             "post": {
                 "tags": [

Разница между файлами не показана из-за своего большого размера
+ 1727 - 1228
js/functions.js


+ 137 - 128
js/news.json

@@ -1,129 +1,138 @@
 [
-    {
-        "title": "PHP 7.4 Required",
-        "subTitle": "Starting May 13th",
-        "date": "2022-05-12 06:02",
-        "body": "Starting on May 13th, Organizr will now require PHP 7.4.  If you are using the organizr/organizr container image, you are fine.  The version change will coincide with Organizr version 2.1.2000.",
-        "author": "CauseFX",
-        "important": true,
-        "id": 15
-    },
-    {
-        "title": "New Plugin in Marketplace",
-        "subTitle": "More Themes in Marketplace",
-        "date": "2021-10-03 23:40",
-        "body": "Make sure to upgrade Organizr before installing!!! TehMuffinMoo had a great idea for a Plex Library plugin and did a great job implementing it.  The plugin is now in the Marketplace.  As for themes, GilbN has created a few more in the Marketplace, be sure to check them out!",
-        "author": "CauseFX",
-        "important": false,
-        "id": 14
-    },
-    {
-        "title": "Feature Request Site Migration",
-        "subTitle": "Bye-bye Feature Upvote",
-        "date": "2021-09-25 18:40",
-        "body": "We have migrated away from Feature Upvote in favor for Fider.  Nothing is needed on your end.  We didn't want to use your email without your permission from Feature Upvote so we ended up creating all of the previous requests under an Organizr API email.  You may search for your old feature request and subscribe to it.  The URL is still the same - the button is also located in Organizr Tab menu at the bottom.",
-        "author": "CauseFX",
-        "important": true,
-        "id": 13
-    },
-    {
-        "title": "Minimum PHP Version change",
-        "subTitle": "Users that use containers are fine",
-        "date": "2021-05-03 04:25",
-        "body": "Starting from version 2.1.306 - The minimum needed php version is 7.3.0 - Make sure to update php before updating Organizr",
-        "author": "CauseFX",
-        "important": true,
-        "id": 12
-    },
-    {
-        "title": "Important Messages - Each message can now be ignored using ignore button",
-        "subTitle": "I know they were annoying to some people",
-        "date": "2021-01-24 15:30",
-        "body": "Just make sure to click (Important Message - Click me to Ignore)",
-        "author": "CauseFX",
-        "important": true,
-        "id": 11
-    },
-    {
-        "title": "API V2 TESTING almost complete",
-        "subTitle": "Organizr API v2 changes will break installs unless changes to webserver are completed",
-        "date": "2020-09-17 20:30",
-        "body": "This is just a warning that the changes will be merged 2nd of October. If you are using docker - make sure to use organizr/organizr - DO NOT USE organizrTools image!  Also with the changes you need to update your webserver if you haven't already.  A list of fixes for each webserver along with other breaking changes are noted in the migration note here: \u003Ca href=\"https://docs.organizr.app/books/setup-features/page/api-v2-webserver-changes-needed\", target=\"_blank\" rel=\"noopener noreferrer\"\u003EAPI migration note\u003C/a\u003E",
-        "author": "CauseFX",
-        "important": true,
-        "id": 10
-    },
-    {
-        "title": "Develop Branch Users - Please switch to Master for mean time",
-        "subTitle": "Organizr API v2 coming this week",
-        "date": "2020-08-24 15:00",
-        "body": "The new API is now complete and we plan on using the develop branch to do the testing.  So if you want to test and are okay with things possibly breaking, then by all means stay on the develop branch.  The API change is coming this week for sure so please be ready and make sure everyone has done the webserver location block addition.  If you do not do this and you upgrade, it will break your install.  Please refer to the migration note for details \u003Ca href=\"https://docs.organizr.app/books/setup-features/page/api-v2-webserver-changes-needed\", target=\"_blank\" rel=\"noopener noreferrer\"\u003EAPI migration note\u003C/a\u003E",
-        "author": "CauseFX",
-        "important": true,
-        "id": 9
-    },
-    {
-        "title": "New Organizr API v2",
-        "subTitle": "Breaking changes are coming!!!",
-        "date": "2020-08-07 17:00",
-        "body": "I am currently in the process of creating the 2nd version of the Organizr API.  With that being said, with the way v1 of API was written, it will be replaced with this new version. I will be writing portions of the API so the change will be minimal.  The first changes that will be pushed are the plugins portion of the API.  This will be pushed by August 14th.  Please refer to the migration note for details \u003Ca href=\"https://docs.organizr.app/books/setup-features/page/api-v2-webserver-changes-needed\", target=\"_blank\" rel=\"noopener noreferrer\"\u003EAPI migration note\u003C/a\u003E",
-        "author": "CauseFX",
-        "id": 8
-    },
-    {
-        "title": "New container",
-        "subTitle": "Deprecating organizrtools/organizr-v2",
-        "date": "2020-07-25 20:30",
-        "body": "Organizrtools/organizr-v2 is being deprecated, breaking changes are coming to Organizr, which this container wont be updated to handle. Please refer to the migration note for details \u003Ca href=\"https://github.com/Organizr/docker-organizr#migration\" target=\"_blank\" rel=\"noopener noreferrer\"\u003EContainer migration note\u003C/a\u003E",
-        "author": "Roxedus",
-        "id": 7
-    },
-    {
-        "title": "Plex OAuth Patch",
-        "subTitle": "You server hostname will now be listed on OAuth box",
-        "date": "2020-06-18 16:30",
-        "body": "Plex has now fixed the 3 open CVE's pertaining to the ability for an attacker to phish for an Plex Admins token.  Please follow this link to read more about it.  \u003Ca href=\"https://www.bleepingcomputer.com/news/security/plex-fixes-media-server-bugs-allowing-full-system-takeover/\" target=\"_blank\" rel=\"noopener noreferrer\"\u003EPlex Vulnerabilities Patched\u003C/a\u003E",
-        "author": "CauseFX",
-        "id": 6
-    },
-    {
-        "title": "Tab Redirect Loops and SameSite Cookie Issues",
-        "subTitle": "New Browser Restrictions",
-        "date": "2020-04-01 19:30",
-        "body": "It seems there are a lot of issues happening with redirect Loops and SameSite cookie issues.  If you are having issues, please read this document.  \u003Ca href=\"https://docs.organizr.app/books/troubleshooting/page/redirect-looping---samesite-errors\" target=\"_blank\" rel=\"noopener noreferrer\"\u003EOrganizr SameSite Docs\u003C/a\u003E",
-        "author": "CauseFX",
-        "id": 5
-    },
-    {
-        "title": "Plex Oauth Issues - RESOLVED!",
-        "subTitle": "Let's make them bring back support correctly",
-        "date": "2019-07-17 15:20",
-        "body": "It seems like Plex has broken support for us using Oauth.  Currently there is a workaround but we would rather they fix the issue.  Please share your feedback in this thread: \u003Ca href=\"https://forums.plex.tv/t/plex-oauth-not-working-with-tautulli-ombi-etc/433945\" target=\"_blank\" rel=\"noopener noreferrer\"\u003EPlex Oauth Discussion\u003C/a\u003E",
-        "author": "CauseFX",
-        "id": 4
-    },
-    {
-        "title": "Emby Discontinued Support",
-        "subTitle": "Emby API - EmbyConnect Deprecated",
-        "date": "2019-05-14 19:15",
-        "body": "Emby has discontinued support for matching users against Emby Connect via API.  Therefore, we will no longer be supporting this method of Authentication for Organizr.  If Emby decides to support this again, I will re-enable it once more.  You can find more information here: \u003Ca href=\"https://github.com/MediaBrowser/Emby/issues/3553\" target=\"_blank\" rel=\"noopener noreferrer\"\u003EEmby API Discussion\u003C/a\u003E",
-        "author": "CauseFX",
-        "id": 3
-    },
-    {
-        "title": "Welcome to Version 2.0",
-        "subTitle": "We finally made it...",
-        "date": "2019-03-01 16:30",
-        "body": "I want to thank all the alpha and beta testers.  It took us awhile but we did it!  I hope you enjoy using Organizr...",
-        "author": "CauseFX",
-        "id": 2
-    },
-    {
-        "title": "Testing out News Area",
-        "subTitle": null,
-        "date": "2019-02-22 21:40",
-        "body": "There will be news here in the future, thanks for bearing with me in the meantime throughout the v2 beta!",
-        "author": "CauseFX",
-        "id": 1
-    }
-]
+  {
+    "title": "Updates coming soon",
+    "subTitle": "Feature and QoL improvements",
+    "date": "2026-01-11 09:00",
+    "body": "We are working on some exciting new features for Organizr V2.  We are getting back into development.  QoL improvements are on the way as well as adding OIDC support. Stay tuned for updates!",
+    "author": "CauseFX",
+    "important": true,
+    "id": 16
+  },
+  {
+    "title": "PHP 7.4 Required",
+    "subTitle": "Starting May 13th",
+    "date": "2022-05-12 06:02",
+    "body": "Starting on May 13th, Organizr will now require PHP 7.4.  If you are using the organizr/organizr container image, you are fine.  The version change will coincide with Organizr version 2.1.2000.",
+    "author": "CauseFX",
+    "important": true,
+    "id": 15
+  },
+  {
+    "title": "New Plugin in Marketplace",
+    "subTitle": "More Themes in Marketplace",
+    "date": "2021-10-03 23:40",
+    "body": "Make sure to upgrade Organizr before installing!!! TehMuffinMoo had a great idea for a Plex Library plugin and did a great job implementing it.  The plugin is now in the Marketplace.  As for themes, GilbN has created a few more in the Marketplace, be sure to check them out!",
+    "author": "CauseFX",
+    "important": false,
+    "id": 14
+  },
+  {
+    "title": "Feature Request Site Migration",
+    "subTitle": "Bye-bye Feature Upvote",
+    "date": "2021-09-25 18:40",
+    "body": "We have migrated away from Feature Upvote in favor for Fider.  Nothing is needed on your end.  We didn't want to use your email without your permission from Feature Upvote so we ended up creating all of the previous requests under an Organizr API email.  You may search for your old feature request and subscribe to it.  The URL is still the same - the button is also located in Organizr Tab menu at the bottom.",
+    "author": "CauseFX",
+    "important": true,
+    "id": 13
+  },
+  {
+    "title": "Minimum PHP Version change",
+    "subTitle": "Users that use containers are fine",
+    "date": "2021-05-03 04:25",
+    "body": "Starting from version 2.1.306 - The minimum needed php version is 7.3.0 - Make sure to update php before updating Organizr",
+    "author": "CauseFX",
+    "important": true,
+    "id": 12
+  },
+  {
+    "title": "Important Messages - Each message can now be ignored using ignore button",
+    "subTitle": "I know they were annoying to some people",
+    "date": "2021-01-24 15:30",
+    "body": "Just make sure to click (Important Message - Click me to Ignore)",
+    "author": "CauseFX",
+    "important": true,
+    "id": 11
+  },
+  {
+    "title": "API V2 TESTING almost complete",
+    "subTitle": "Organizr API v2 changes will break installs unless changes to webserver are completed",
+    "date": "2020-09-17 20:30",
+    "body": "This is just a warning that the changes will be merged 2nd of October. If you are using docker - make sure to use organizr/organizr - DO NOT USE organizrTools image!  Also with the changes you need to update your webserver if you haven't already.  A list of fixes for each webserver along with other breaking changes are noted in the migration note here: \u003Ca href=\"https://docs.organizr.app/books/setup-features/page/api-v2-webserver-changes-needed\", target=\"_blank\" rel=\"noopener noreferrer\"\u003EAPI migration note\u003C/a\u003E",
+    "author": "CauseFX",
+    "important": true,
+    "id": 10
+  },
+  {
+    "title": "Develop Branch Users - Please switch to Master for mean time",
+    "subTitle": "Organizr API v2 coming this week",
+    "date": "2020-08-24 15:00",
+    "body": "The new API is now complete and we plan on using the develop branch to do the testing.  So if you want to test and are okay with things possibly breaking, then by all means stay on the develop branch.  The API change is coming this week for sure so please be ready and make sure everyone has done the webserver location block addition.  If you do not do this and you upgrade, it will break your install.  Please refer to the migration note for details \u003Ca href=\"https://docs.organizr.app/books/setup-features/page/api-v2-webserver-changes-needed\", target=\"_blank\" rel=\"noopener noreferrer\"\u003EAPI migration note\u003C/a\u003E",
+    "author": "CauseFX",
+    "important": true,
+    "id": 9
+  },
+  {
+    "title": "New Organizr API v2",
+    "subTitle": "Breaking changes are coming!!!",
+    "date": "2020-08-07 17:00",
+    "body": "I am currently in the process of creating the 2nd version of the Organizr API.  With that being said, with the way v1 of API was written, it will be replaced with this new version. I will be writing portions of the API so the change will be minimal.  The first changes that will be pushed are the plugins portion of the API.  This will be pushed by August 14th.  Please refer to the migration note for details \u003Ca href=\"https://docs.organizr.app/books/setup-features/page/api-v2-webserver-changes-needed\", target=\"_blank\" rel=\"noopener noreferrer\"\u003EAPI migration note\u003C/a\u003E",
+    "author": "CauseFX",
+    "id": 8
+  },
+  {
+    "title": "New container",
+    "subTitle": "Deprecating organizrtools/organizr-v2",
+    "date": "2020-07-25 20:30",
+    "body": "Organizrtools/organizr-v2 is being deprecated, breaking changes are coming to Organizr, which this container wont be updated to handle. Please refer to the migration note for details \u003Ca href=\"https://github.com/Organizr/docker-organizr#migration\" target=\"_blank\" rel=\"noopener noreferrer\"\u003EContainer migration note\u003C/a\u003E",
+    "author": "Roxedus",
+    "id": 7
+  },
+  {
+    "title": "Plex OAuth Patch",
+    "subTitle": "You server hostname will now be listed on OAuth box",
+    "date": "2020-06-18 16:30",
+    "body": "Plex has now fixed the 3 open CVE's pertaining to the ability for an attacker to phish for an Plex Admins token.  Please follow this link to read more about it.  \u003Ca href=\"https://www.bleepingcomputer.com/news/security/plex-fixes-media-server-bugs-allowing-full-system-takeover/\" target=\"_blank\" rel=\"noopener noreferrer\"\u003EPlex Vulnerabilities Patched\u003C/a\u003E",
+    "author": "CauseFX",
+    "id": 6
+  },
+  {
+    "title": "Tab Redirect Loops and SameSite Cookie Issues",
+    "subTitle": "New Browser Restrictions",
+    "date": "2020-04-01 19:30",
+    "body": "It seems there are a lot of issues happening with redirect Loops and SameSite cookie issues.  If you are having issues, please read this document.  \u003Ca href=\"https://docs.organizr.app/books/troubleshooting/page/redirect-looping---samesite-errors\" target=\"_blank\" rel=\"noopener noreferrer\"\u003EOrganizr SameSite Docs\u003C/a\u003E",
+    "author": "CauseFX",
+    "id": 5
+  },
+  {
+    "title": "Plex Oauth Issues - RESOLVED!",
+    "subTitle": "Let's make them bring back support correctly",
+    "date": "2019-07-17 15:20",
+    "body": "It seems like Plex has broken support for us using Oauth.  Currently there is a workaround but we would rather they fix the issue.  Please share your feedback in this thread: \u003Ca href=\"https://forums.plex.tv/t/plex-oauth-not-working-with-tautulli-ombi-etc/433945\" target=\"_blank\" rel=\"noopener noreferrer\"\u003EPlex Oauth Discussion\u003C/a\u003E",
+    "author": "CauseFX",
+    "id": 4
+  },
+  {
+    "title": "Emby Discontinued Support",
+    "subTitle": "Emby API - EmbyConnect Deprecated",
+    "date": "2019-05-14 19:15",
+    "body": "Emby has discontinued support for matching users against Emby Connect via API.  Therefore, we will no longer be supporting this method of Authentication for Organizr.  If Emby decides to support this again, I will re-enable it once more.  You can find more information here: \u003Ca href=\"https://github.com/MediaBrowser/Emby/issues/3553\" target=\"_blank\" rel=\"noopener noreferrer\"\u003EEmby API Discussion\u003C/a\u003E",
+    "author": "CauseFX",
+    "id": 3
+  },
+  {
+    "title": "Welcome to Version 2.0",
+    "subTitle": "We finally made it...",
+    "date": "2019-03-01 16:30",
+    "body": "I want to thank all the alpha and beta testers.  It took us awhile but we did it!  I hope you enjoy using Organizr...",
+    "author": "CauseFX",
+    "id": 2
+  },
+  {
+    "title": "Testing out News Area",
+    "subTitle": null,
+    "date": "2019-02-22 21:40",
+    "body": "There will be news here in the future, thanks for bearing with me in the meantime throughout the v2 beta!",
+    "author": "CauseFX",
+    "id": 1
+  }
+]

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
js/version.json


+ 1 - 1
scripts/linux-update.sh

@@ -6,7 +6,7 @@
 set -euo pipefail
 
 # Configuration
-GITHUB_REPO="${GITHUB_REPO:-metalcated/Organizr}"
+GITHUB_REPO="${GITHUB_REPO:-causefx/Organizr}"
 
 # Determine branch
 if [ -z "${1:-}" ]; then

+ 0 - 39
test_debug.php

@@ -1,39 +0,0 @@
-<?php
-error_reporting(E_ALL);
-ini_set('display_errors', 1);
-
-echo "Starting test...\n";
-
-// Include all necessary files
-$traitsPath = __DIR__ . '/api/functions/';
-$homepagePath = __DIR__ . '/api/homepage/';
-
-// Get all trait files
-$traitFiles = glob($traitsPath . '*.php');
-$homepageFiles = glob($homepagePath . '*.php');
-
-echo "Loading trait files...\n";
-foreach ($traitFiles as $file) {
-    echo "Including: " . basename($file) . "\n";
-    include_once $file;
-}
-
-echo "Loading homepage files...\n";
-foreach ($homepageFiles as $file) {
-    echo "Including: " . basename($file) . "\n";
-    include_once $file;
-}
-
-echo "Loading main class...\n";
-include_once __DIR__ . '/api/classes/organizr.class.php';
-
-echo "Creating instance...\n";
-try {
-    $organizr = new Organizr();
-    echo "SUCCESS: Class instantiated successfully!\n";
-} catch (Throwable $e) {
-    echo "ERROR: " . $e->getMessage() . "\n";
-    echo "File: " . $e->getFile() . "\n";
-    echo "Line: " . $e->getLine() . "\n";
-    echo "Trace:\n" . $e->getTraceAsString() . "\n";
-}

+ 0 - 37
test_jellystat_api.html

@@ -1,37 +0,0 @@
-<!DOCTYPE html>
-<html>
-<head>
-    <title>JellyStat API Test</title>
-</head>
-<body>
-    <h1>JellyStat Metadata API Test</h1>
-    <button onclick="testMetadata()">Test Metadata API</button>
-    <pre id="result"></pre>
-    
-    <script>
-    async function testMetadata() {
-        const token = '4wr9yn1z30k57hnsczpu';
-        const testKey = '123456'; // Replace with actual item ID
-        
-        try {
-            const response = await fetch('https://media.glassnetworks.net/api/v2/homepage/jellystat/metadata', {
-                method: 'POST',
-                headers: {
-                    'Content-Type': 'application/json',
-                    'Token': token
-                },
-                body: JSON.stringify({ key: testKey })
-            });
-            
-            const data = await response.json();
-            document.getElementById('result').textContent = JSON.stringify(data, null, 2);
-        } catch (error) {
-            document.getElementById('result').textContent = 'Error: ' + error.message;
-        }
-    }
-    
-    // Auto-run on load
-    window.onload = testMetadata;
-    </script>
-</body>
-</html>

Некоторые файлы не были показаны из-за большого количества измененных файлов