PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.1.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.1.0
2.1.0 2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 1.41 All 42 releases
fluent-boards / app / Services / PublicAccessService.php

PublicAccessService.php in FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration 2.1.0, at app/Services/PublicAccessService.php

195 lines 5.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBoards\App\Services;
4
5 use FluentBoards\App\Models\Board;
6 use FluentBoards\App\Models\User;
7
8 class PublicAccessService
9 {
10 const TOKEN_DELIMITER = '|';
11
12 public static function generateAccessToken($boardId)
13 {
14 $boardId = absint($boardId);
15 if (!$boardId) {
16 return '';
17 }
18
19 $signature = self::signature($boardId);
20 $payload = $boardId . self::TOKEN_DELIMITER . $signature;
21
22 return rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
23 }
24
25 public static function validateAccessToken($boardId, $token)
26 {
27 $boardId = absint($boardId);
28 $token = sanitize_text_field($token);
29
30 if (!$boardId || !$token) {
31 return false;
32 }
33
34 $decoded = base64_decode(strtr($token, '-_', '+/'), true);
35 if (!$decoded || strpos($decoded, self::TOKEN_DELIMITER) === false) {
36 return false;
37 }
38
39 [$tokenBoardId, $tokenSignature] = explode(self::TOKEN_DELIMITER, $decoded, 2);
40 $tokenBoardId = absint($tokenBoardId);
41 if (!$tokenBoardId || $tokenBoardId !== $boardId) {
42 return false;
43 }
44
45 return hash_equals(self::signature($boardId), $tokenSignature);
46 }
47
48 /**
49 * Fields a logged-out visitor may see for a board member or task assignee.
50 * Everything else on the WordPress user row (user_login, user_email, ...) is dropped.
51 */
52 const PUBLIC_USER_FIELDS = ['ID', 'display_name', 'photo', 'role'];
53
54 /**
55 * Reduce user records to the public-safe shape defined by PUBLIC_USER_FIELDS.
56 *
57 * Accepts an ORM collection, a plain array, or any iterable of user models/objects/arrays
58 * and always returns a list of plain arrays, so no User model can reach a public response.
59 */
60 public static function sanitizeUsers($users)
61 {
62 $sanitizedUsers = [];
63
64 foreach (self::toIterable($users) as $user) {
65 if (is_array($user)) {
66 $user = (object)$user;
67 }
68
69 if (!is_object($user) || empty($user->ID)) {
70 continue;
71 }
72
73 $role = 'Member';
74 $pivot = isset($user->pivot) ? $user->pivot : null;
75 if ($pivot && isset($pivot->settings)) {
76 $settings = maybe_unserialize($pivot->settings);
77 if (is_array($settings)) {
78 if (!empty($settings['is_admin'])) {
79 $role = 'Admin';
80 } elseif (!empty($settings['is_viewer_only'])) {
81 $role = 'Viewer';
82 }
83 }
84 }
85
86 $displayName = isset($user->display_name) ? (string)$user->display_name : '';
87 $email = isset($user->user_email) ? (string)$user->user_email : '';
88
89 $record = [
90 'ID' => (int)$user->ID,
91 'display_name' => $displayName,
92 'photo' => fluent_boards_user_avatar($email, $displayName),
93 'role' => $role
94 ];
95
96 // Explicit allow-list: anything not in PUBLIC_USER_FIELDS can never reach the response.
97 $sanitizedUsers[] = array_intersect_key($record, array_flip(self::PUBLIC_USER_FIELDS));
98 }
99
100 return $sanitizedUsers;
101 }
102
103 /**
104 * Swap a user relation on a model for its public-safe list before the model is serialized.
105 *
106 * Loaded relations override attributes of the same name during toArray()/JSON encoding,
107 * so assigning the sanitized list as an attribute alone is not enough: the relation must be
108 * unloaded first. Only the sanitized plain array is left on the model.
109 */
110 public static function replaceUserRelation($model, $relation)
111 {
112 if ($model->relationLoaded($relation)) {
113 $users = $model->getRelation($relation);
114 } else {
115 $users = $model->$relation()->get();
116 }
117
118 $model->unsetRelation($relation);
119 $model->setAttribute($relation, self::sanitizeUsers($users));
120
121 return $model;
122 }
123
124 /**
125 * Defense in depth for public responses: drop any still-loaded relation that would
126 * serialize a WordPress user model, whatever name it was loaded under.
127 */
128 public static function stripUserRelations($model)
129 {
130 foreach ($model->getRelations() as $name => $value) {
131 if (self::containsUserModel($value)) {
132 $model->unsetRelation($name);
133 }
134 }
135
136 return $model;
137 }
138
139 private static function containsUserModel($value)
140 {
141 if ($value instanceof User) {
142 return true;
143 }
144
145 foreach (self::toIterable($value) as $item) {
146 if ($item instanceof User) {
147 return true;
148 }
149 }
150
151 return false;
152 }
153
154 private static function toIterable($value)
155 {
156 if (is_array($value)) {
157 return $value;
158 }
159
160 if (is_object($value) && method_exists($value, 'all')) {
161 return (array)$value->all();
162 }
163
164 if ($value instanceof \Traversable) {
165 return iterator_to_array($value, false);
166 }
167
168 return [];
169 }
170
171 private static function signature($boardId)
172 {
173 $secret = self::getSecretForBoard($boardId);
174
175 return hash_hmac('sha256', 'fluent_boards_public_board_' . absint($boardId), $secret);
176 }
177
178 private static function getSecretForBoard($boardId)
179 {
180 $board = Board::find($boardId);
181 $perBoardSalt = $board ? $board->getMetaByKey('public_token_salt') : '';
182
183 return wp_salt('auth') . $perBoardSalt;
184 }
185
186 public static function revokeAccessToken($boardId)
187 {
188 $board = Board::find($boardId);
189 if ($board) {
190 $board->updateMeta('public_token_salt', wp_generate_password(32, true, true));
191 }
192 }
193 }
194
195