| @@ -1,8 +1,11 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | |
| 3 | 3 | namespace FluentBoards\App\Services; |
| 4 | 4 | |
| 5 | +use FluentBoards\App\Models\Board; | |
| 6 | +use FluentBoards\App\Models\User; | |
| 7 | + | |
| 5 | 8 | class PublicAccessService |
| 6 | 9 | { |
| 7 | 10 | const TOKEN_DELIMITER = '|'; |
| 8 | 11 | |
| @@ -41,20 +44,37 @@ | ||
| 41 | 44 | |
| 42 | 45 | return hash_equals(self::signature($boardId), $tokenSignature); |
| 43 | 46 | } |
| 44 | 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 | + */ | |
| 45 | 60 | public static function sanitizeUsers($users) |
| 46 | 61 | { |
| 47 | 62 | $sanitizedUsers = []; |
| 48 | 63 | |
| 49 | - foreach ((array)$users as $user) { | |
| 64 | + foreach (self::toIterable($users) as $user) { | |
| 65 | + if (is_array($user)) { | |
| 66 | + $user = (object)$user; | |
| 67 | + } | |
| 68 | + | |
| 50 | 69 | if (!is_object($user) || empty($user->ID)) { |
| 51 | 70 | continue; |
| 52 | 71 | } |
| 53 | 72 | |
| 54 | 73 | $role = 'Member'; |
| 55 | - if (isset($user->pivot) && isset($user->pivot->settings)) { | |
| 56 | - $settings = maybe_unserialize($user->pivot->settings); | |
| 74 | + $pivot = isset($user->pivot) ? $user->pivot : null; | |
| 75 | + if ($pivot && isset($pivot->settings)) { | |
| 76 | + $settings = maybe_unserialize($pivot->settings); | |
| 57 | 77 | if (is_array($settings)) { |
| 58 | 78 | if (!empty($settings['is_admin'])) { |
| 59 | 79 | $role = 'Admin'; |
| 60 | 80 | } elseif (!empty($settings['is_viewer_only'])) { |
| @@ -62,21 +82,93 @@ | ||
| 62 | 82 | } |
| 63 | 83 | } |
| 64 | 84 | } |
| 65 | 85 | |
| 66 | - $displayName = isset($user->display_name) ? $user->display_name : ''; | |
| 86 | + $displayName = isset($user->display_name) ? (string)$user->display_name : ''; | |
| 87 | + $email = isset($user->user_email) ? (string)$user->user_email : ''; | |
| 67 | 88 | |
| 68 | - $sanitizedUsers[] = [ | |
| 89 | + $record = [ | |
| 69 | 90 | 'ID' => (int)$user->ID, |
| 70 | 91 | 'display_name' => $displayName, |
| 71 | - 'photo' => fluent_boards_user_avatar($user->user_email ?? '', $displayName), | |
| 92 | + 'photo' => fluent_boards_user_avatar($email, $displayName), | |
| 72 | 93 | 'role' => $role |
| 73 | 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)); | |
| 74 | 98 | } |
| 75 | 99 | |
| 76 | 100 | return $sanitizedUsers; |
| 77 | 101 | } |
| 78 | 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 | + | |
| 79 | 171 | private static function signature($boardId) |
| 80 | 172 | { |
| 81 | 173 | $secret = self::getSecretForBoard($boardId); |
| 82 | 174 | |
| @@ -84,9 +176,9 @@ | ||
| 84 | 176 | } |
| 85 | 177 | |
| 86 | 178 | private static function getSecretForBoard($boardId) |
| 87 | 179 | { |
| 88 | - $board = \FluentBoards\App\Models\Board::find($boardId); | |
| 180 | + $board = Board::find($boardId); | |
| 89 | 181 | $perBoardSalt = $board ? $board->getMetaByKey('public_token_salt') : ''; |
| 90 | 182 | |
| 91 | 183 | return wp_salt('auth') . $perBoardSalt; |
| 92 | 184 | } |
| @@ -92,9 +184,9 @@ | ||
| 92 | 184 | } |
| 93 | 185 | |
| 94 | 186 | public static function revokeAccessToken($boardId) |
| 95 | 187 | { |
| 96 | - $board = \FluentBoards\App\Models\Board::find($boardId); | |
| 188 | + $board = Board::find($boardId); | |
| 97 | 189 | if ($board) { |
| 98 | 190 | $board->updateMeta('public_token_salt', wp_generate_password(32, true, true)); |
| 99 | 191 | } |
| 100 | 192 | } |