Skip to content

Commit a54d0d4

Browse files
author
Septiyan Nariyanto
committed
feat: scaffold safe staging guard plugin
0 parents  commit a54d0d4

12 files changed

Lines changed: 686 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- name: Setup PHP
15+
uses: shivammathur/setup-php@v2
16+
with:
17+
php-version: '7.4'
18+
- name: Run tests
19+
run: php tests/run.php
20+
- name: PHP syntax check
21+
run: find . -path ./.git -prune -o -name '*.php' -print0 | xargs -0 -n1 php -l
22+
- name: Build release ZIP
23+
run: bash scripts/build-release.sh

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
dist/
2+
.DS_Store

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Safe Staging Guard
2+
3+
Prevent staging-site accidents with visible environment labels, noindex controls, and safe email handling.
4+
5+
## Why
6+
7+
Staging sites are useful, but they can accidentally:
8+
9+
- get indexed by search engines,
10+
- send real emails to users,
11+
- look too similar to production,
12+
- confuse support and testing workflows.
13+
14+
Safe Staging Guard adds a small safety layer for WordPress staging/local environments.
15+
16+
## v0.1.0 features
17+
18+
- Admin bar environment label.
19+
- Frontend staging banner for logged-in users.
20+
- Noindex/nofollow for local/staging environments.
21+
- Email block or redirect mode.
22+
- Production mode disables email interception and noindex.
23+
24+
## Development
25+
26+
Run tests:
27+
28+
```bash
29+
php tests/run.php
30+
```
31+
32+
Run PHP syntax checks:
33+
34+
```bash
35+
find . -path ./.git -prune -o -name '*.php' -print0 | xargs -0 -n1 php -l
36+
```
37+
38+
Build release ZIP:
39+
40+
```bash
41+
bash scripts/build-release.sh
42+
```
43+
44+
## Safety notes
45+
46+
- No tracking by default.
47+
- No external network calls.
48+
- No destructive operations.
49+
- Production mode keeps emails and robots behavior untouched.
50+
51+
## License
52+
53+
GPL-2.0-or-later.

docs/roadmap.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Safe Staging Guard Roadmap
2+
3+
## v0.1.0
4+
5+
- Environment setting: Local / Staging / Production.
6+
- Admin bar label.
7+
- Frontend staging banner for logged-in users.
8+
- Noindex/nofollow for local/staging.
9+
- Email block/redirect/allow mode.
10+
11+
## Later ideas
12+
13+
- Payment gateway staging warnings.
14+
- Webhook safety checks.
15+
- WP-CLI status command.
16+
- Production-looking domain warning.
17+
- Exportable safety report.

readme.txt

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
=== Safe Staging Guard ===
2+
Contributors: nariyanto
3+
Tags: staging, noindex, email, development, safety
4+
Requires at least: 6.0
5+
Tested up to: 7.0
6+
Requires PHP: 7.4
7+
Stable tag: 0.1.0
8+
License: GPLv2 or later
9+
License URI: https://www.gnu.org/licenses/gpl-2.0.html
10+
11+
Prevent staging-site accidents with visible environment labels, noindex controls, and safe email handling.
12+
13+
== Description ==
14+
15+
Safe Staging Guard helps WordPress site owners and developers avoid common staging-site mistakes.
16+
17+
Version 0.1.0 focuses on simple, review-friendly safety controls:
18+
19+
* Admin bar environment label for administrators.
20+
* Optional frontend staging banner for logged-in users.
21+
* Noindex/nofollow robots control for local and staging environments.
22+
* Email safety mode to block or redirect outgoing staging emails.
23+
* Production mode automatically disables noindex and email interception.
24+
25+
The plugin does not send data to external services and does not collect analytics.
26+
27+
== Installation ==
28+
29+
1. Upload the plugin folder to `/wp-content/plugins/`.
30+
2. Activate Safe Staging Guard from the Plugins screen.
31+
3. Open Settings > Safe Staging Guard.
32+
4. Select the environment and safety options.
33+
34+
== Frequently Asked Questions ==
35+
36+
= Does this plugin change production email delivery? =
37+
38+
If Environment is set to Production, Safe Staging Guard does not block or redirect outgoing email.
39+
40+
= Does this plugin send data externally? =
41+
42+
No. Version 0.1.0 does not send data to external services and does not collect analytics.
43+
44+
= Is this a replacement for a proper staging environment? =
45+
46+
No. It is a helper layer for visibility and safety. You should still keep staging and production separate.
47+
48+
== Screenshots ==
49+
50+
1. Safe Staging Guard settings page.
51+
2. Admin bar environment label.
52+
3. Frontend staging banner.
53+
54+
== Changelog ==
55+
56+
= 0.1.0 =
57+
* Initial release with environment label, staging banner, noindex control, and email safety mode.

safe-staging-guard.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
/**
3+
* Plugin Name: Safe Staging Guard
4+
* Plugin URI: https://github.com/nariyanto/wp-safe-staging-guard
5+
* Description: Prevent staging-site accidents with visible environment labels, noindex controls, and safe email handling.
6+
* Version: 0.1.0
7+
* Requires at least: 6.0
8+
* Requires PHP: 7.4
9+
* Author: Septiyan Nariyanto
10+
* Author URI: https://nariyanto.id
11+
* License: GPL-2.0-or-later
12+
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
13+
* Text Domain: safe-staging-guard
14+
* Domain Path: /languages
15+
*/
16+
17+
declare(strict_types=1);
18+
19+
if (!defined('ABSPATH')) {
20+
exit;
21+
}
22+
23+
require_once __DIR__ . '/src/EnvironmentSettings.php';
24+
require_once __DIR__ . '/src/EmailSafety.php';
25+
require_once __DIR__ . '/src/NoindexPolicy.php';
26+
require_once __DIR__ . '/src/Plugin.php';
27+
28+
add_action('plugins_loaded', static function (): void {
29+
$plugin = new \Nariyanto\SafeStagingGuard\Plugin();
30+
$plugin->register();
31+
});

scripts/build-release.sh

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4+
DIST="$ROOT/dist"
5+
BUILD="$DIST/safe-staging-guard"
6+
ZIP="$DIST/safe-staging-guard.zip"
7+
rm -rf "$DIST"
8+
mkdir -p "$BUILD"
9+
rsync -a "$ROOT/" "$BUILD/" \
10+
--exclude='.git' \
11+
--exclude='.github' \
12+
--exclude='dist' \
13+
--exclude='tests' \
14+
--exclude='docs' \
15+
--exclude='scripts' \
16+
--exclude='.gitignore'
17+
if command -v zip >/dev/null 2>&1; then
18+
(cd "$DIST" && zip -qr "$ZIP" safe-staging-guard)
19+
else
20+
python3 - <<'PY' "$DIST" "$ZIP"
21+
import os
22+
import sys
23+
import zipfile
24+
from pathlib import Path
25+
26+
dist = Path(sys.argv[1])
27+
zip_path = Path(sys.argv[2])
28+
root = dist / 'safe-staging-guard'
29+
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as archive:
30+
for path in sorted(root.rglob('*')):
31+
if path.is_file():
32+
archive.write(path, path.relative_to(dist).as_posix())
33+
PY
34+
fi
35+
echo "$ZIP"

src/EmailSafety.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Nariyanto\SafeStagingGuard;
6+
7+
final class EmailSafety
8+
{
9+
/**
10+
* @param array<string, mixed> $args
11+
* @return array<string, mixed>
12+
*/
13+
public static function apply(array $args, EnvironmentSettings $settings): array
14+
{
15+
$args += [
16+
'to' => [],
17+
'subject' => '',
18+
'message' => '',
19+
'headers' => [],
20+
'attachments' => [],
21+
];
22+
23+
if ($settings->isProduction() || $settings->emailMode() === 'allow') {
24+
$args['blocked'] = false;
25+
return $args;
26+
}
27+
28+
$originalRecipients = self::recipientList($args['to']);
29+
30+
if ($settings->emailMode() === 'redirect' && $settings->redirectEmail() !== '') {
31+
$args['to'] = [$settings->redirectEmail()];
32+
$args['message'] = self::prependAuditNote((string)$args['message'], $originalRecipients, $settings);
33+
$args['blocked'] = false;
34+
return $args;
35+
}
36+
37+
$args['to'] = [];
38+
$args['subject'] = 'Safe Staging Guard blocked a staging email.';
39+
$args['message'] = self::prependAuditNote('Email delivery was blocked by Safe Staging Guard.', $originalRecipients, $settings);
40+
$args['headers'] = [];
41+
$args['attachments'] = [];
42+
$args['blocked'] = true;
43+
return $args;
44+
}
45+
46+
/** @param mixed $to */
47+
private static function recipientList($to): string
48+
{
49+
if (is_array($to)) {
50+
return implode(', ', array_map('strval', $to));
51+
}
52+
return (string)$to;
53+
}
54+
55+
private static function prependAuditNote(string $message, string $originalRecipients, EnvironmentSettings $settings): string
56+
{
57+
$note = "Safe Staging Guard environment: " . $settings->environment() . "\n";
58+
$note .= "Original recipients: " . ($originalRecipients !== '' ? $originalRecipients : '(none)') . "\n\n";
59+
return $note . $message;
60+
}
61+
}

src/EnvironmentSettings.php

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Nariyanto\SafeStagingGuard;
6+
7+
final class EnvironmentSettings
8+
{
9+
/** @var array<string, mixed> */
10+
private array $values;
11+
12+
/** @param array<string, mixed> $values */
13+
private function __construct(array $values)
14+
{
15+
$this->values = $values;
16+
}
17+
18+
/** @param array<string, mixed> $raw */
19+
public static function fromArray(array $raw): self
20+
{
21+
$environment = self::choice((string)($raw['environment'] ?? 'staging'), ['local', 'staging', 'production'], 'staging');
22+
$emailMode = self::choice((string)($raw['email_mode'] ?? 'block'), ['block', 'redirect', 'allow'], 'block');
23+
$redirectEmail = strtolower(trim((string)($raw['redirect_email'] ?? '')));
24+
if (!filter_var($redirectEmail, FILTER_VALIDATE_EMAIL)) {
25+
$redirectEmail = '';
26+
}
27+
28+
$noindex = self::boolValue($raw['noindex_enabled'] ?? true);
29+
if ($environment === 'production') {
30+
$noindex = false;
31+
}
32+
33+
return new self([
34+
'environment' => $environment,
35+
'show_admin_bar_label' => self::boolValue($raw['show_admin_bar_label'] ?? true),
36+
'show_frontend_banner' => self::boolValue($raw['show_frontend_banner'] ?? true),
37+
'noindex_enabled' => $noindex,
38+
'email_mode' => $emailMode,
39+
'redirect_email' => $redirectEmail,
40+
]);
41+
}
42+
43+
public static function defaults(): self
44+
{
45+
return self::fromArray([]);
46+
}
47+
48+
/** @return array<string, mixed> */
49+
public function toArray(): array
50+
{
51+
return $this->values;
52+
}
53+
54+
public function environment(): string
55+
{
56+
return (string)$this->values['environment'];
57+
}
58+
59+
public function showAdminBarLabel(): bool
60+
{
61+
return (bool)$this->values['show_admin_bar_label'];
62+
}
63+
64+
public function showFrontendBanner(): bool
65+
{
66+
return (bool)$this->values['show_frontend_banner'];
67+
}
68+
69+
public function noindexEnabled(): bool
70+
{
71+
return (bool)$this->values['noindex_enabled'];
72+
}
73+
74+
public function emailMode(): string
75+
{
76+
return (string)$this->values['email_mode'];
77+
}
78+
79+
public function redirectEmail(): string
80+
{
81+
return (string)$this->values['redirect_email'];
82+
}
83+
84+
public function isProduction(): bool
85+
{
86+
return $this->environment() === 'production';
87+
}
88+
89+
/** @param array<int, string> $allowed */
90+
private static function choice(string $value, array $allowed, string $default): string
91+
{
92+
$value = strtolower(trim($value));
93+
return in_array($value, $allowed, true) ? $value : $default;
94+
}
95+
96+
/** @param mixed $value */
97+
private static function boolValue($value): bool
98+
{
99+
if (is_bool($value)) {
100+
return $value;
101+
}
102+
if (is_numeric($value)) {
103+
return (int)$value === 1;
104+
}
105+
$normalized = strtolower(trim((string)$value));
106+
return in_array($normalized, ['1', 'true', 'yes', 'on'], true);
107+
}
108+
}

0 commit comments

Comments
 (0)