PluginProbe
wpForo Forum / 3.2.1
wpForo Forum v3.2.1
3.2.1 3.2.0 3.1.7 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 All 141 releases
wpforo / admin / pages / news / src / NewsModule.php

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

268 lines 11.0 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 // Blocked updates: license module's update check found new versions of licensed
75 // addons that this site can't install (file modifications blocked) — tell the admins.
76 add_action( 'gvectors_blocked_updates', [ $email, 'handle_blocked_updates' ], 10, 1 );
77
78 self::$core = [
79 'config' => $config,
80 'api' => $api,
81 'consent' => $consent,
82 'prefs' => $prefs,
83 'email' => $email,
84 'cron' => $cron,
85 'notices' => $notices,
86 'admin_page' => $admin_page,
87 ];
88 } else {
89 // Another gVectors plugin already initialized the module — only add
90 // this plugin's settings menu item; state, cron and notices are shared.
91 self::$core['admin_page']->add_context( $config );
92 }
93 }
94
95 /**
96 * Get a shared service instance ('config' returns the given plugin's own context).
97 * e.g. NewsModule::get( 'wpforo', 'cron' )
98 */
99 public static function get( string $slug, string $service = 'config' ) {
100 if( ! isset( self::$contexts[ $slug ] ) ) return null;
101 if( $service === 'config' ) return self::$contexts[ $slug ];
102
103 return self::$core[ $service ] ?? null;
104 }
105
106 /**
107 * All registered plugin contexts (slug → Config).
108 * @return Config[]
109 */
110 public static function get_contexts(): array {
111 return self::$contexts;
112 }
113
114 /**
115 * Unschedule this module's cron for a plugin — call from the host plugin's
116 * deactivation hook: NewsModule::clear_scheduled_events( new MyNewsConfig( ... ) ).
117 */
118 public static function clear_scheduled_events( Config $config ): void {
119 wp_clear_scheduled_hook( $config->get_cron_hook() );
120 }
121
122 /**
123 * Load the shared 'gvectors' textdomain from this module's languages/ dir.
124 */
125 public static function load_textdomain(): void {
126 static $loaded = false;
127 if( $loaded ) return;
128 $loaded = true;
129
130 $locale = function_exists( 'determine_locale' ) ? determine_locale() : get_locale();
131 $mofile = dirname( __DIR__ ) . '/languages/gvectors-' . $locale . '.mo';
132 if( file_exists( $mofile ) ) {
133 load_textdomain( 'gvectors', $mofile );
134 }
135 }
136
137 /**
138 * Full data cleanup — call from the host plugin's uninstall flow.
139 *
140 * Shared data (options, news transient, per-admin user meta, cron) is
141 * removed ONLY when no OTHER gVectors plugin carrying this module remains
142 * installed: news preferences and reminder state are gVectors-wide, so the
143 * last product out turns off the lights.
144 */
145 public static function uninstall( Config $config ): void {
146 if( self::another_gvectors_plugin_installed( $config->get_core_plugin_slug() ) ) {
147 return; // another gVectors product still owns the shared data
148 }
149
150 wp_clear_scheduled_hook( $config->get_cron_hook() );
151
152 delete_option( $config->get_service_enabled_option() );
153 delete_option( $config->get_site_prefs_option() );
154 delete_option( $config->get_optin_notice_dismissed_option() );
155 delete_option( $config->get_emailed_transactions_option() );
156 delete_option( $config->get_abandoned_emailed_option() );
157 delete_transient( $config->get_news_transient() );
158
159 // Per-admin meta — remove for ALL users
160 delete_metadata( 'user', 0, $config->get_dismissed_news_meta(), '', true );
161 delete_metadata( 'user', 0, $config->get_emailed_news_meta(), '', true );
162 delete_metadata( 'user', 0, $config->get_emailed_phases_meta(), '', true );
163 delete_metadata( 'user', 0, $config->get_user_prefs_meta(), '', true );
164 delete_metadata( 'user', 0, $config->get_emailed_recs_meta(), '', true );
165 }
166
167 /**
168 * Is any OTHER installed plugin shipping this news module?
169 * Detected by the module's entry file inside each plugin directory.
170 */
171 private static function another_gvectors_plugin_installed( string $current_slug ): bool {
172 $pattern = trailingslashit( WP_PLUGIN_DIR ) . '*/admin/pages/news/src/NewsModule.php';
173 foreach( glob( $pattern ) ?: [] as $file ) {
174 // {WP_PLUGIN_DIR}/{plugin}/admin/pages/news/src/NewsModule.php → {plugin}
175 $plugin_dir = basename( dirname( $file, 5 ) );
176 if( $plugin_dir !== $current_slug ) {
177 return true;
178 }
179 }
180
181 return false;
182 }
183
184 /**
185 * Check if the current admin page should display this module's notices.
186 * Allowed pages: Dashboard Home, Updates, Installed Plugins, Add Plugins,
187 * and the admin pages of ANY registered gVectors plugin (pass a slug to
188 * check one specific plugin only).
189 *
190 * IMPORTANT: keep in sync with AddonsService::is_notice_page() in the
191 * license module — both modules must surface notices on the same pages.
192 */
193 public static function is_notice_page( ?string $core_plugin_slug = null ): bool {
194 if( ! is_admin() ) return false;
195
196 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
197 if( $screen ) {
198 // Dashboard Home, Updates, Plugins, Add Plugins
199 if( in_array( $screen->id, [ 'dashboard', 'update-core', 'plugins', 'plugin-install' ], true ) ) {
200 return true;
201 }
202 // Any page belonging to a registered gVectors plugin (screen id contains its slug)
203 $slugs = $core_plugin_slug !== null ? [ $core_plugin_slug ] : array_keys( self::$contexts );
204 foreach( $slugs as $slug ) {
205 if( strpos( $screen->id, $slug ) !== false ) {
206 return true;
207 }
208 }
209 }
210
211 return false;
212 }
213
214 /**
215 * Generate the unique site token for authenticating with the proxy server.
216 * IMPORTANT: must produce the exact same value as the license module's
217 * LicenseModule::get_site_token() — the proxy stores one token per domain
218 * (TOFU), so both modules must present the same identity.
219 */
220 public static function get_site_token(): string {
221 if( class_exists( '\gVectors\License\LicenseModule' ) ) {
222 return \gVectors\License\LicenseModule::get_site_token();
223 }
224
225 return hash_hmac( 'sha256', self::get_site_domain(), self::get_auth_salt() );
226 }
227
228 /**
229 * Same salt derivation as the license module (kept in sync intentionally).
230 */
231 private static function get_auth_salt(): string {
232 $parts = [];
233 if( defined( 'AUTH_SALT' ) && AUTH_SALT !== '' ) $parts[] = AUTH_SALT;
234 if( defined( 'SECURE_AUTH_SALT' ) && SECURE_AUTH_SALT !== '' ) $parts[] = SECURE_AUTH_SALT;
235 if( defined( 'LOGGED_IN_SALT' ) && LOGGED_IN_SALT !== '' ) $parts[] = LOGGED_IN_SALT;
236 if( defined( 'NONCE_SALT' ) && NONCE_SALT !== '' ) $parts[] = NONCE_SALT;
237
238 if( ! empty( $parts ) ) {
239 return implode( '|', $parts );
240 }
241
242 return hash( 'sha256', DB_NAME . ':' . DB_USER . ':' . self::get_site_domain() );
243 }
244
245 /**
246 * Raw site domain (no protocol, no www, no trailing slash).
247 */
248 public static function get_site_domain(): string {
249 if( class_exists( '\gVectors\License\LicenseModule' ) ) {
250 return \gVectors\License\LicenseModule::get_site_domain();
251 }
252
253 return self::normalize_domain( get_site_url() );
254 }
255
256 /**
257 * Normalize a site domain: lowercase, strip protocol/www/path.
258 * Must match the server-side LicenseService::normalizeDomain() logic.
259 */
260 public static function normalize_domain( string $url ): string {
261 $url = rtrim( strtolower( trim( $url ) ), '/' );
262 $url = preg_replace( '#^https?://#', '', $url );
263 $url = preg_replace( '#^www\.#', '', $url );
264
265 return explode( '/', $url )[0];
266 }
267 }
268