SettingService.php 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. <?php
  2. namespace App\Services;
  3. use App\Exceptions\DbEncryptionException;
  4. use App\Models\Option;
  5. use Exception;
  6. use Illuminate\Support\Collection;
  7. use Illuminate\Support\Facades\Cache;
  8. use Illuminate\Support\Facades\Crypt;
  9. use Illuminate\Support\Facades\DB;
  10. use Illuminate\Support\Facades\Log;
  11. use Throwable;
  12. class SettingService
  13. {
  14. /**
  15. * All settings
  16. *
  17. * @var Collection<string, mixed>
  18. */
  19. private Collection $settings;
  20. /**
  21. * Cache duration
  22. */
  23. private int $minutes = 10;
  24. /**
  25. * Name of the cache item where options are persisted
  26. */
  27. public const CACHE_ITEM_NAME = 'adminOptions';
  28. /**
  29. * Constructor
  30. */
  31. public function __construct()
  32. {
  33. $this->settings = Cache::remember(self::CACHE_ITEM_NAME, now()->addMinutes($this->minutes), function () {
  34. self::build();
  35. return $this->settings;
  36. });
  37. }
  38. /**
  39. * Get a setting
  40. *
  41. * @param string $setting A single setting name
  42. * @return mixed string|int|boolean|null
  43. */
  44. public function get($setting)
  45. {
  46. return $this->settings->get($setting);
  47. }
  48. /**
  49. * Get all settings
  50. *
  51. * @return Collection<string, mixed> the Settings collection
  52. */
  53. public function all() : Collection
  54. {
  55. return $this->settings;
  56. }
  57. /**
  58. * Set a setting
  59. *
  60. * @param string|array $setting A single setting name or an associative array of name:value settings
  61. * @param string|int|bool|null $value The value for single setting
  62. */
  63. public function set($setting, $value = null) : void
  64. {
  65. $settings = is_array($setting) ? $setting : [$setting => $value];
  66. foreach ($settings as $setting => $value) {
  67. if ($setting === 'useEncryption') {
  68. $this->setEncryptionTo($value);
  69. }
  70. $settings[$setting] = $this->replaceBoolean($value);
  71. }
  72. foreach ($settings as $setting => $value) {
  73. Option::updateOrCreate(['key' => $setting], ['value' => $value]);
  74. Log::notice(sprintf('App setting %s set to %s', var_export($setting, true), var_export($this->restoreType($value), true)));
  75. }
  76. self::buildAndCache();
  77. }
  78. /**
  79. * Delete a setting
  80. *
  81. * @param string $name The setting name
  82. */
  83. public function delete(string $name) : void
  84. {
  85. Option::where('key', $name)->delete();
  86. Log::notice(sprintf('App setting %s reset to default', var_export($name, true)));
  87. self::buildAndCache();
  88. }
  89. /**
  90. * Determine if the given setting has been edited
  91. *
  92. * @param string $key
  93. */
  94. public function isEdited($key) : bool
  95. {
  96. return DB::table('options')->where('key', $key)->exists();
  97. }
  98. /**
  99. * Set the settings collection
  100. *
  101. * @return void
  102. */
  103. private function build()
  104. {
  105. // Get a collection of saved options
  106. $options = DB::table('options')->pluck('value', 'key');
  107. $options->transform(function ($item, $key) {
  108. return $this->restoreType($item);
  109. });
  110. // Merge customized values with app default values
  111. $settings = collect(config('2fauth.settings'))->merge($options); /** @phpstan-ignore-line */
  112. $this->settings = $settings;
  113. }
  114. /**
  115. * Build and cache the options collection
  116. *
  117. * @return void
  118. */
  119. private function buildAndCache()
  120. {
  121. self::build();
  122. Cache::put(self::CACHE_ITEM_NAME, $this->settings, now()->addMinutes($this->minutes));
  123. }
  124. /**
  125. * Replaces boolean by a patterned string as appstrack/laravel-options package does not support var type
  126. *
  127. * @return string
  128. */
  129. private function replaceBoolean(mixed $value)
  130. {
  131. return is_bool($value) ? '{{' . $value . '}}' : $value;
  132. }
  133. /**
  134. * Replaces patterned string that represent booleans with real booleans
  135. *
  136. * @return mixed
  137. */
  138. private function restoreType(mixed $value)
  139. {
  140. if (is_numeric($value)) {
  141. $value = is_float($value + 0) ? (float) $value : (int) $value;
  142. }
  143. if ($value === '{{}}') {
  144. return false;
  145. } elseif ($value === '{{1}}') {
  146. return true;
  147. } else {
  148. return $value;
  149. }
  150. }
  151. /**
  152. * Enable or Disable encryption of 2FAccounts sensible data
  153. *
  154. *
  155. * @throws DbEncryptionException Something failed, everything have been rolled back
  156. */
  157. private function setEncryptionTo(bool $state) : void
  158. {
  159. // We don't want the records to be encrypted/decrypted multiple successive times
  160. $isInUse = $this->get('useEncryption');
  161. if ($isInUse === ! $state) {
  162. if ($this->updateRecords($state)) {
  163. if ($state) {
  164. Log::notice('Sensible data are now encrypted');
  165. } else {
  166. Log::notice('Sensible data are now decrypted');
  167. }
  168. } else {
  169. Log::warning('Some data cannot be encrypted/decrypted, the useEncryption setting remain unchanged');
  170. throw new DbEncryptionException($state === true ? __('errors.error_during_encryption') : __('errors.error_during_decryption'));
  171. }
  172. }
  173. }
  174. /**
  175. * Encrypt/Decrypt accounts in database
  176. *
  177. * @param bool $encrypted Whether the record should be encrypted or not
  178. * @return bool Whether the operation completed successfully
  179. */
  180. private function updateRecords(bool $encrypted) : bool
  181. {
  182. $success = true;
  183. $twofaccounts = DB::table('twofaccounts')->get();
  184. $twofaccounts->each(function ($item, $key) use (&$success, $encrypted) {
  185. try {
  186. $item->legacy_uri = $encrypted ? Crypt::encryptString($item->legacy_uri) : Crypt::decryptString($item->legacy_uri);
  187. $item->account = $encrypted ? Crypt::encryptString($item->account) : Crypt::decryptString($item->account);
  188. $item->secret = $encrypted ? Crypt::encryptString($item->secret) : Crypt::decryptString($item->secret);
  189. } catch (Exception $ex) {
  190. $success = false;
  191. // Exit the each iteration
  192. return false;
  193. }
  194. });
  195. if ($success) {
  196. // The whole collection has now its sensible data encrypted/decrypted
  197. // We update the db using a transaction that can rollback everything if an error occured
  198. DB::beginTransaction();
  199. try {
  200. $twofaccounts->each(function ($item, $key) {
  201. DB::table('twofaccounts')
  202. ->where('id', $item->id)
  203. ->update([
  204. 'legacy_uri' => $item->legacy_uri,
  205. 'account' => $item->account,
  206. 'secret' => $item->secret,
  207. ]);
  208. });
  209. DB::commit();
  210. return true;
  211. }
  212. // @codeCoverageIgnoreStart
  213. catch (Throwable $ex) {
  214. DB::rollBack();
  215. return false;
  216. }
  217. // @codeCoverageIgnoreEnd
  218. } else {
  219. return false;
  220. }
  221. }
  222. }