Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Client Reports — Every report waits for approval** (on by default): the monthly run compiles and checks every report but emails nothing; operators send them from the Reports list. Switch it on or off on the Scheduling tab (`client_reports.require_approval`, logged as `client_report_approval_changed`).
- **Client Reports — Approve & send all ready / Send selected** on the Reports list: send every Ready to send report of the period, or the ticked ones (held ones included), up to 25 per press, skipping outdated, replaced and already-sent reports.

### Changed
- **Client Reports — Report schema 8**: unsent reports compiled before the checks below are outdated and must be recompiled before they can be sent.
- **Client Reports — More reports held for review before the first unattended run (Dec 1)**, each one a client email that would otherwise have been wrong or unexplained:
- a Sucuri SiteCheck hit (malware or a blocklisting) on a site whose malware row comes from a clean Companion scan;
- a core-file or blocklist problem earlier in the month that a later clean check hides;
- no malware scan or blocklist check finished all month while those scans are switched on;
- a vulnerability list older than 48 hours, or an installed plugin whose vulnerability lookup has never succeeded (the refresh now records each plugin's last successful lookup);
- the uptime monitor blocked (HTTP 429) for an hour or more, or maintenance mode for 2 hours or more, in total (both count as up);
- every listed backup files only, or the latest backup read failed or finished before the month ended;
- a care plan switched off after the month ended (the month is now compiled for it, not skipped).
- **Bill.com care-plan sync** logs each care-plan flip as a local `care_plan_toggled` entry (not mirrored to wp-admin).
- **Client Reports — Schedule form** no longer offers "Weekly" for new schedules (every run reports on the previous calendar month).

### Fixed
- **Confirm dialogs** submit through the button that was clicked, so its `formaction` and value survive the dialog (a button's value was re-added as a hidden field, which stayed behind when the page came back from the browser's back button).
- **Client Reports — Schedule form**: unchecking **Enable** had no effect, and saving a paused schedule switched it back on.
- **Client Reports — Recipients**: editing a schedule's recipients now updates its unsent reports (Send used the list the report was compiled with); addresses are de-duplicated ignoring case; sends to one report are locked, so a double click or a hand send during the scheduled run can't email anyone twice; Send on an already-sent report says so first; a draft-mode Run no longer compiles a second live report beside a sent one; `client_reports.client_email` holds long recipient lists.
- **Client Reports — Email content**: updates run from a site's Updates tab show the plugin's name instead of the raw log line; vulnerable plugin names no longer show HTML entities; a speed test with no score is skipped instead of showing a red 0/100; requests with no visits count as suspect traffic; uptime is rounded down, so a short outage never reads 100%; the inbox preview text follows the template's sections; a site added late on Nov 1 (a 25-hour day in New York) is no longer held as a partial month.
- **Client Reports — Backups**: a SpinupWP run's database half is stored on its own (`site_backups.database_bytes`), so a broken dump can't hide behind the files.
- **Client emails — Logo**: a logo uploaded in Settings is embedded from Control's disk; a relative or Control-host logo URL is never linked (it showed as a broken image).
- **Client Reports — Recipient push** no longer writes an older Companion snapshot back over a newer one.
- **Plugin updates from a site's Updates tab** are logged in the update jobs' wording ("Plugin updated: Name 1.0 → 1.1").

### Upgrade notes
- Run `php artisan migrate` (`site_backups.database_bytes`; `client_reports.client_email` becomes text), then `php artisan clockwork:record-site-backups` and `php artisan clockwork:refresh-plugin-vulnerabilities` once.
- Report schema 8 marks every unsent report outdated: run `php artisan clockwork:recompile-held-client-reports --outdated` (never sends).
- Approval is on by default after deploy: from the next run nothing is emailed until someone sends it. Run `npm run build` (confirm dialog change).

## [1.12.2] - 2026-11-06

### Added
Expand Down
15 changes: 14 additions & 1 deletion app/Http/Controllers/SitesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -1592,7 +1592,8 @@ public function updatePlugin(Site $site, Request $request, ActionLogger $logger)
$summary = $result['ok']
? ($result['before_version'] === $result['after_version']
? "Plugin already up to date: {$slug} ({$result['before_version']}) on {$site->domain}."
: "Updated {$slug} {$result['before_version']} → {$result['after_version']} on {$site->domain}.")
// Worded as the update jobs word it: client reports list this.
: sprintf('Plugin updated: %s %s → %s', $this->snapshotPluginName($site, $slug), $result['before_version'], $result['after_version']))
: "Plugin update failed: {$slug} on {$site->domain}.";

$logger->record(
Expand All @@ -1609,6 +1610,18 @@ public function updatePlugin(Site $site, Request $request, ActionLogger $logger)
return response()->json($result);
}

/** The plugin's name from the site's Companion snapshot, or its slug. */
private function snapshotPluginName(Site $site, string $slug): string
{
foreach ((array) ($site->companion_snapshot['plugins']['plugins'] ?? []) as $plugin) {
if (is_array($plugin) && ($plugin['slug'] ?? null) === $slug && trim((string) ($plugin['name'] ?? '')) !== '') {
return html_entity_decode(trim((string) $plugin['name']), ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
}

return $slug;
}

/**
* Toggle the care_plan_enabled flag on a site. Also sets care_plan_override
* so the daily Bill.com sync respects the manual choice (sync only writes
Expand Down
3 changes: 3 additions & 0 deletions app/Models/SiteBackup.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* @property string $external_ref
* @property Carbon $backup_at
* @property ?int $bytes
* @property ?int $database_bytes the database half of a full backup, when known
* @property bool $is_manual
* @property ?array $manifest
* @property Carbon $first_seen_at
Expand Down Expand Up @@ -43,6 +44,7 @@ class SiteBackup extends Model
'external_ref',
'backup_at',
'bytes',
'database_bytes',
'is_manual',
'manifest',
'first_seen_at',
Expand All @@ -54,6 +56,7 @@ protected function casts(): array
return [
'backup_at' => 'datetime',
'bytes' => 'integer',
'database_bytes' => 'integer',
'is_manual' => 'boolean',
'manifest' => 'array',
'first_seen_at' => 'datetime',
Expand Down
7 changes: 5 additions & 2 deletions app/Services/Backups/SiteBackupRecorder.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,14 @@ public function record(Site $site): ?array
if ($rows !== []) {
SiteBackup::query()->upsert(
array_map(fn (array $row) => $row + [
'database_bytes' => null,
'site_id' => $site->id,
'source' => $source,
'first_seen_at' => $now,
'last_seen_at' => $now,
], $rows),
['site_id', 'source', 'kind', 'external_ref'],
['backup_at', 'bytes', 'is_manual', 'last_seen_at'],
['backup_at', 'bytes', 'database_bytes', 'is_manual', 'last_seen_at'],
);
}
} catch (Throwable $e) {
Expand Down Expand Up @@ -137,7 +138,7 @@ protected function fromPressable(Site $site): array
return $rows;
}

/** @return list<array{kind: string, external_ref: string, backup_at: Carbon, bytes: ?int, is_manual: bool}> */
/** @return list<array{kind: string, external_ref: string, backup_at: Carbon, bytes: ?int, database_bytes: ?int, is_manual: bool}> */
protected function fromSpaces(Site $site): array
{
$rows = [];
Expand All @@ -158,6 +159,8 @@ protected function fromSpaces(Site $site): array
'external_ref' => (string) $run['date'],
'backup_at' => Carbon::parse($run['date'])->utc(),
'bytes' => (int) $database + (int) $files,
// Kept apart so a broken dump can't hide behind the files.
'database_bytes' => $database,
'is_manual' => false,
];
}
Expand Down
39 changes: 39 additions & 0 deletions app/Services/Security/WpVulnerabilityClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Services\Security;

use App\Support\Settings;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
Expand Down Expand Up @@ -32,6 +33,8 @@ class WpVulnerabilityClient
{
private const BASE_URL = 'https://www.wpvulnerability.net/plugin/';

public const LOOKUPS_SETTING = 'plugin_vulnerabilities.last_lookups';

private const PER_REQUEST_TIMEOUT = 10;

private const REQUEST_DELAY_MS = 100;
Expand Down Expand Up @@ -113,6 +116,7 @@ public function refresh(?callable $onProgress = null): array
DB::table('plugin_vulnerabilities')->insert($batch);
}
});
$this->recordLookups($slugs, $failedSlugs);

return [
'ok' => true,
Expand All @@ -125,6 +129,41 @@ public function refresh(?callable $onProgress = null): array
];
}

/**
* When each fleet plugin last had a successful lookup (slug => ISO 8601
* time), kept in settings under LOOKUPS_SETTING. A plugin whose lookup
* has never succeeded has no entries to keep, so it would read "None
* found" unchecked; client reports hold for it instead.
*
* @return array<string, string>|null null before the first refresh that kept them
*/
public static function lastLookups(): ?array
{
$lookups = app(Settings::class)->get(self::LOOKUPS_SETTING);

return is_array($lookups) ? $lookups : null;
}

/**
* @param list<string> $slugs
* @param list<string> $failedSlugs
*/
private function recordLookups(array $slugs, array $failedSlugs): void
{
$previous = self::lastLookups() ?? [];
$now = now()->toIso8601String();
$lookups = [];
foreach ($slugs as $slug) {
if (! in_array($slug, $failedSlugs, true)) {
$lookups[$slug] = $now;
} elseif (isset($previous[$slug])) {
$lookups[$slug] = $previous[$slug];
}
}

app(Settings::class)->put(self::LOOKUPS_SETTING, $lookups);
}

/**
* Collect unique plugin directory-slugs from every site's companion
* snapshot. Companion stores slugs as "directory/file.php"; we strip
Expand Down
84 changes: 83 additions & 1 deletion app/Support/Mail/BrandLogo.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

namespace App\Support\Mail;

use App\Services\Companion\CompanionBrandingManager;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Throwable;

Expand All @@ -14,9 +16,20 @@
*/
class BrandLogo
{
/** Raw image bytes, or null when the URL is unusable or the fetch fails. */
/**
* Raw image bytes, or null when the URL is unusable or the fetch fails.
*
* A logo uploaded in Settings lives on Control's public disk, whose URL
* is often relative ("/storage/branding/…") or on Control's own host,
* which clients can't reach: that one is read from the disk instead.
*/
public static function bytes(string $url): ?string
{
$local = self::fromPublicDisk($url);
if ($local !== null) {
return $local;
}

if (! Str::startsWith($url, ['https://', 'http://'])) {
return null;
}
Expand All @@ -35,4 +48,73 @@ public static function bytes(string $url): ?string

return $encoded !== '' ? (base64_decode($encoded, true) ?: null) : null;
}

/** The image's MIME type, for embedding it ("image/png" when unknown). */
public static function mimeType(string $bytes): string
{
try {
$type = (new \finfo(FILEINFO_MIME_TYPE))->buffer($bytes);
} catch (Throwable) {
$type = false;
}

return is_string($type) && str_starts_with($type, 'image/') ? $type : 'image/png';
}

/**
* Whether an email may link to $url as a remote image: an absolute
* http(s) URL that isn't on Control's own (private) host.
*/
public static function linkable(string $url): bool
{
$host = parse_url($url, PHP_URL_HOST);

return Str::startsWith($url, ['https://', 'http://']) && is_string($host)
&& $host !== parse_url((string) config('app.url'), PHP_URL_HOST)
&& self::publicDiskPath($url) === null;
}

private static function fromPublicDisk(string $url): ?string
{
$path = self::publicDiskPath($url);
if ($path === null) {
return null;
}

try {
$disk = Storage::disk(CompanionBrandingManager::LOGO_DISK);
$bytes = $disk->exists($path) ? $disk->get($path) : null;
} catch (Throwable) {
return null;
}

return is_string($bytes) && $bytes !== '' ? $bytes : null;
}

/** The path on the public disk that $url points at, or null when it's elsewhere. */
private static function publicDiskPath(string $url): ?string
{
if ($url === '') {
return null;
}

try {
$prefix = rtrim(Storage::disk(CompanionBrandingManager::LOGO_DISK)->url(''), '/').'/';
} catch (Throwable) {
return null;
}

$path = parse_url($url, PHP_URL_PATH);
$prefixPath = parse_url($prefix, PHP_URL_PATH) ?: $prefix;
$sameHost = ! Str::startsWith($url, ['https://', 'http://']) || Str::startsWith($url, $prefix)
|| parse_url($url, PHP_URL_HOST) === parse_url((string) config('app.url'), PHP_URL_HOST);

if (! $sameHost || ! is_string($path) || ! Str::startsWith($path, $prefixPath)) {
return null;
}

$relative = ltrim(Str::after($path, $prefixPath), '/');

return $relative !== '' && ! str_contains($relative, '..') ? rawurldecode($relative) : null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* The database half of a combined (SpinupWP) backup, so client reports
* can compare it with the database's size: the combined size hides a
* broken dump behind the files. Filled for rows still in the bucket by
* the next clockwork:record-site-backups run.
*/
public function up(): void
{
Schema::table('site_backups', function (Blueprint $table) {
$table->unsignedBigInteger('database_bytes')->nullable()->after('bytes');
});
}

public function down(): void
{
Schema::table('site_backups', function (Blueprint $table) {
$table->dropColumn('database_bytes');
});
}
};
18 changes: 18 additions & 0 deletions modules/BillCom/src/CarePlanSyncService.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Modules\BillCom;

use App\Models\ActionLog;
use App\Models\Site;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
Expand Down Expand Up @@ -212,7 +213,24 @@ public function sync(int $windowDays, string $itemRegex, bool $dryRun = false):
'reason' => $domainHit ? 'domain_match' : 'customer_match',
]);
} else {
$previous = (bool) $site->care_plan_enabled;
$site->forceFill(['care_plan_enabled' => $shouldBeOnPlan])->save();
// Logged like the site page's toggle, so client reports can
// tell when coverage began or ended. Written directly rather
// than through ActionLogger, which would mirror a billing
// change into the client's own wp-admin activity log.
ActionLog::query()->create([
'site_id' => $site->id,
'action_type' => ActionLog::TYPE_CARE_PLAN_TOGGLED,
'target' => $shouldBeOnPlan ? 'on' : 'off',
'summary' => $shouldBeOnPlan
? "Marked {$site->domain} as on a care plan (Bill.com sync)."
: "Marked {$site->domain} as NOT on a care plan (Bill.com sync).",
'details' => ['previous' => $previous, 'now' => $shouldBeOnPlan, 'source' => 'bill_com', 'reason' => $domainHit ? 'domain_match' : 'customer_match'],
'ok' => true,
'actor' => 'scheduled',
'ran_at' => now(),
]);
}

$shouldBeOnPlan ? $stats['sites_set_true']++ : $stats['sites_set_false']++;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* A scheduled report stores every recipient comma-joined here; eight or
* so long addresses overflow a varchar(255), and under MySQL's strict
* mode the insert fails, so that site's report crashes every day.
*/
public function up(): void
{
Schema::table('client_reports', function (Blueprint $table) {
$table->text('client_email')->nullable()->change();
});
}

public function down(): void
{
Schema::table('client_reports', function (Blueprint $table) {
$table->string('client_email')->nullable()->change();
});
}
};
Loading
Loading