PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / trunk
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification vtrunk
5.11.0 5.10.0 5.9.0 5.8.0 5.8.1 5.7.0 5.6.2 5.6.3 5.6.1 5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 All 44 releases
double-opt-in / src / Help / HelpRepository.php

HelpRepository.php in Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification trunk, at src/Help/HelpRepository.php

151 lines 4.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Reads the bundled help articles from disk.
4 *
5 * Layout per source: `help/<lang>/<name>.md`, images under
6 * `help/images/<lang>/`. Article ids are `<source>--<name>`; lookups go through
7 * the list of files that exist, never through the id as a path, so a crafted
8 * id cannot reach any other file.
9 *
10 * @package Forge12\DoubleOptIn\Help
11 * @since 5.11.0
12 */
13
14 declare( strict_types=1 );
15
16 namespace Forge12\DoubleOptIn\Help;
17
18 if ( ! defined( 'ABSPATH' ) ) {
19 exit;
20 }
21
22 class HelpRepository {
23
24 public const LANGUAGES = array( 'de', 'en' );
25
26 /** Core articles first, in reading order; anything else follows alphabetically. */
27 private const CORE_ORDER = array( 'guide', 'troubleshooting', 'shortcodes' );
28
29 /** @var HelpSource[] */
30 private $sources;
31
32 /** @var string 'de' or 'en' */
33 private $language;
34
35 /**
36 * @param HelpSource[] $sources
37 * @param string $locale WordPress locale, e.g. "de_DE_formal".
38 */
39 public function __construct( array $sources, string $locale ) {
40 $this->sources = $sources;
41 $this->language = self::languageFor( $locale );
42 }
43
44 public static function languageFor( string $locale ): string {
45 return 0 === strpos( strtolower( $locale ), 'de' ) ? 'de' : 'en';
46 }
47
48 /**
49 * Table of contents: id, title, group, source name — no bodies.
50 *
51 * @return array<int, array{id: string, title: string, group: string, source: string}>
52 */
53 public function listArticles(): array {
54 $out = array();
55 foreach ( $this->sources as $source ) {
56 foreach ( $this->filesOf( $source ) as $name => $info ) {
57 $title = MarkdownRenderer::extractTitle( $this->read( $info['path'] ) );
58 $out[] = array(
59 'id' => $source->id . '--' . $name,
60 'title' => '' !== $title ? $title : $name,
61 'group' => $source->group,
62 'source' => $source->name,
63 );
64 }
65 }
66 return $out;
67 }
68
69 /**
70 * @return array{id: string, title: string, group: string, source: string, html: string, language: string}|null
71 */
72 public function getArticle( string $id ): ?array {
73 foreach ( $this->sources as $source ) {
74 foreach ( $this->filesOf( $source ) as $name => $info ) {
75 if ( $source->id . '--' . $name !== $id ) {
76 continue;
77 }
78 $markdown = $this->read( $info['path'] );
79 $lang = $info['language'];
80 $base = $source->url . 'images/' . $lang . '/';
81 $pattern = '#^images/' . preg_quote( $lang, '#' ) . '/([A-Za-z0-9._-]+\.(?:png|jpe?g|gif|webp))$#';
82 $renderer = new MarkdownRenderer(
83 static function ( string $src ) use ( $base, $pattern ): string {
84 // Only `images/<lang>/<file>`; nothing that climbs out of that folder.
85 if ( preg_match( $pattern, $src, $m ) && false === strpos( $m[1], '..' ) ) {
86 return $base . $m[1];
87 }
88 return '';
89 }
90 );
91 $title = MarkdownRenderer::extractTitle( $markdown );
92 return array(
93 'id' => $id,
94 'title' => '' !== $title ? $title : $name,
95 'group' => $source->group,
96 'source' => $source->name,
97 'html' => $renderer->render( $markdown ),
98 'language' => $lang,
99 );
100 }
101 }
102 return null;
103 }
104
105 /**
106 * Markdown files of a source in the active language, falling back to the
107 * other one per file. Keys are file names without extension, ordered.
108 *
109 * @return array<string, array{path: string, language: string}>
110 */
111 private function filesOf( HelpSource $source ): array {
112 $order = array_values( array_unique( array_merge( array( $this->language ), self::LANGUAGES ) ) );
113 $found = array();
114 // Walk the fallback language first so the active one overwrites it.
115 foreach ( array_reverse( $order ) as $lang ) {
116 $files = glob( $source->dir . '/' . $lang . '/*.md' );
117 foreach ( false === $files ? array() : $files as $file ) {
118 $name = basename( $file, '.md' );
119 if ( ! preg_match( '/^[a-z0-9-]+$/', $name ) ) {
120 continue;
121 }
122 $found[ $name ] = array(
123 'path' => $file,
124 'language' => $lang,
125 );
126 }
127 }
128
129 if ( HelpSource::GROUP_CORE === $source->group ) {
130 uksort(
131 $found,
132 static function ( string $a, string $b ): int {
133 $ia = array_search( $a, self::CORE_ORDER, true );
134 $ib = array_search( $b, self::CORE_ORDER, true );
135 $ia = false === $ia ? 99 : $ia;
136 $ib = false === $ib ? 99 : $ib;
137 return $ia === $ib ? strcmp( $a, $b ) : $ia <=> $ib;
138 }
139 );
140 } else {
141 ksort( $found );
142 }
143 return $found;
144 }
145
146 private function read( string $path ): string {
147 $content = is_readable( $path ) ? file_get_contents( $path ) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
148 return false === $content ? '' : $content;
149 }
150 }
151