Structure.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. <?php
  2. /**
  3. * This file is part of the ForkBB <https://github.com/forkbb>.
  4. *
  5. * @copyright (c) Visman <mio.visman@yandex.ru, https://github.com/MioVisman>
  6. * @license The MIT License (MIT)
  7. */
  8. declare(strict_types=1);
  9. namespace ForkBB\Models\BBCodeList;
  10. use ForkBB\Core\Container;
  11. use ForkBB\Models\Model;
  12. use RuntimeException;
  13. use Throwable;
  14. class Structure extends Model
  15. {
  16. const TAG_PATTERN = '%^(?:ROOT|[a-z\*][a-z\d-]{0,10})$%D';
  17. const ATTR_PATTERN = '%^[a-z-]{2,15}$%D';
  18. const JSON_OPTIONS = \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR;
  19. /**
  20. * Ключ модели для контейнера
  21. * @var string
  22. */
  23. protected $cKey = 'BBStructure';
  24. public function __construct(Container $container)
  25. {
  26. parent::__construct($container);
  27. $this->zDepend = [
  28. 'attrs' => ['no_attr', 'def_attr', 'other_attrs'],
  29. ];
  30. }
  31. public function isInDefault(): bool
  32. {
  33. if (empty($this->tag)) {
  34. return false;
  35. }
  36. $bbcode = include $this->c->bbcode->fileDefault;
  37. foreach ($bbcode as $cur) {
  38. if ($this->tag === $cur['tag']) {
  39. return true;
  40. }
  41. }
  42. return false;
  43. }
  44. public function setDefault(): Structure
  45. {
  46. if (! $this->isInDefault()) {
  47. throw new RuntimeException("There is no default for the '{$this->tag}' tag");
  48. }
  49. $bbcode = include $this->c->bbcode->fileDefault;
  50. foreach ($bbcode as $cur) {
  51. if ($this->tag === $cur['tag']) {
  52. return $this->setAttrs($cur);
  53. }
  54. }
  55. }
  56. public function fromString(string $data): Structure
  57. {
  58. return $this->setAttrs(\json_decode($data, true, 512, \JSON_THROW_ON_ERROR));
  59. }
  60. public function toString(): string
  61. {
  62. $a = [
  63. 'tag' => $this->tag,
  64. 'type' => $this->type,
  65. 'parents' => $this->parents,
  66. ];
  67. if (null !== $this->auto) {
  68. $a['auto'] = (bool) $this->auto;
  69. }
  70. if (null !== $this->self_nesting) {
  71. $a['self_nesting'] = (int) $this->self_nesting > 0 ? (int) $this->self_nesting : false;
  72. }
  73. if (null !== $this->recursive) {
  74. $a['recursive'] = true;
  75. }
  76. if (null !== $this->text_only) {
  77. $a['text_only'] = true;
  78. }
  79. if (null !== $this->tags_only) {
  80. $a['tags_only'] = true;
  81. }
  82. if (null !== $this->single) {
  83. $a['single'] = true;
  84. }
  85. if (null !== $this->pre) {
  86. $a['pre'] = true;
  87. }
  88. if (
  89. \is_array($this->new_attr)
  90. && ! empty($this->new_attr['allowed'])
  91. && ! empty($this->new_attr['name'])
  92. ) {
  93. $this->setBBAttr($this->new_attr['name'], $this->new_attr, ['required', 'format', 'body_format', 'text_only']);
  94. }
  95. $a['attrs'] = $this->other_attrs;
  96. if (null !== $this->no_attr) {
  97. $a['attrs']['No_attr'] = $this->no_attr;
  98. }
  99. if (null !== $this->def_attr) {
  100. $a['attrs']['Def'] = $this->def_attr;
  101. }
  102. if (empty($a['attrs'])) {
  103. unset($a['attrs']);
  104. }
  105. if (! empty($this->handler) && \is_string($this->handler)) {
  106. $a['handler'] = $this->handler;
  107. }
  108. if (! empty($this->text_handler) && \is_string($this->text_handler)) {
  109. $a['text_handler'] = $this->text_handler;
  110. }
  111. return \json_encode($a, self::JSON_OPTIONS);
  112. }
  113. protected function gettype(): string
  114. {
  115. $type = $this->getAttr('type');
  116. return \is_string($type) ? $type : 'inline';
  117. }
  118. protected function getparents(): array
  119. {
  120. $parents = $this->getAttr('parents');
  121. if (\is_array($parents)) {
  122. return $parents;
  123. } elseif ('inline' === $this->type) {
  124. return ['inline', 'block'];
  125. } else {
  126. return ['block'];
  127. }
  128. }
  129. protected function setrecursive($value): void
  130. {
  131. $value = empty($value) ? null : true;
  132. $this->setAttr('recursive', $value);
  133. }
  134. protected function settext_only($value): void
  135. {
  136. $value = empty($value) ? null : true;
  137. $this->setAttr('text_only', $value);
  138. }
  139. protected function settags_only($value): void
  140. {
  141. $value = empty($value) ? null : true;
  142. $this->setAttr('tags_only', $value);
  143. }
  144. protected function setpre($value): void
  145. {
  146. $value = empty($value) ? null : true;
  147. $this->setAttr('pre', $value);
  148. }
  149. protected function setsingle($value): void
  150. {
  151. $value = empty($value) ? null : true;
  152. $this->setAttr('single', $value);
  153. }
  154. protected function getauto(): bool
  155. {
  156. $auto = $this->getAttr('auto');
  157. if (\is_bool($auto)) {
  158. return $auto;
  159. } elseif ('inline' === $this->type) {
  160. return true;
  161. } else {
  162. return false;
  163. }
  164. }
  165. protected function setauto($value): void
  166. {
  167. $value = ! empty($value);
  168. $this->setAttr('auto', $value);
  169. }
  170. protected function setself_nesting($value): void
  171. {
  172. $value = (int) $value < 1 ? false : (int) $value;
  173. $this->setAttr('self_nesting', $value);
  174. }
  175. protected function getBBAttr(string $name, array $fields) /* : mixed */
  176. {
  177. if (empty($this->attrs[$name])) {
  178. return null;
  179. }
  180. $data = $this->attrs[$name];
  181. if (true === $data) {
  182. return true;
  183. } elseif (! \is_array($data)) {
  184. return null;
  185. } else {
  186. $result = [];
  187. foreach ($fields as $field) {
  188. switch ($field) {
  189. case 'format':
  190. case 'body_format':
  191. $value = isset($data[$field]) && \is_string($data[$field]) ? $data[$field] : null;
  192. break;
  193. case 'required':
  194. case 'text_only':
  195. $value = isset($data[$field]) ? true : null;
  196. break;
  197. default:
  198. throw new RuntimeException('Unknown attribute property');
  199. }
  200. $result[$field] = $value;
  201. }
  202. return $result;
  203. }
  204. }
  205. protected function setBBAttr(string $name, /* mixed */ $data, array $fields): void
  206. {
  207. $attrs = $this->getAttr('attrs');
  208. if (
  209. empty($data['allowed'])
  210. || $data['allowed'] < 1
  211. ) {
  212. unset($attrs[$name]);
  213. } else {
  214. $result = [];
  215. foreach ($fields as $field) {
  216. switch ($field) {
  217. case 'format':
  218. case 'body_format':
  219. $value = ! empty($data[$field]) && \is_string($data[$field]) ? $data[$field] : null;
  220. break;
  221. case 'required':
  222. case 'text_only':
  223. $value = ! empty($data[$field]) ? true : null;
  224. break;
  225. default:
  226. throw new RuntimeException('Unknown attribute property');
  227. }
  228. if (isset($value)) {
  229. $result[$field] = $value;
  230. }
  231. }
  232. $attrs[$name] = empty($result) ? true : $result;
  233. }
  234. $this->setAttr('attrs', $attrs);
  235. }
  236. protected function getno_attr() /* : mixed */
  237. {
  238. return $this->getBBAttr('No_attr', ['body_format', 'text_only']);
  239. }
  240. protected function setno_attr(array $value): void
  241. {
  242. $this->setBBAttr('No_attr', $value, ['body_format', 'text_only']);
  243. }
  244. protected function getdef_attr() /* : mixed */
  245. {
  246. return $this->getBBAttr('Def', ['required', 'format', 'body_format', 'text_only']);
  247. }
  248. protected function setdef_attr(array $value): void
  249. {
  250. $this->setBBAttr('Def', $value, ['required', 'format', 'body_format', 'text_only']);
  251. }
  252. protected function getother_attrs(): array
  253. {
  254. $attrs = $this->getAttr('attrs');
  255. if (! \is_array($attrs)) {
  256. return [];
  257. }
  258. unset($attrs['No_attr'], $attrs['Def'], $attrs['New']);
  259. $result = [];
  260. foreach ($attrs as $name => $attr) {
  261. $value = $this->getBBAttr($name, ['required', 'format', 'body_format', 'text_only']);
  262. if (null !== $value) {
  263. $result[$name] = $value;
  264. }
  265. }
  266. return $result;
  267. }
  268. protected function setother_attrs(array $attrs): void
  269. {
  270. unset($attrs['No_attr'], $attrs['Def']);
  271. foreach ($attrs as $name => $attr) {
  272. $this->setBBAttr($name, $attr, ['required', 'format', 'body_format', 'text_only']);
  273. }
  274. }
  275. /**
  276. * Ищет ошибку в структуре bb-кода
  277. */
  278. public function getError(): ?array
  279. {
  280. if (
  281. ! \is_string($this->tag)
  282. || ! \preg_match(self::TAG_PATTERN, $this->tag)
  283. ) {
  284. return ['Tag name not specified'];
  285. }
  286. $result = $this->testPHP($this->handler);
  287. if (null !== $result) {
  288. return ['PHP code error in Handler: %s', $result];
  289. }
  290. $result = $this->testPHP($this->text_handler);
  291. if (null !== $result ) {
  292. return ['PHP code error in Text handler: %s', $result];
  293. }
  294. if (
  295. null !== $this->recursive
  296. && null !== $this->tags_only
  297. ) {
  298. return ['Recursive and Tags only are enabled at the same time'];
  299. }
  300. if (
  301. null !== $this->recursive
  302. && null !== $this->single
  303. ) {
  304. return ['Recursive and Single are enabled at the same time'];
  305. }
  306. if (
  307. null !== $this->text_only
  308. && null !== $this->tags_only
  309. ) {
  310. return ['Text only and Tags only are enabled at the same time'];
  311. }
  312. if (\is_array($this->attrs)) {
  313. foreach ($this->attrs as $name => $attr) {
  314. if (
  315. 'No_attr' !== $name
  316. && 'Def' !== $name
  317. && ! \preg_match(self::ATTR_PATTERN, $name)
  318. ) {
  319. return ['Attribute name %s is not valid', $name];
  320. }
  321. if (isset($attr['format'])) {
  322. $result = ['Attribute %1$s, %2$s - regular expression error', $name, 'Format'];
  323. try {
  324. if (
  325. ! \is_string($attr['format'])
  326. || false === @\preg_match($attr['format'], 'abcdef')
  327. ) {
  328. return $result;
  329. }
  330. } catch (Throwable $e) {
  331. return $result;
  332. }
  333. }
  334. if (isset($attr['body_format'])) {
  335. $result = ['Attribute %1$s, %2$s - regular expression error', $name, 'Body format'];
  336. try {
  337. if (
  338. ! \is_string($attr['body_format'])
  339. || false === @\preg_match($attr['body_format'], 'abcdef')
  340. ) {
  341. return $result;
  342. }
  343. } catch (Throwable $e) {
  344. return $result;
  345. }
  346. }
  347. }
  348. }
  349. if (
  350. \is_array($this->new_attr)
  351. && ! empty($this->new_attr['allowed'])
  352. && ! empty($this->new_attr['name'])
  353. ) {
  354. $name = $this->new_attr['name'];
  355. if (
  356. 'No_attr' === $name
  357. || 'Def' === $name
  358. || isset($this->attrs[$name])
  359. || ! \preg_match(self::ATTR_PATTERN, $name)
  360. ) {
  361. return ['Attribute name %s is not valid', $name];
  362. }
  363. if (isset($this->new_attr['format'])) {
  364. $result = ['Attribute %1$s, %2$s - regular expression error', $name, 'Format'];
  365. try {
  366. if (
  367. ! \is_string($this->new_attr['format'])
  368. || false === @\preg_match($this->new_attr['format'], 'abcdef')
  369. ) {
  370. return $result;
  371. }
  372. } catch (Throwable $e) {
  373. return $result;
  374. }
  375. }
  376. if (isset($this->new_attr['body_format'])) {
  377. $result = ['Attribute %1$s, %2$s - regular expression error', $name, 'Body format'];
  378. try {
  379. if (
  380. ! \is_string($this->new_attr['body_format'])
  381. || false === @\preg_match($this->new_attr['body_format'], 'abcdef')
  382. ) {
  383. return $result;
  384. }
  385. } catch (Throwable $e) {
  386. return $result;
  387. }
  388. }
  389. }
  390. return null;
  391. }
  392. protected function testPHP(?string $code): ?string
  393. {
  394. if (
  395. null === $code
  396. || '' === $code
  397. ) {
  398. return null;
  399. }
  400. // тест на парность скобок
  401. $testCode = \preg_replace('%//[^\r\n]*+|#[^\r\n]*+|/\*.*?\*/|\'.*?(?<!\\\\)\'|".*?(?<!\\\\)"%s', '', $code);
  402. if (false === \preg_match_all('%[(){}\[\]]%s', $testCode, $matches)) {
  403. throw new RuntimeException('The preg_match_all() returned an error');
  404. }
  405. $round = 0;
  406. $square = 0;
  407. $curly = 0;
  408. foreach ($matches[0] as $value) {
  409. switch ($value) {
  410. case '(':
  411. ++$round;
  412. break;
  413. case ')':
  414. --$round;
  415. if ($round < 0) {
  416. return '\')\' > \'(\'.';
  417. }
  418. break;
  419. case '[':
  420. ++$square;
  421. break;
  422. case ']':
  423. --$square;
  424. if ($square < 0) {
  425. return '\']\' > \'[\'.';
  426. }
  427. break;
  428. case '{':
  429. ++$curly;
  430. break;
  431. case '}':
  432. --$curly;
  433. if ($curly < 0) {
  434. return '\'}\' > \'{\'.';
  435. }
  436. break;
  437. default:
  438. throw new RuntimeException('Unknown bracket type');
  439. }
  440. }
  441. if (0 !== $round) {
  442. return '\'(\' != \')\'.';
  443. }
  444. if (0 !== $square) {
  445. return '\'[\' != \']\'.';
  446. }
  447. if (0 !== $curly) {
  448. return '\'{\' != \'}\'.';
  449. }
  450. // тест на выполнение DANGER! DANGER! DANGER! O_o
  451. $testCode = "\$testVar = function (\$body, \$attrs, \$parser) { {$code} };\nreturn true;";
  452. try {
  453. $result = @eval($testCode);
  454. if (true !== $result) {
  455. $error = \error_get_last();
  456. $message = $error['message'] ?? 'Unknown error';
  457. $line = $error['line'] ?? '';
  458. return "{$message}: [$line]";
  459. }
  460. } catch (Throwable $e) {
  461. return "{$e->getMessage()}: [{$e->getLine()}]";
  462. }
  463. return null;
  464. }
  465. }