• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

daycry / maintenancemode / 25548350420

08 May 2026 09:28AM UTC coverage: 77.17% (+1.7%) from 75.492%
25548350420

push

github

daycry
docs: full docs/ tree and modernised README

README rewritten as a tight landing page (~115 lines, was 511):
- Three badge sections (Package / Quality / Community) matching the
  daycry/* convention, including per-workflow status badges for PHPUnit,
  PHPStan, Psalm, Rector, Code Style, CodeQL plus Coveralls.
- Highlights, install, quick start, plus a "Quality bar" table linking to
  each workflow file. Everything else is one click away in docs/.

New docs/ tree (16 files):
- README.md (index), installation, configuration, commands, bypass,
  filters-and-events, storage-drivers, architecture, security,
  troubleshooting, faq, upgrade, roadmap.
- examples/: basic-maintenance, scheduled-window, api-json-response,
  webhook-notifications (all functional today), multi-tenant and
  cdn-cloudflare (planned, with workarounds).

The previous README's misleading content has been removed:
- "v2.0.0 Latest" claim and the v1/v2 changelog block (superseded by
  CHANGELOG.md).
- setcookie('maintenance_bypass', 'your-secret-key', ...) snippet (the
  legacy cookie-bypass logic was effectively broken; v3 uses a 32-byte
  random cookie_value compared with hash_equals).
- mm:migrate --to=cache flag that never existed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

649 of 841 relevant lines covered (77.17%)

31.49 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.72
/src/Commands/Status.php
1
<?php
2

3
namespace Daycry\Maintenance\Commands;
4

5
use CodeIgniter\CLI\BaseCommand;
6
use CodeIgniter\CLI\CLI;
7
use Config\Services;
8
use Daycry\Maintenance\Libraries\IpUtils;
9
use Daycry\Maintenance\Libraries\MaintenanceStorage;
10
use Throwable;
11

12
class Status extends BaseCommand
13
{
14
    protected $group       = 'Maintenance Mode';
15
    protected $name        = 'mm:status';
16
    protected $description = 'Display the maintenance mode status';
17
    protected $usage       = 'mm:status [--show-public-ip]';
18
    protected $arguments   = [];
19
    protected $options     = [
20
        '-show-public-ip' => 'Try to detect the public IP via an external service (timeouts: 2s connect / 3s total)',
21
    ];
22

23
    public function run(array $params)
24
    {
25
        helper('setting');
69✔
26

27
        // Load configuration and storage
28
        $maintenanceConfig = config('Maintenance');
69✔
29
        $storage           = new MaintenanceStorage($maintenanceConfig);
69✔
30

31
        if (! $storage->isActive()) {
69✔
32
            CLI::newLine(1);
3✔
33
            CLI::write('✅ **** Application is LIVE ****', 'green');
3✔
34
            CLI::write('Users can access the application normally.', 'green');
3✔
35
            CLI::newLine(1);
3✔
36

37
            // Show storage method info
38
            $storageMethod = $maintenanceConfig->useCache ? 'Cache' : 'File System';
3✔
39
            CLI::write("Storage method: {$storageMethod}", 'cyan');
3✔
40
            CLI::newLine(1);
3✔
41

42
            return;
3✔
43
        }
44

45
        $data = $storage->getData();
68✔
46

47
        if ($data === null) {
68✔
48
            CLI::newLine(1);
×
49
            CLI::error('⚠️  Maintenance data is invalid or corrupted.');
×
50
            CLI::newLine(1);
×
51

52
            return;
×
53
        }
54

55
        CLI::newLine(1);
68✔
56
        CLI::error('🔧 Application is in MAINTENANCE MODE');
68✔
57

58
        // Show storage method info
59
        $storageMethod = $maintenanceConfig->useCache ? 'Cache' : 'File System';
68✔
60
        CLI::write("Storage method: {$storageMethod}", 'cyan');
68✔
61

62
        // Check current bypass status
63
        $this->showCurrentBypassStatus($maintenanceConfig, $data);
68✔
64

65
        CLI::newLine(1);
68✔
66

67
        // Main information table
68
        $thead = ['Property', 'Value'];
68✔
69
        $tbody = [];
68✔
70

71
        foreach ($data as $key => $value) {
68✔
72
            switch ($key) {
73
                case 'allowed_ips':
68✔
74
                case 'secret_key':
68✔
75
                    // These will be shown separately
76
                    break;
68✔
77

78
                case 'time':
68✔
79
                    $tbody[] = ['Started', date('Y-m-d H:i:s', $value)];
68✔
80
                    break;
68✔
81

82
                case 'estimated_end':
68✔
83
                    if (isset($value)) {
68✔
84
                        $endTime      = date('Y-m-d H:i:s', $value);
66✔
85
                        $remaining    = $value - time();
66✔
86
                        $remainingStr = $remaining > 0 ?
66✔
87
                            sprintf('%d minutes remaining', ceil($remaining / 60)) :
66✔
88
                            'Overdue';
×
89
                        $tbody[] = ['Estimated End', $endTime . " ({$remainingStr})"];
66✔
90
                    }
91
                    break;
68✔
92

93
                case 'duration_minutes':
68✔
94
                    $tbody[] = ['Duration', $value . ' minutes'];
68✔
95
                    break;
68✔
96

97
                case 'secret_bypass':
68✔
98
                    $tbody[] = ['Secret Bypass', $value ? 'Enabled' : 'Disabled'];
68✔
99
                    break;
68✔
100

101
                case 'cookie_name':
68✔
102
                    $tbody[] = ['Cookie Name', $value];
68✔
103
                    break;
68✔
104

105
                default:
106
                    $tbody[] = [ucfirst(str_replace('_', ' ', $key)), $value];
68✔
107
            }
108
        }
109

110
        CLI::table($tbody, $thead);
68✔
111

112
        // Show allowed IPs
113
        if (isset($data->allowed_ips) && ! empty($data->allowed_ips)) {
68✔
114
            CLI::newLine(1);
67✔
115
            CLI::write('🌐 Allowed IP Addresses:', 'yellow');
67✔
116

117
            $ipThead = ['IP Address', 'Type'];
67✔
118
            $ipTbody = [];
67✔
119

120
            foreach ($data->allowed_ips as $ip) {
67✔
121
                $type = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ? 'IPv4' :
67✔
122
                       (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'IPv6' : 'Invalid');
4✔
123
                $ipTbody[] = [$ip, $type];
67✔
124
            }
125

126
            CLI::table($ipTbody, $ipThead);
67✔
127
        }
128

129
        // Show secret key if enabled
130
        if (isset($data->secret_bypass) && $data->secret_bypass && isset($data->secret_key)) {
68✔
131
            CLI::newLine(1);
19✔
132
            CLI::write('🔑 Secret Bypass Information:', 'yellow');
19✔
133
            CLI::write('   URL: ' . base_url() . '?maintenance_secret=' . $data->secret_key, 'cyan');
19✔
134
        }
135

136
        CLI::newLine(1);
68✔
137
    }
138

139
    /**
140
     * Check and display current bypass status
141
     *
142
     * @param mixed $config
143
     * @param mixed $data
144
     */
145
    private function showCurrentBypassStatus($config, $data): void
146
    {
147
        CLI::newLine(1);
68✔
148
        CLI::write('🔍 Current Bypass Status:', 'yellow');
68✔
149

150
        $bypassMethods = [];
68✔
151
        $currentIP     = $this->getCurrentClientIP();
68✔
152

153
        $secretParam = $this->readGetParam('maintenance_secret');
68✔
154

155
        // Check config secret bypass
156
        if ($config->allowSecretBypass && ! empty($config->secretBypassKey)) {
68✔
157
            if ($secretParam !== '' && hash_equals((string) $config->secretBypassKey, $secretParam)) {
13✔
158
                $bypassMethods[] = '✅ Config Secret (via URL parameter)';
×
159
            } else {
160
                $bypassMethods[] = "🔑 Config Secret available (add ?maintenance_secret={$config->secretBypassKey} to URL)";
13✔
161
            }
162
        }
163

164
        // Check data secret bypass
165
        if (isset($data->secret_bypass) && $data->secret_bypass && isset($data->secret_key)) {
68✔
166
            if ($secretParam !== '' && hash_equals((string) $data->secret_key, $secretParam)) {
19✔
167
                $bypassMethods[] = '✅ Data Secret (via URL parameter)';
×
168
            } else {
169
                $bypassMethods[] = "🔑 Data Secret available (add ?maintenance_secret={$data->secret_key} to URL)";
19✔
170
            }
171
        }
172

173
        // Check IP bypass
174
        if (isset($data->allowed_ips) && ! empty($data->allowed_ips)) {
68✔
175
            $ipUtils = new IpUtils();
67✔
176
            if ($ipUtils->checkIp($currentIP, $data->allowed_ips)) {
67✔
177
                $bypassMethods[] = "✅ IP Address bypass (current IP: {$currentIP})";
47✔
178
            } else {
179
                $bypassMethods[] = "🌐 IP Address bypass configured (current IP {$currentIP} not in allowed list)";
20✔
180
            }
181
        }
182

183
        // Check cookie bypass
184
        if (isset($data->cookie_name) && ! empty($data->cookie_name)) {
68✔
185
            $cookieName    = (string) $data->cookie_name;
67✔
186
            $expectedValue = (string) ($data->cookie_value ?? '');
67✔
187
            $providedValue = $this->readCookie($cookieName);
67✔
188

189
            if ($expectedValue !== '' && $providedValue !== '' && hash_equals($expectedValue, $providedValue)) {
67✔
190
                $bypassMethods[] = '✅ Cookie bypass (active)';
×
191
            } else {
192
                $bypassMethods[] = '🍪 Cookie bypass configured (cookie not set or invalid)';
67✔
193
            }
194
        }
195

196
        // Display results
197
        if (empty($bypassMethods)) {
68✔
198
            CLI::write('   ❌ No bypass methods configured', 'red');
1✔
199
        } else {
200
            foreach ($bypassMethods as $method) {
67✔
201
                CLI::write("   {$method}");
67✔
202
            }
203
        }
204

205
        // Show access status
206
        CLI::newLine(1);
68✔
207
        $this->showAccessStatus($config, $data, $currentIP);
68✔
208
    }
209

210
    /**
211
     * Show current access status for this CLI session
212
     *
213
     * @param mixed $config
214
     * @param mixed $data
215
     * @param mixed $currentIP
216
     */
217
    private function showAccessStatus($config, $data, $currentIP): void
218
    {
219
        CLI::write('🚦 Access Status from CLI:', 'yellow');
68✔
220

221
        // Simulate the same logic as Maintenance::check()
222
        $hasAccess    = false;
68✔
223
        $accessReason = '';
68✔
224

225
        // Check CLI bypass first (always allowed in CLI unless testing)
226
        if (is_cli() && ENVIRONMENT !== 'testing') {
68✔
227
            $hasAccess    = true;
×
228
            $accessReason = 'CLI access (always allowed)';
×
229
        } else {
230
            $secretParam = $this->readGetParam('maintenance_secret');
68✔
231

232
            // Check config secret
233
            if ($config->allowSecretBypass && ! empty($config->secretBypassKey)
68✔
234
                && $secretParam !== ''
68✔
235
                && hash_equals((string) $config->secretBypassKey, $secretParam)) {
68✔
236
                $hasAccess    = true;
×
237
                $accessReason = 'Config secret bypass';
×
238
            }
239

240
            // Check data secret
241
            if (! $hasAccess && isset($data->secret_bypass) && $data->secret_bypass && isset($data->secret_key)
68✔
242
                && $secretParam !== ''
68✔
243
                && hash_equals((string) $data->secret_key, $secretParam)) {
68✔
244
                $hasAccess    = true;
×
245
                $accessReason = 'Data secret bypass';
×
246
            }
247

248
            // Check IP
249
            if (! $hasAccess && isset($data->allowed_ips) && ! empty($data->allowed_ips)) {
68✔
250
                $ipUtils = new IpUtils();
67✔
251
                if ($ipUtils->checkIp($currentIP, $data->allowed_ips)) {
67✔
252
                    $hasAccess    = true;
47✔
253
                    $accessReason = 'IP address bypass';
47✔
254
                }
255
            }
256

257
            // Check cookie
258
            if (! $hasAccess && isset($data->cookie_name) && ! empty($data->cookie_name)) {
68✔
259
                $cookieName    = (string) $data->cookie_name;
20✔
260
                $expectedValue = (string) ($data->cookie_value ?? '');
20✔
261
                $providedValue = $this->readCookie($cookieName);
20✔
262

263
                if ($expectedValue !== '' && $providedValue !== '' && hash_equals($expectedValue, $providedValue)) {
20✔
264
                    $hasAccess    = true;
×
265
                    $accessReason = 'Cookie bypass';
×
266
                }
267
            }
268
        }
269

270
        if ($hasAccess) {
68✔
271
            CLI::write("   ✅ Access ALLOWED: {$accessReason}", 'green');
47✔
272
        } else {
273
            CLI::write('   ❌ Access BLOCKED: No valid bypass method', 'red');
21✔
274
        }
275

276
        CLI::newLine(1);
68✔
277
        CLI::write('💡 Tips:', 'yellow');
68✔
278
        CLI::write('   • Add your IP: php spark mm:down --allow=' . $currentIP, 'cyan');
68✔
279
        CLI::write('   • Use secret: php spark mm:down --secret=your-key', 'cyan');
68✔
280
        CLI::write('   • Access URL: ' . (base_url() ?: 'https://yoursite.com') . '?maintenance_secret=your-key', 'cyan');
68✔
281
    }
282

283
    /**
284
     * Get current client IP (best effort for CLI).
285
     *
286
     * Reads from $_SERVER with type validation. Avoids implicit network calls;
287
     * the public-IP fallback is only attempted when explicitly enabled via the
288
     * --show-public-ip flag.
289
     */
290
    private function getCurrentClientIP(): string
291
    {
292
        $candidates = ['HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'];
68✔
293

294
        foreach ($candidates as $key) {
68✔
295
            $value = $_SERVER[$key] ?? null;
68✔
296

297
            if (! is_string($value) || $value === '') {
68✔
298
                continue;
68✔
299
            }
300

301
            // X-Forwarded-For can be a comma-separated list; take the first hop.
302
            $first = trim(explode(',', $value)[0]);
19✔
303
            if (filter_var($first, FILTER_VALIDATE_IP)) {
19✔
304
                return $first;
19✔
305
            }
306
        }
307

308
        if (CLI::getOption('show-public-ip')) {
49✔
309
            return $this->fetchPublicIp() ?? '127.0.0.1';
×
310
        }
311

312
        return '127.0.0.1';
49✔
313
    }
314

315
    /**
316
     * Fetch the public IP from an external service with strict timeouts.
317
     * Returns null on any failure (timeout, network error, invalid response).
318
     */
319
    private function fetchPublicIp(): ?string
320
    {
321
        try {
322
            $client = Services::curlrequest([
×
323
                'timeout'         => 3,
×
324
                'connect_timeout' => 2,
×
325
                'http_errors'     => false,
×
326
            ]);
×
327
            $response = $client->get('https://api.ipify.org');
×
328
            $body     = trim($response->getBody());
×
329

330
            return filter_var($body, FILTER_VALIDATE_IP) ? $body : null;
×
331
        } catch (Throwable $e) {
×
332
            log_message('warning', 'Failed to fetch public IP: ' . $e->getMessage());
×
333

334
            return null;
×
335
        }
336
    }
337

338
    /**
339
     * Read a string GET parameter without trusting the superglobal type.
340
     */
341
    private function readGetParam(string $key): string
342
    {
343
        $value = $_GET[$key] ?? null;
68✔
344

345
        return is_string($value) ? $value : '';
68✔
346
    }
347

348
    /**
349
     * Read a cookie value as a string without trusting the superglobal type.
350
     */
351
    private function readCookie(string $key): string
352
    {
353
        $value = $_COOKIE[$key] ?? null;
67✔
354

355
        return is_string($value) ? $value : '';
67✔
356
    }
357
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc