1: <?php
2: /*
3: * SimpleID
4: *
5: * Copyright (C) Kelvin Mo 2014-2025
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:
29: /**
30: * A data store module that uses the file system for all of
31: * its storage requirements.
32: */
33: class DefaultStoreModule extends StoreModule {
34: /** @var array<string, mixed> */
35: protected $config;
36:
37: public function __construct() {
38: parent::__construct();
39: $this->config = $this->f3->get('config');
40:
41: $this->checkConfig();
42: }
43:
44: /** @return void */
45: protected function checkConfig() {
46: if (!is_dir($this->config['identities_dir'])) {
47: $this->logger->log(\Psr\Log\LogLevel::CRITICAL, 'Identities directory not found.');
48: $this->f3->error(500, $this->f3->get('intl.store.identity_not_found', 'http://simpleid.org/docs/2/installing/'));
49: }
50:
51: if (!is_dir($this->config['store_dir']) || !is_writeable($this->config['store_dir'])) {
52: $this->logger->log(\Psr\Log\LogLevel::CRITICAL, 'Store directory not found or not writeable.');
53: $this->f3->error(500, $this->f3->get('intl.store.store_not_found', 'http://simpleid.org/docs/2/installing/'));
54: }
55: }
56:
57: public function getStores() {
58: return [ 'user:default', 'client:default', 'keyvalue:default' ];
59: }
60:
61: public function find($type, $criteria, $value) {
62: switch ($type) {
63: case 'user':
64: return $this->findUser($criteria, $value);
65: }
66: return null;
67: }
68:
69: public function exists($type, $id) {
70: switch ($type) {
71: case 'client':
72: return $this->hasClient($id);
73: case 'user':
74: return $this->hasUser($id);
75: default:
76: return $this->hasKeyValue($type, $id);
77: }
78: }
79:
80: public function read($type, $id) {
81: switch ($type) {
82: case 'client':
83: return $this->readClient($id);
84: case 'user':
85: return $this->readUser($id);
86: default:
87: return $this->readKeyValue($type, $id);
88: }
89: }
90:
91: public function write($type, $id, $value) {
92: switch ($type) {
93: case 'client':
94: /** @var Client $value */
95: $this->writeClient($id, $value);
96: break;
97: case 'user':
98: // user settings are written using the keyvalue:write store
99: break;
100: default:
101: $this->writeKeyValue($type, $id, $value);
102: }
103:
104: }
105:
106: public function delete($type, $id) {
107: switch ($type) {
108: default:
109: $this->deleteKeyValue($type, $id);
110: }
111: }
112:
113: /**
114: * Finds a user
115: *
116: * @param string $criteria the criteria name
117: * @param string $value the criteria value
118: * @return string|null the item or null if no item is found
119: */
120: protected function findUser($criteria, $value) {
121: $cache = \Cache::instance();
122: $index = $cache->get('users_' . rawurldecode($criteria) . '.storeindex');
123: if ($index === false) $index = [];
124: if (isset($index[$value])) return $index[$value];
125:
126: $result = NULL;
127:
128: $dir = opendir($this->config['identities_dir']);
129: if ($dir == false) return null;
130:
131: while (($file = readdir($dir)) !== false) {
132: $filename = $this->config['identities_dir'] . '/' . $file;
133:
134: if (is_link($filename) && readlink($filename)) $filename = readlink($filename);
135: if (($filename === false) || (filetype($filename) != "file") || (!preg_match('/^(.+)\.user\.yml$/', $file, $matches))) continue;
136:
137: $uid = $matches[1];
138: $test_user = $this->readUser($uid);
139:
140: $test_value = $test_user->get($criteria);
141:
142: if ($test_value !== null) {
143: if (is_array($test_value)) {
144: foreach ($test_value as $test_element) {
145: if (trim($test_element) != '') $index[$test_element] = $uid;
146: if ($test_element == $value) $result = $uid;
147: }
148: } else {
149: if (trim($test_value) != '') {
150: $index[$test_value] = $uid;
151: if ($test_value == $value) $result = $uid;
152: }
153: }
154: }
155: }
156:
157: closedir($dir);
158:
159: $cache->set('users_' . rawurldecode($criteria) . '.storeindex', $index);
160:
161: return $result;
162: }
163:
164: /**
165: * Returns whether the user name exists in the user store.
166: *
167: * @param string $uid the name of the user to check
168: * @return bool whether the user name exists
169: */
170: protected function hasUser($uid) {
171: if ($this->isValidName($uid)) {
172: $identity_file = $this->config['identities_dir'] . "/$uid.user.yml";
173: return (file_exists($identity_file));
174: } else {
175: return false;
176: }
177: }
178:
179: /**
180: * Loads user data for a specified user name.
181: *
182: * The user name must exist. You should check whether the user name exists with
183: * the {@link store_user_exists()} function
184: *
185: * @param string $uid the name of the user to load
186: * @return User|null data for the specified user
187: */
188: protected function readUser($uid) {
189: if (!$this->isValidName($uid) || !$this->hasUser($uid)) return null;
190:
191: $identity_file = $this->config['identities_dir'] . "/$uid.user.yml";
192:
193: try {
194: $data = Yaml::parseFile($identity_file);
195: } catch (\Exception $e) {
196: $this->logger->log(\Psr\Log\LogLevel::ERROR, 'Cannot read user file ' . $identity_file . ': ' . $e->getMessage());
197: trigger_error('Cannot read user file ' . $identity_file . ': ' . $e->getMessage(), E_USER_ERROR);
198: }
199:
200: return new User($data);
201: }
202:
203:
204: /**
205: * Returns whether the client name exists in the client store.
206: *
207: * @param string $cid the name of the client to check
208: * @return bool whether the client name exists
209: */
210: protected function hasClient($cid) {
211: if ($this->isValidName($cid)) {
212: $client_file = $this->config['identities_dir'] . "/$cid.client.yml";
213: if (file_exists($client_file)) return true;
214:
215: $store_file = $this->config['store_dir'] . "/$cid.client";
216: return (file_exists($store_file));
217: } else {
218: return false;
219: }
220: }
221:
222: /**
223: * Loads client data for a specified client name.
224: *
225: * The client name must exist. You should check whether the client name exists with
226: * the {@link hasClient()} function
227: *
228: * @param string $cid the name of the client to load
229: * @return Client|null data for the specified user
230: */
231: protected function readClient($cid) {
232: if (!$this->isValidName($cid) || !$this->hasClient($cid)) return null;
233:
234: $store_file = $this->config['store_dir'] . "/$cid.client";
235:
236: if (file_exists($store_file)) {
237: $client = $this->f3->mutex($store_file, function($f3, $store_file) {
238: return $f3->unserialize(file_get_contents($store_file));
239: }, [ $this->f3, $store_file ]);
240: } else {
241: $client = new Client();
242: }
243:
244: $client_file = $this->config['identities_dir'] . "/$cid.client.yml";
245: if (file_exists($client_file)) {
246: try {
247: $data = Yaml::parseFile($client_file);
248: } catch (\Exception $e) {
249: $this->logger->log(\Psr\Log\LogLevel::ERROR, 'Cannot read client file ' . $client_file . ' :' . $e->getMessage());
250: trigger_error('Cannot read client file ' . $client_file . ' :' . $e->getMessage(), E_USER_ERROR);
251: }
252:
253: if ($data != null) $client->loadData($data);
254: }
255:
256: $client->cid = $cid;
257:
258: return $client;
259: }
260:
261: /**
262: * Saves client data for a specific client name.
263: *
264: * This data is stored in the client store file.
265: *
266: * @param string $cid the name of the client
267: * @param Client $client the data to save
268: * @return void
269: */
270: protected function writeClient($cid, $client) {
271: if (!$this->isValidName($cid)) {
272: trigger_error("Invalid client name for filesystem store", E_USER_ERROR);
273: }
274:
275: $store_file = $this->config['store_dir'] . "/$cid.client";
276: $this->f3->mutex($store_file, function($f3, $store_file, $client) {
277: $file = fopen($store_file, 'w');
278: if ($file == false) return;
279: fwrite($file, $f3->serialize($client));
280: fclose($file);
281: }, [ $this->f3, $store_file, $client ]);
282: }
283:
284: /**
285: * Returns whether a key-value item exists.
286: *
287: * @param string $type the item type
288: * @param string $name the name of the key-value item to return
289: * @return bool the value of the key-value item
290: *
291: */
292: protected function hasKeyValue($type, $name) {
293: $file = $this->getKeyValueFile($type, $name);
294: return file_exists($file);
295: }
296:
297:
298: /**
299: * Loads a key-value item.
300: *
301: * @param string $type the item type
302: * @param string $name the name of the key-value item to return
303: * @return mixed the value of the key-value item
304: *
305: */
306: protected function readKeyValue($type, $name) {
307: if (!$this->isValidName($name) || !$this->hasKeyValue($type, $name)) return null;
308: $file = $this->getKeyValueFile($type, $name);
309: return $this->f3->mutex($file, function($f3, $file) {
310: return $f3->unserialize(file_get_contents($file));
311: }, [ $this->f3, $file ]);
312:
313: }
314:
315: /**
316: * Saves a key-value item.
317: *
318: * @param string $type the item type
319: * @param string $name the name of the key-value item to save
320: * @param mixed $value the value of the key-value item
321: * @return void
322: */
323: protected function writeKeyValue($type, $name, $value) {
324: if (!$this->isValidName($name . '.' . $type)) {
325: trigger_error("Invalid name for filesystem store", E_USER_ERROR);
326: }
327:
328: $file = $this->getKeyValueFile($type, $name);
329: $this->f3->mutex($file, function($f3, $file, $value) {
330: file_put_contents($file, $f3->serialize($value), LOCK_EX);
331: }, [ $this->f3, $file, $value ]);
332: }
333:
334: /**
335: * Deletes a key-value item.
336: *
337: * @param string $type the item type
338: * @param string $name the name of the setting to delete
339: * @return void
340: */
341: protected function deleteKeyValue($type, $name) {
342: if (!$this->isValidName($name . '.' . $type)) {
343: trigger_error("Invalid name for filesystem store", E_USER_ERROR);
344: }
345:
346: $file = $this->getKeyValueFile($type, $name);
347: $this->f3->mutex($file, function() use ($file) { unlink($file); });
348: }
349:
350: /**
351: * Determines whether a name is a valid name for use with this store.
352: *
353: * For file system storage, a name is not valid if it contains either a
354: * directory separator (i.e. / or \).
355: *
356: * @param string $name the name to check
357: * @return boolean whether the name is valid for use with this store
358: */
359: protected function isValidName($name) {
360: return (preg_match('!\A[^/\\\\]*\z!', $name) != false);
361: }
362:
363: /**
364: * @param string $type
365: * @param string $name
366: * @return string
367: */
368: private function getKeyValueFile($type, $name) {
369: $name = str_replace('%7E', '~', rawurlencode($name));
370: if (preg_match('/^\.+$/', $name)) $name = str_replace('.', '%2E', $name);
371: return $this->config['store_dir'] . '/' . $name . '.' . $type;
372: }
373: }
374:
375: ?>
376: