PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.10.01
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.10.01
2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 1.1.0 All 77 releases
fluent-community / app / Services / SmartCodeParser.php

SmartCodeParser.php in FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses 2.10.01, at app/Services/SmartCodeParser.php

408 lines 13.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCommunity\App\Services;
4
5 use FluentCommunity\Modules\Course\Model\CourseTopic;
6 use FluentCommunity\Modules\Course\Model\Course;
7 use FluentCommunity\App\Services\ProfileHelper;
8 use FluentCommunity\App\Services\Helper;
9 use FluentCommunity\Framework\Support\Arr;
10
11 class SmartCodeParser
12 {
13 protected static $isHtml = true;
14
15 protected static $store = [
16 'user' => null,
17 'feed' => null,
18 'course' => null,
19 'community' => null
20 ];
21
22 public function parse($templateString, $user, $feed = null, $isHtml = true)
23 {
24 static::$isHtml = $isHtml;
25 static::setData($user, $feed);
26
27 $result = [];
28 $isSingle = false;
29
30 if (!is_array($templateString)) {
31 $isSingle = true;
32 }
33
34 foreach ((array)$templateString as $key => $string) {
35 $result[$key] = $this->parseShortcode($string);
36 }
37
38 if ($isSingle) {
39 return reset($result);
40 }
41
42 return $result;
43 }
44
45 protected static function setData($user, $feed = null)
46 {
47 static::$store['user'] = $user;
48 static::$store['feed'] = $feed;
49 static::$store['course'] = ($feed && $feed->course) ? $feed->course : null;
50 static::$store['community'] = Helper::generalSettings();
51 }
52
53 public function parseShortcode($string)
54 {
55 if (strpos($string, '{{') === false && strpos($string, '##') === false) {
56 return $string;
57 }
58
59 if (static::$isHtml) {
60 $string = $this->resolveHrefPlaceholders($string);
61 }
62
63 return preg_replace_callback('/({{|##)+(.*?)(}}|##)/', function ($matches) {
64 return $this->replace($matches);
65 }, $string);
66 }
67
68 /**
69 * Resolve placeholders inside href="..." attributes to raw URLs.
70 * Prevents nested anchors when URL smartcodes auto-wrap in HTML mode.
71 */
72 protected function resolveHrefPlaceholders($string)
73 {
74 return preg_replace_callback(
75 '/(?<![a-zA-Z-])href\s*=\s*(["\'])([^"\']*(?:\{\{|##)[^"\']*)\1/i',
76 function ($m) {
77 static::$isHtml = false;
78 $resolved = $this->parseShortcode($m[2]);
79 static::$isHtml = true;
80 return 'href=' . $m[1] . esc_url($resolved) . $m[1];
81 },
82 $string
83 );
84 }
85
86 protected function replace($matches)
87 {
88 if (empty($matches[2])) {
89 return apply_filters('fluent_community/smartcode_fallback', $matches[0], $this->store['user']);
90 }
91
92 $matches[2] = trim($matches[2]);
93
94 $matched = explode('.', $matches[2]);
95
96 if (count($matched) <= 1) {
97 return apply_filters('fluent_community/smartcode_fallback', $matches[0], $this->store['user']);
98 }
99
100 $dataKey = trim(array_shift($matched));
101
102 $valueKey = trim(implode('.', $matched));
103
104 if (!$valueKey) {
105 return apply_filters('fluent_community/smartcode_fallback', $matches[0], $this->store['user']);
106 }
107
108 $valueKeys = explode('|', $valueKey);
109
110 $valueKey = $valueKeys[0];
111 $defaultValue = '';
112 $transformer = '';
113
114 $valueCounts = count($valueKeys);
115
116 if ($valueCounts >= 3) {
117 $defaultValue = trim($valueKeys[1]);
118 $transformer = trim($valueKeys[2]);
119 } else if ($valueCounts === 2) {
120 $defaultValue = trim($valueKeys[1]);
121 }
122
123 $value = '';
124 switch ($dataKey) {
125 case 'site':
126 $value = $this->getWpValue($valueKey, $defaultValue);
127 break;
128 case 'user':
129 $value = static::$store['user'] ? $this->getUserValue($valueKey, $defaultValue) : $defaultValue;
130 break;
131 case 'community':
132 $value = $this->getCommunityValue($valueKey, $defaultValue);
133 break;
134 case 'section':
135 $value = $this->getSectionValue($valueKey, $defaultValue);
136 break;
137 case 'course':
138 $value = $this->getCourseValue($valueKey, $defaultValue);
139 break;
140 default:
141 $value = apply_filters('fluent_community/smartcode_group_callback_' . $dataKey, $matches[0], $valueKey, $defaultValue, static::$store['user']);
142 }
143
144 if ($transformer && is_string($transformer) && $value) {
145 switch ($transformer) {
146 case 'trim':
147 $value = trim($value);
148 break;
149 case 'ucfirst':
150 $value = ucfirst($value);
151 break;
152 case 'strtolower':
153 $value = strtolower($value);
154 break;
155 case 'strtoupper':
156 $value = strtoupper($value);
157 break;
158 case 'ucwords':
159 $value = ucwords($value);
160 break;
161 case 'concat_first': // usage: {{contact.first_name||concat_first|Hi
162 if (isset($valueKeys[3])) {
163 $value = trim($valueKeys[3] . ' ' . $value);
164 }
165 break;
166 case 'concat_last': // usage: {{contact.first_name||concat_last|, => FIRST_NAME,
167 if (isset($valueKeys[3])) {
168 $value = trim($value . '' . $valueKeys[3]);
169 }
170 break;
171 case 'show_if': // usage {{contact.first_name||show_if|First name exist
172 if (isset($valueKeys[3])) {
173 $value = $valueKeys[3];
174 }
175 break;
176 }
177 }
178
179 return $this->escapeValueForContext($value, $dataKey, $valueKey);
180 }
181
182 /**
183 * Smartcode values are substituted into lockscreen/lesson/email HTML *after*
184 * that content has passed through wp_kses / do_blocks, so a resolved scalar
185 * carrying markup would otherwise bypass sanitisation. Escape ordinary
186 * values for the HTML context. The few branches that intentionally build a
187 * trusted fragment (photo_html, name_with_url, section url) already escape
188 * their own interpolated parts, so they are left untouched, and values from
189 * third-party group callbacks are the extension's responsibility.
190 */
191 protected function escapeValueForContext($value, $dataKey, $valueKey)
192 {
193 if (!static::$isHtml || !is_string($value) || $value === '') {
194 return $value;
195 }
196
197 $knownGroups = ['site', 'user', 'community', 'section', 'course'];
198 if (!in_array($dataKey, $knownGroups, true)) {
199 return $value;
200 }
201
202 $trustedHtml = [
203 'user' => ['photo_html'],
204 'community' => ['name_with_url'],
205 'section' => ['url'],
206 ];
207
208 $baseKey = strtok($valueKey, '.'); // "photo_html.50px" -> "photo_html"
209 if (in_array($baseKey, Arr::get($trustedHtml, $dataKey, []), true)) {
210 return $value;
211 }
212
213 return esc_html($value);
214 }
215
216 protected function getWpValue($valueKey, $defaultValue)
217 {
218 if ($valueKey == 'login_url') {
219 return network_site_url('wp-login.php', 'login');
220 }
221
222 if ($valueKey == 'name') {
223 return wp_specialchars_decode(get_option('blogname'), ENT_QUOTES);
224 }
225
226 $value = get_bloginfo($valueKey);
227 if (!$value) {
228 return $defaultValue;
229 }
230 return $value;
231 }
232
233
234 protected function getUserValue($valueKey, $defaultValue)
235 {
236 $userModel = static::$store['user'];
237 if (!$userModel || !$userModel instanceof \FluentCommunity\App\Models\User) {
238 return $defaultValue;
239 }
240
241 $xProfile = $userModel->xprofile;
242
243 if ($valueKey == 'profile_link') {
244 if ($xProfile) {
245 return $xProfile->getPermalink();
246 }
247 return $defaultValue;
248 }
249
250 if ($xProfile) {
251 if ($valueKey == 'display_name') {
252 return $xProfile->display_name;
253 }
254 }
255
256 if ($valueKey == 'photo_html') {
257 if ($xProfile) {
258 return '<img src="' . esc_url($xProfile->avatar) . '" alt="' . esc_attr($xProfile->display_name) . '" class="fcom_user_dynamic_photo" />';
259 }
260 return '<img src="' . esc_url($userModel->photo) . '" alt="' . esc_attr($userModel->display_name) . '" class="fcom_user_dynamic_photo" />';
261 }
262
263 $wpUser = $userModel->getWpUser();
264 $valueKeys = explode('.', $valueKey);
265 if (count($valueKeys) == 1) {
266 // Smartcodes are resolved against the *viewer* and can be authored by
267 // space/course/page admins, so only a fixed set of non-sensitive
268 // profile fields may be read. Never expose user_pass,
269 // user_activation_key, session tokens or capability meta.
270 $allowedFields = apply_filters('fluent_community/smartcode/user_fields', [
271 'ID', 'first_name', 'last_name', 'nickname', 'display_name',
272 'user_email', 'user_login', 'user_nicename', 'user_url',
273 'description', 'user_registered'
274 ]);
275
276 if (!in_array($valueKey, $allowedFields, true)) {
277 return $defaultValue;
278 }
279
280 $value = $wpUser->get($valueKey);
281 if (!$value) {
282 return $defaultValue;
283 }
284
285 if (!is_array($value) || !is_object($value)) {
286 return $value;
287 }
288
289 return $defaultValue;
290 }
291
292 $customKey = $valueKeys[0];
293 $customProperty = $valueKeys[1];
294
295 if ($customKey === 'photo_html') {
296 $width = (string)esc_attr($customProperty);
297 $style = 'style="width: ' . $width . '; height: ' . $width . ';"';
298 if ($xProfile) {
299 return '<img ' . $style . ' src="' . esc_url($xProfile->avatar) . '" alt="' . esc_attr($xProfile->display_name) . '" class="fcom_user_dynamic_photo" />';
300 }
301 return '<img ' . $style . ' src="' . esc_url($userModel->photo) . '" alt="' . esc_attr($userModel->display_name) . '" class="fcom_user_dynamic_photo" />';
302 }
303
304 if ($customKey == 'meta') {
305 // No first-party template reads user meta through smartcodes, and the
306 // viewer's own meta (session_tokens, capability keys, reset keys,
307 // any _-prefixed value) must never leak into author-controlled
308 // content. Resolve only meta keys a site has explicitly allowed.
309 $allowedMetaKeys = apply_filters('fluent_community/smartcode/user_meta_keys', []);
310
311 if (strpos($customProperty, '_') === 0 || !in_array($customProperty, $allowedMetaKeys, true)) {
312 return $defaultValue;
313 }
314
315 $metaValue = get_user_meta($wpUser->ID, $customProperty, true);
316 if (!$metaValue) {
317 return $defaultValue;
318 }
319
320 if (!is_array($metaValue) || !is_object($metaValue)) {
321 return $metaValue;
322 }
323
324 return $defaultValue;
325 }
326
327 return $defaultValue;
328 }
329
330 protected function getCommunityValue($valueKey, $defaultValue)
331 {
332 $communitySettings = static::$store['community'];
333
334 if ($valueKey == 'name') {
335 return Arr::get($communitySettings, 'site_title');
336 }
337
338 if ($valueKey == 'name_with_url') {
339 $siteTitle = Arr::get($communitySettings, 'site_title');
340 return static::$isHtml ? '<a target="_blank" href="' . Helper::baseUrl('/') . '">' . $siteTitle . '</a>' : $siteTitle;
341 }
342
343 if (isset($communitySettings[$valueKey])) {
344 return $communitySettings[$valueKey];
345 }
346
347 return $defaultValue;
348 }
349
350 protected function getSectionValue($valueKey, $defaultValue)
351 {
352 $sectionModel = static::$store['feed'];
353 if (!$sectionModel || !$sectionModel instanceof CourseTopic) {
354 return $defaultValue;
355 }
356
357 if ($valueKey === 'url') {
358 $user = static::$store['user'] ?? null;
359 $course = static::$store['course'] ?? null;
360
361 if (!$course instanceof Course) {
362 return $defaultValue;
363 }
364
365 $courseUrl = $course->getPermalink();
366
367 $userId = $user->ID ?? null;
368 $signedUrl = $userId ? ProfileHelper::signUserUrlWithAuthHash($courseUrl, $userId) : $courseUrl;
369
370 if (static::$isHtml) {
371 return sprintf('<a href="%s">%s</a>', esc_url($signedUrl), esc_html($courseUrl));
372 }
373
374 return $signedUrl;
375 }
376
377 $fillables = array_merge(
378 (new CourseTopic())->getFillable(),
379 ['id', 'created_at', 'updated_at']
380 );
381
382 if (in_array($valueKey, $fillables)) {
383 return $sectionModel->{$valueKey};
384 }
385
386 return $defaultValue;
387 }
388
389 protected function getCourseValue($valueKey, $defaultValue)
390 {
391 $courseModel = static::$store['course'];
392 if (!$courseModel || !$courseModel instanceof Course) {
393 return $defaultValue;
394 }
395
396 $fillables = array_merge(
397 (new Course())->getFillable(),
398 ['id', 'created_at', 'updated_at']
399 );
400
401 if (in_array($valueKey, $fillables)) {
402 return $courseModel->{$valueKey};
403 }
404
405 return $defaultValue;
406 }
407 }
408