PluginProbe
wpForo Forum / 3.1.6
wpForo Forum v3.1.6
3.1.6 3.1.5 3.1.4 3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.10 1.4.11 1.4.12 1.4.13 All 138 releases
wpforo / admin / pages / news / src / NewsModule.php

NewsModule.php in wpForo Forum 3.1.6, at admin/pages/news/src/NewsModule.php

264 lines 10.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace gVectors\News;
4
5 use gVectors\News\Services\ApiService;
6 use gVectors\News\Services\ConsentService;
7 use gVectors\News\Services\CronService;
8 use gVectors\News\Services\EmailService;
9 use gVectors\News\Services\NoticesService;
10 use gVectors\News\Services\PrefsService;
11
12 // Exit if accessed directly
13 if( ! defined( 'ABSPATH' ) ) exit;
14
15 /**
16 * gVectors News Module — entry point.
17 *
18 * Reusable library shared by gVectors plugins (wpForo, wpDiscuz, ...):
19 * - fetches global news from the gVectors proxy server (daily cron)
20 * - renders dismissible admin notices for news items
21 * - sends the news digest email and license expiry reminder emails
22 * - everything is gated behind an explicit admin opt-in (zero outbound
23 * requests before consent — WordPress.org Guideline 7)
24 *
25 * The proxy is the single source of truth: news content, at-risk license
26 * selection, phase math and email bodies are all computed server-side.
27 * This module is a thin renderer/sender.
28 */
29 class NewsModule {
30
31 /**
32 * Shared service core — created ONCE by whichever gVectors plugin loads
33 * first. All hooks (cron, admin notices, AJAX, emails) live here, so no
34 * matter how many gVectors plugins instantiate the module, everything
35 * runs exactly once.
36 */
37 private static $core = null;
38
39 /**
40 * Registered plugin contexts: slug → Config.
41 * Each additional plugin only contributes its own settings menu item and
42 * its own license storage to the shared versions map.
43 */
44 private static $contexts = [];
45
46 public function __construct( Config $config ) {
47 $slug = $config->get_core_plugin_slug();
48 if( isset( self::$contexts[ $slug ] ) ) return; // same plugin twice — no-op
49 self::$contexts[ $slug ] = $config;
50
51 if( self::$core === null ) {
52 // Translations for the shared 'gvectors' textdomain (also used by the
53 // license module) — loaded once, from the first module copy that runs.
54 add_action( 'init', [ __CLASS__, 'load_textdomain' ] );
55
56 // First gVectors plugin in: build the shared service tree.
57 $api = new ApiService( $config );
58 $consent = new ConsentService( $config );
59 $prefs = new PrefsService( $config );
60 $email = new EmailService( $config, $prefs );
61 $cron = new CronService( $config, $api, $consent, $email );
62 $notices = new NoticesService( $config, $consent, $cron, $prefs );
63 $admin_page = new AdminPage( $config, $consent, $prefs );
64
65 // Purchase confirmation: license module fires this after the dashboard
66 // polling activates purchased license(s) — email the keys to the BUYER
67 // (4th arg; the polling may run in another admin's session).
68 add_action( 'gvectors_transaction_licenses_activated', [ $email, 'handle_purchase_activated' ], 10, 4 );
69
70 // Abandoned checkout recovery: license module confirms via the proxy
71 // that a checkout is still unpaid at a phase — email the purchaser.
72 add_action( 'gvectors_abandoned_checkout_email', [ $email, 'handle_abandoned_checkout' ], 10, 4 );
73
74 self::$core = [
75 'config' => $config,
76 'api' => $api,
77 'consent' => $consent,
78 'prefs' => $prefs,
79 'email' => $email,
80 'cron' => $cron,
81 'notices' => $notices,
82 'admin_page' => $admin_page,
83 ];
84 } else {
85 // Another gVectors plugin already initialized the module — only add
86 // this plugin's settings menu item; state, cron and notices are shared.
87 self::$core['admin_page']->add_context( $config );
88 }
89 }
90
91 /**
92 * Get a shared service instance ('config' returns the given plugin's own context).
93 * e.g. NewsModule::get( 'wpforo', 'cron' )
94 */
95 public static function get( string $slug, string $service = 'config' ) {
96 if( ! isset( self::$contexts[ $slug ] ) ) return null;
97 if( $service === 'config' ) return self::$contexts[ $slug ];
98
99 return self::$core[ $service ] ?? null;
100 }
101
102 /**
103 * All registered plugin contexts (slug → Config).
104 * @return Config[]
105 */
106 public static function get_contexts(): array {
107 return self::$contexts;
108 }
109
110 /**
111 * Unschedule this module's cron for a plugin — call from the host plugin's
112 * deactivation hook: NewsModule::clear_scheduled_events( new MyNewsConfig( ... ) ).
113 */
114 public static function clear_scheduled_events( Config $config ): void {
115 wp_clear_scheduled_hook( $config->get_cron_hook() );
116 }
117
118 /**
119 * Load the shared 'gvectors' textdomain from this module's languages/ dir.
120 */
121 public static function load_textdomain(): void {
122 static $loaded = false;
123 if( $loaded ) return;
124 $loaded = true;
125
126 $locale = function_exists( 'determine_locale' ) ? determine_locale() : get_locale();
127 $mofile = dirname( __DIR__ ) . '/languages/gvectors-' . $locale . '.mo';
128 if( file_exists( $mofile ) ) {
129 load_textdomain( 'gvectors', $mofile );
130 }
131 }
132
133 /**
134 * Full data cleanup — call from the host plugin's uninstall flow.
135 *
136 * Shared data (options, news transient, per-admin user meta, cron) is
137 * removed ONLY when no OTHER gVectors plugin carrying this module remains
138 * installed: news preferences and reminder state are gVectors-wide, so the
139 * last product out turns off the lights.
140 */
141 public static function uninstall( Config $config ): void {
142 if( self::another_gvectors_plugin_installed( $config->get_core_plugin_slug() ) ) {
143 return; // another gVectors product still owns the shared data
144 }
145
146 wp_clear_scheduled_hook( $config->get_cron_hook() );
147
148 delete_option( $config->get_service_enabled_option() );
149 delete_option( $config->get_site_prefs_option() );
150 delete_option( $config->get_optin_notice_dismissed_option() );
151 delete_option( $config->get_emailed_transactions_option() );
152 delete_option( $config->get_abandoned_emailed_option() );
153 delete_transient( $config->get_news_transient() );
154
155 // Per-admin meta — remove for ALL users
156 delete_metadata( 'user', 0, $config->get_dismissed_news_meta(), '', true );
157 delete_metadata( 'user', 0, $config->get_emailed_news_meta(), '', true );
158 delete_metadata( 'user', 0, $config->get_emailed_phases_meta(), '', true );
159 delete_metadata( 'user', 0, $config->get_user_prefs_meta(), '', true );
160 delete_metadata( 'user', 0, $config->get_emailed_recs_meta(), '', true );
161 }
162
163 /**
164 * Is any OTHER installed plugin shipping this news module?
165 * Detected by the module's entry file inside each plugin directory.
166 */
167 private static function another_gvectors_plugin_installed( string $current_slug ): bool {
168 $pattern = trailingslashit( WP_PLUGIN_DIR ) . '*/admin/pages/news/src/NewsModule.php';
169 foreach( glob( $pattern ) ?: [] as $file ) {
170 // {WP_PLUGIN_DIR}/{plugin}/admin/pages/news/src/NewsModule.php → {plugin}
171 $plugin_dir = basename( dirname( $file, 5 ) );
172 if( $plugin_dir !== $current_slug ) {
173 return true;
174 }
175 }
176
177 return false;
178 }
179
180 /**
181 * Check if the current admin page should display this module's notices.
182 * Allowed pages: Dashboard Home, Updates, Installed Plugins, Add Plugins,
183 * and the admin pages of ANY registered gVectors plugin (pass a slug to
184 * check one specific plugin only).
185 *
186 * IMPORTANT: keep in sync with AddonsService::is_notice_page() in the
187 * license module — both modules must surface notices on the same pages.
188 */
189 public static function is_notice_page( ?string $core_plugin_slug = null ): bool {
190 if( ! is_admin() ) return false;
191
192 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
193 if( $screen ) {
194 // Dashboard Home, Updates, Plugins, Add Plugins
195 if( in_array( $screen->id, [ 'dashboard', 'update-core', 'plugins', 'plugin-install' ], true ) ) {
196 return true;
197 }
198 // Any page belonging to a registered gVectors plugin (screen id contains its slug)
199 $slugs = $core_plugin_slug !== null ? [ $core_plugin_slug ] : array_keys( self::$contexts );
200 foreach( $slugs as $slug ) {
201 if( strpos( $screen->id, $slug ) !== false ) {
202 return true;
203 }
204 }
205 }
206
207 return false;
208 }
209
210 /**
211 * Generate the unique site token for authenticating with the proxy server.
212 * IMPORTANT: must produce the exact same value as the license module's
213 * LicenseModule::get_site_token() — the proxy stores one token per domain
214 * (TOFU), so both modules must present the same identity.
215 */
216 public static function get_site_token(): string {
217 if( class_exists( '\gVectors\License\LicenseModule' ) ) {
218 return \gVectors\License\LicenseModule::get_site_token();
219 }
220
221 return hash_hmac( 'sha256', self::get_site_domain(), self::get_auth_salt() );
222 }
223
224 /**
225 * Same salt derivation as the license module (kept in sync intentionally).
226 */
227 private static function get_auth_salt(): string {
228 $parts = [];
229 if( defined( 'AUTH_SALT' ) && AUTH_SALT !== '' ) $parts[] = AUTH_SALT;
230 if( defined( 'SECURE_AUTH_SALT' ) && SECURE_AUTH_SALT !== '' ) $parts[] = SECURE_AUTH_SALT;
231 if( defined( 'LOGGED_IN_SALT' ) && LOGGED_IN_SALT !== '' ) $parts[] = LOGGED_IN_SALT;
232 if( defined( 'NONCE_SALT' ) && NONCE_SALT !== '' ) $parts[] = NONCE_SALT;
233
234 if( ! empty( $parts ) ) {
235 return implode( '|', $parts );
236 }
237
238 return hash( 'sha256', DB_NAME . ':' . DB_USER . ':' . self::get_site_domain() );
239 }
240
241 /**
242 * Raw site domain (no protocol, no www, no trailing slash).
243 */
244 public static function get_site_domain(): string {
245 if( class_exists( '\gVectors\License\LicenseModule' ) ) {
246 return \gVectors\License\LicenseModule::get_site_domain();
247 }
248
249 return self::normalize_domain( get_site_url() );
250 }
251
252 /**
253 * Normalize a site domain: lowercase, strip protocol/www/path.
254 * Must match the server-side LicenseService::normalizeDomain() logic.
255 */
256 public static function normalize_domain( string $url ): string {
257 $url = rtrim( strtolower( trim( $url ) ), '/' );
258 $url = preg_replace( '#^https?://#', '', $url );
259 $url = preg_replace( '#^www\.#', '', $url );
260
261 return explode( '/', $url )[0];
262 }
263 }
264