1: <?php
2: /*
3: * SimpleID
4: *
5: * Copyright (C) Kelvin Mo 2014-2026
6: *
7: * This program is free software; you can redistribute it and/or
8: * modify it under the terms of the GNU General Public
9: * License as published by the Free Software Foundation; either
10: * version 2 of the License, or (at your option) any later version.
11: *
12: * This program is distributed in the hope that it will be useful,
13: * but WITHOUT ANY WARRANTY; without even the implied warranty of
14: * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15: * General Public License for more details.
16: *
17: * You should have received a copy of the GNU General Public
18: * License along with this program; if not, write to the Free
19: * Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
20: *
21: */
22:
23: namespace SimpleID\Store;
24:
25: use Symfony\Component\Yaml\Yaml;
26: use SimpleID\Models\User;
27: use SimpleID\Models\Client;
28: use SimpleID\Util\ArrayWrapper;
29:
30: /**
31: * A data store module that uses the file system for all of
32: * its storage requirements.
33: */
34: class DefaultStoreModule extends StoreModule {
35: /** @var array<string, mixed> */
36: protected $config;
37:
38: public function __construct() {
39: parent::__construct();
40: $this->config = $this->f3->get('config');
41:
42: $this->checkConfig();
43: }
44:
45: /** @return void */
46: protected function checkConfig() {
47: if (!is_dir($this->config['identities_dir'])) {
48: $this->logger->log(\Psr\Log\LogLevel::CRITICAL, 'Identities directory not found.');
49: $this->f3->error(500, $this->f3->get('intl.store.identity_not_found', 'https://simpleid.org/docs/2/installing/'));
50: }
51:
52: if (!is_dir($this->config['store_dir']) || !is_writeable($this->config['store_dir'])) {
53: $this->logger->log(\Psr\Log\LogLevel::CRITICAL, 'Store directory not found or not writeable.');
54: $this->f3->error(500, $this->f3->get('intl.store.store_not_found', 'https://simpleid.org/docs/2/installing/'));
55: }
56: }
57:
58: public function getStores() {
59: return [ 'user:default', 'client:default', 'keyvalue:default' ];
60: }
61:
62: public function find($type, $criteria, $value) {
63: switch ($type) {
64: case 'client':
65: // Not implemented
66: return null;
67: case 'user':
68: return $this->findUser($criteria, $value);
69: default:
70: return $this->findKeyValue($type, $criteria, $value);
71: }
72: }
73:
74: public function exists($type, $id) {
75: switch ($type) {
76: case 'client':
77: return $this->hasClient($id);
78: case 'user':
79: return $this->hasUser($id);
80: default:
81: return $this->hasKeyValue($type, $id);
82: }
83: }
84:
85: public function read($type, $id) {
86: switch ($type) {
87: case 'client':
88: return $this->readClient($id);
89: case 'user':
90: return $this->readUser($id);
91: default:
92: return $this->readKeyValue($type, $id);
93: }
94: }
95:
96: public function write($type, $id, $value) {
97: switch ($type) {
98: case 'client':
99: /** @var Client $value */
100: $this->writeClient($id, $value);
101: break;
102: case 'user':
103: // user settings are written using the keyvalue:write store
104: break;
105: default:
106: $this->writeKeyValue($type, $id, $value);
107: }
108:
109: }
110:
111: public function delete($type, $id) {
112: switch ($type) {
113: default:
114: $this->deleteKeyValue($type, $id);
115: }
116: }
117:
118: /**
119: * Finds a user
120: *
121: * @param string $criteria the criteria name
122: * @param string $value the criteria value
123: * @return string|null the item or null if no item is found
124: */
125: protected function findUser($criteria, $value) {
126: $cache = \Cache::instance();
127: $index = $cache->get('users_' . rawurldecode($criteria) . '.storeindex');
128: if ($index === false) $index = [];
129: if (isset($index[$value])) return $index[$value];
130:
131: $result = NULL;
132:
133: $dir = opendir($this->config['identities_dir']);
134: if ($dir == false) return null;
135:
136: while (($file = readdir($dir)) !== false) {
137: $filename = $this->config['identities_dir'] . '/' . $file;
138:
139: if (is_link($filename) && readlink($filename)) $filename = readlink($filename);
140: if (($filename === false) || (filetype($filename) != "file") || (!preg_match('/^(.+)\.user\.yml$/', $file, $matches))) continue;
141:
142: $uid = $matches[1];
143: $test_user = $this->readUser($uid);
144:
145: $test_value = $test_user->get($criteria);
146:
147: if ($test_value !== null) {
148: if ($this->equalsOrContainsValue($value, $test_value, $uid, $index))
149: $result = $uid;
150: }
151: }
152:
153: closedir($dir);
154:
155: $cache->set('users_' . rawurldecode($criteria) . '.storeindex', $index);
156:
157: return $result;
158: }
159:
160: /**
161: * Returns whether the user name exists in the user store.
162: *
163: * @param string $uid the name of the user to check
164: * @return bool whether the user name exists
165: */
166: protected function hasUser($uid) {
167: if ($this->isValidName($uid)) {
168: $identity_file = $this->config['identities_dir'] . "/$uid.user.yml";
169: return (file_exists($identity_file));
170: } else {
171: return false;
172: }
173: }
174:
175: /**
176: * Loads user data for a specified user name.
177: *
178: * The user name must exist. You should check whether the user name exists with
179: * the {@link store_user_exists()} function
180: *
181: * @param string $uid the name of the user to load
182: * @return User|null data for the specified user
183: */
184: protected function readUser($uid) {
185: if (!$this->isValidName($uid) || !$this->hasUser($uid)) return null;
186:
187: $identity_file = $this->config['identities_dir'] . "/$uid.user.yml";
188:
189: try {
190: $data = Yaml::parseFile($identity_file);
191: } catch (\Exception $e) {
192: $this->logger->log(\Psr\Log\LogLevel::ERROR, 'Cannot read user file ' . $identity_file . ': ' . $e->getMessage());
193: trigger_error('Cannot read user file ' . $identity_file . ': ' . $e->getMessage(), E_USER_ERROR);
194: }
195:
196: return new User($data);
197: }
198:
199:
200: /**
201: * Returns whether the client name exists in the client store.
202: *
203: * @param string $cid the name of the client to check
204: * @return bool whether the client name exists
205: */
206: protected function hasClient($cid) {
207: if ($this->isValidName($cid)) {
208: $client_file = $this->config['identities_dir'] . "/$cid.client.yml";
209: if (file_exists($client_file)) return true;
210:
211: $store_file = $this->config['store_dir'] . "/$cid.client";
212: return (file_exists($store_file));
213: } else {
214: return false;
215: }
216: }
217:
218: /**
219: * Loads client data for a specified client name.
220: *
221: * The client name must exist. You should check whether the client name exists with
222: * the {@link hasClient()} function
223: *
224: * @param string $cid the name of the client to load
225: * @return Client|null data for the specified user
226: */
227: protected function readClient($cid) {
228: if (!$this->isValidName($cid) || !$this->hasClient($cid)) return null;
229:
230: $store_file = $this->config['store_dir'] . "/$cid.client";
231:
232: if (file_exists($store_file)) {
233: $client = $this->f3->mutex($store_file, function($f3, $store_file) {
234: return $f3->unserialize(file_get_contents($store_file));
235: }, [ $this->f3, $store_file ]);
236: } else {
237: $client = new Client();
238: }
239:
240: $client_file = $this->config['identities_dir'] . "/$cid.client.yml";
241: if (file_exists($client_file)) {
242: try {
243: $data = Yaml::parseFile($client_file);
244: } catch (\Exception $e) {
245: $this->logger->log(\Psr\Log\LogLevel::ERROR, 'Cannot read client file ' . $client_file . ' :' . $e->getMessage());
246: trigger_error('Cannot read client file ' . $client_file . ' :' . $e->getMessage(), E_USER_ERROR);
247: }
248:
249: if ($data != null) $client->loadData($data);
250: }
251:
252: $client->cid = $cid;
253:
254: return $client;
255: }
256:
257: /**
258: * Saves client data for a specific client name.
259: *
260: * This data is stored in the client store file.
261: *
262: * @param string $cid the name of the client
263: * @param Client $client the data to save
264: * @return void
265: */
266: protected function writeClient($cid, $client) {
267: if (!$this->isValidName($cid)) {
268: trigger_error("Invalid client name for filesystem store", E_USER_ERROR);
269: }
270:
271: $store_file = $this->config['store_dir'] . "/$cid.client";
272: $this->f3->mutex($store_file, function($f3, $store_file, $client) {
273: $file = fopen($store_file, 'w');
274: if ($file == false) return;
275: fwrite($file, $f3->serialize($client));
276: fclose($file);
277: }, [ $this->f3, $store_file, $client ]);
278: }
279:
280: /**
281: * Finds a key-value item based on its value
282: *
283: * @param string $type the item type
284: * @param string $criteria the criteria name
285: * @param string $value the criteria value
286: * @return string|null the item ID or null if no item is found
287: */
288: protected function findKeyValue($type, $criteria, $value) {
289: $cache = \Cache::instance();
290: $index = $cache->get($type . '_' . rawurldecode($criteria) . '.storeindex');
291: if ($index === false) $index = [];
292: //if (isset($index[$value])) return $index[$value];
293:
294: $result = NULL;
295:
296: $dir = opendir($this->config['store_dir']);
297: if ($dir == false) return null;
298:
299: while (($file = readdir($dir)) !== false) {
300: $filename = $this->config['store_dir'] . '/' . $file;
301:
302: if (is_link($filename) && readlink($filename)) $filename = readlink($filename);
303: if (($filename === false) || (filetype($filename) != "file") || (!preg_match('/^(.+)\.' . preg_quote($type, '/') . '$/', $file, $matches))) continue;
304:
305: $name = $matches[1];
306: $test = $this->readKeyValue($type, $name);
307:
308: if ($test instanceof ArrayWrapper) {
309: $test_value = $test->get($criteria);
310: } elseif (is_array($test)) {
311: $test_value = (new ArrayWrapper())->get($criteria);
312: } else {
313: $test_value = $test;
314: }
315:
316: if ($test_value !== null) {
317: if ($this->equalsOrContainsValue($value, $test_value, $name, $index))
318: $result = $name;
319: }
320: }
321:
322: closedir($dir);
323:
324: $cache->set($type . '_' . rawurldecode($criteria) . '.storeindex', $index);
325:
326: return $result;
327: }
328:
329: /**
330: * Returns whether a key-value item exists.
331: *
332: * @param string $type the item type
333: * @param string $name the name of the key-value item to return
334: * @return bool the value of the key-value item
335: *
336: */
337: protected function hasKeyValue($type, $name) {
338: $file = $this->getKeyValueFile($type, $name);
339: return file_exists($file);
340: }
341:
342:
343: /**
344: * Loads a key-value item.
345: *
346: * @param string $type the item type
347: * @param string $name the name of the key-value item to return
348: * @return mixed the value of the key-value item
349: *
350: */
351: protected function readKeyValue($type, $name) {
352: if (!$this->isValidName($name) || !$this->hasKeyValue($type, $name)) return null;
353: $file = $this->getKeyValueFile($type, $name);
354: return $this->f3->mutex($file, function($f3, $file) {
355: return $f3->unserialize(file_get_contents($file));
356: }, [ $this->f3, $file ]);
357:
358: }
359:
360: /**
361: * Saves a key-value item.
362: *
363: * @param string $type the item type
364: * @param string $name the name of the key-value item to save
365: * @param mixed $value the value of the key-value item
366: * @return void
367: */
368: protected function writeKeyValue($type, $name, $value) {
369: if (!$this->isValidName($name . '.' . $type)) {
370: trigger_error("Invalid name for filesystem store", E_USER_ERROR);
371: }
372:
373: $file = $this->getKeyValueFile($type, $name);
374: $this->f3->mutex($file, function($f3, $file, $value) {
375: file_put_contents($file, $f3->serialize($value), LOCK_EX);
376: }, [ $this->f3, $file, $value ]);
377: }
378:
379: /**
380: * Deletes a key-value item.
381: *
382: * @param string $type the item type
383: * @param string $name the name of the setting to delete
384: * @return void
385: */
386: protected function deleteKeyValue($type, $name) {
387: if (!$this->isValidName($name . '.' . $type)) {
388: trigger_error("Invalid name for filesystem store", E_USER_ERROR);
389: }
390:
391: $file = $this->getKeyValueFile($type, $name);
392: $this->f3->mutex($file, function() use ($file) { unlink($file); });
393: }
394:
395: /**
396: * Determines whether a name is a valid name for use with this store.
397: *
398: * For file system storage, a name is not valid if it contains either a
399: * directory separator (i.e. / or \).
400: *
401: * @param string $name the name to check
402: * @return boolean whether the name is valid for use with this store
403: */
404: protected function isValidName($name) {
405: return (preg_match('!\A[^/\\\\]+\z!', $name) != false);
406: }
407:
408: /**
409: * @param string $type
410: * @param string $name
411: * @return string
412: */
413: private function getKeyValueFile($type, $name) {
414: $name = str_replace('%7E', '~', rawurlencode($name));
415: if (preg_match('/^\.+$/', $name)) $name = str_replace('.', '%2E', $name);
416: return $this->config['store_dir'] . '/' . $name . '.' . $type;
417: }
418: }
419:
420: ?>
421: