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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ CSRF_ENABLED=true
XSS_PROTECTION=true
CORS_ENABLED=false

# CSP
CSP_CONNECT_SRC=https://api.github.com
CSP_IMG_SRC=https://img.shields.io

# View
VIEW_ENGINE=twig
TWIG_CACHE=false
Expand Down
34 changes: 34 additions & 0 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,40 @@ SproutPHP includes a built-in CORS middleware, registered globally by default bu

- Only enable CORS for trusted origins in production. Use `*` for development only.

## Content Security Policy (CSP) and External APIs/Images

By default, SproutPHP sets a strict Content Security Policy (CSP) to maximize security:
- Only resources from your own domain are allowed (default-src 'self').
- No external APIs (AJAX/fetch) or images are permitted by default.

### Allowing External APIs (connect-src)
To allow your app to fetch data from external APIs (e.g., GitHub, third-party services), set the `CSP_CONNECT_SRC` variable in your `.env` file:

```
CSP_CONNECT_SRC=https://api.github.com,https://another.api.com
```

This will add the specified domains to the CSP `connect-src` directive, allowing JavaScript to make requests to those APIs.

### Allowing External Images (img-src)
To allow your app to load images from external sources (e.g., shields.io, Gravatar), set the `CSP_IMG_SRC` variable in your `.env` file:

```
CSP_IMG_SRC=https://img.shields.io,https://www.gravatar.com
```

This will add the specified domains to the CSP `img-src` directive, allowing images from those sources.

### Why is CSP strict by default?
- This prevents accidental data leaks and XSS attacks by only allowing resources from your own domain.
- You must explicitly allow any external domains you trust for APIs or images.

### Where is this configured?
- See `config/security.php` for how these variables are loaded.
- The CSP header is set in the `XssProtection` middleware.

**Tip:** Only add domains you trust and actually use. Never use `*` in production for these settings.

## CLI Reference

Run `php sprout` for all available commands, including:
Expand Down
32 changes: 26 additions & 6 deletions app/Middlewares/XssProtection.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,41 @@ public function handle(Request $request, callable $next)
$response = $next($request);
return $response;
}

private function getCspPolicy(): string
{
$env = config('app.env', 'local');
$debug = config('app.debug', false);

// Base CSP policy
$basePolicy = "default-src 'self'; object-src 'none';";


// Allow developers to specify connect-src for external APIs via .env (CSP_CONNECT_SRC)
$connectSrcDomains = config('security.csp.connect_src', []);
$connectSrc = '';
if (!empty($connectSrcDomains)) {
$connectSrc = " connect-src 'self'";
foreach ($connectSrcDomains as $domain) {
$connectSrc .= ' ' . $domain;
}
$connectSrc .= ';';
}

// Allow developers to specify img-src for external images via .env (CSP_IMG_SRC)
$imgSrcDomains = config('security.csp.img_src', []);
$imgSrc = '';
if (!empty($imgSrcDomains)) {
$imgSrc = " img-src 'self'";
foreach ($imgSrcDomains as $domain) {
$imgSrc .= ' ' . $domain;
}
$imgSrc .= ';';
}

if ($env === 'local' || $debug) {
// Development: relaxed policy
return $basePolicy . " script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https://img.shields.io;";
return $basePolicy . " script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" . $imgSrc . $connectSrc;
} else {
// Production: strict policy
return $basePolicy . " script-src 'self'; style-src 'self'; img-src 'self';";
return $basePolicy . " script-src 'self'; style-src 'self';" . $imgSrc . $connectSrc;
}
}
}
41 changes: 26 additions & 15 deletions app/Views/Components/footer.twig
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
{# SproutPHP Component: footer.twig #}
<footer class="footer" style="margin-top:2rem; padding:1.5rem 0; border-top:1px solid #eee; text-align:center; font-size:0.95rem; color:#888;">
<div style="margin-bottom:0.5rem;">
{% if config('app.env') == 'local' or config('app.debug') %}
<a href="/security-test" hx-get="/security-test" hx-target="#main-content" hx-push-url="true">Security Test</a> |
<a href="/debug-config" hx-get="/debug-config" hx-target="#main-content" hx-push-url="true">Debug Config</a> |
<a href="/config-test" hx-get="/config-test" hx-target="#main-content" hx-push-url="true">Config Test</a> |
<a href="/envtest" hx-get="/envtest" hx-target="#main-content" hx-push-url="true">Env Test</a>
{% endif %}
</div>
<div>
SproutPHP {{ getLatestRelease() }} &mdash; <span style="text-transform:capitalize">{{ config('app.env', 'local') }}</span> environment
</div>
<div style="margin-top:0.5rem;">
&copy; {{ "now"|date("Y") }} <a href="https://github.com/yanikkumar" target="_blank">Yanik Kumar</a> &mdash; <a href="https://github.com/SproutPHP/framework" target="_blank">GitHub</a>
</div>
</footer>
<div style="margin-bottom:0.5rem;">
{% if config('app.env') == 'local' or config('app.debug') %}
<a href="/security-test" hx-get="/security-test" hx-target="#main-content" hx-push-url="true">Security Test</a>
|
<a href="/debug-config" hx-get="/debug-config" hx-target="#main-content" hx-push-url="true">Debug Config</a>
|
<a href="/config-test" hx-get="/config-test" hx-target="#main-content" hx-push-url="true">Config Test</a>
|
<a href="/envtest" hx-get="/envtest" hx-target="#main-content" hx-push-url="true">Env Test</a>
{% endif %}
</div>
<div class="footer-follow">
<span>Follow us:</span>
<a href="https://github.com/SproutPHP" target="_blank" rel="noopener" aria-label="GitHub">
<svg width="24" height="24" viewbox="0 0 24 24" fill="currentColor" style="vertical-align:middle;"><path d="M12 2C6.477 2 2 6.484 2 12.021c0 4.428 2.865 8.184 6.839 9.504.5.092.682-.217.682-.482 0-.237-.009-.868-.014-1.703-2.782.605-3.369-1.342-3.369-1.342-.454-1.154-1.11-1.462-1.11-1.462-.908-.62.069-.608.069-.608 1.004.07 1.532 1.032 1.532 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.339-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.987 1.029-2.686-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.025A9.564 9.564 0 0 1 12 6.844c.85.004 1.705.115 2.504.337 1.909-1.295 2.748-1.025 2.748-1.025.546 1.378.202 2.397.1 2.65.64.699 1.028 1.593 1.028 2.686 0 3.847-2.338 4.695-4.566 4.944.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.744 0 .267.18.579.688.481C19.138 20.203 22 16.447 22 12.021 22 6.484 17.523 2 12 2z"/></svg>
</a>
<a href="https://twitter.com/SproutPHP" target="_blank" rel="noopener" aria-label="Twitter">
<svg width="24" height="24" viewbox="0 0 24 24" fill="currentColor" style="vertical-align:middle;"><path d="M17.53 2H21.5L14.36 10.09L22.75 22H15.97L10.98 15.18L5.29 22H1.32L8.87 13.36L1 2H7.97L12.54 8.22L17.53 2ZM16.32 20H18.34L7.75 3.97H5.59L16.32 20Z"/></svg>
</a>
</div>
<div style="margin-top: 0.5em; font-size: 0.98em; color: var(--muted)">
&copy;
{{ "now"|date("Y") }}
SproutPHP. All rights reserved.
</div>
</footer>
67 changes: 34 additions & 33 deletions app/Views/Components/navbar.twig
Original file line number Diff line number Diff line change
@@ -1,41 +1,27 @@
{# SproutPHP Component: navbar.twig #}
<div class="component navbar">
<header>
<a href="/" class="logo-row">
<img src="{{ assets('img/logo.png') }}" alt="SproutPHP Logo" class="logo-img"/>
<span class="brand">SproutPHP</span>
</a>
<nav>
<ul>
<li>
<a href="/" hx-boost="false" target="_self" style="text-decoration: none; color: #ffffff;">
<img src="{{ assets('img/logo.png') }}" alt="SproutPHP Logo" width="32" height="32" style="vertical-align:middle;"/>
<span style="font-weight:bold;font-size:1.2rem;vertical-align:middle;">SproutPHP</span>
</a>
</li>
</ul>
<ul>
<li>
<a href="https://github.com/SproutPHP/framework/blob/development/README.md">About</a>
</li>
<li>
<a href="https://github.com/SproutPHP/framework/blob/development/DOCS.md">Docs</a>
</li>
<li>
<a href="https://github.com/sponsors/yanikkumar"><img src="https://img.shields.io/badge/Sponsor%20Creator-%E2%9D%A4%EF%B8%8F-pink?logo=github-sponsors"/></a>
</li>
<li>
<a href="#"><img src="https://img.shields.io/github/stars/sproutphp/framework?style=social&link=https%3A%2F%2Fgithub.com%2FSproutPHP%2Fframework"/></a>
</li>
<li>
<button id="theme-toggle-btn" aria-label="Toggle dark/light mode" style="background:none;border:none;cursor:pointer;font-size:1.5rem;">
<span id="theme-icon">☀️</span>
</button>
</li>
</ul>
<a href="https://sproutphp.github.io/about.html" class="nav-link" target="_blank">About</a>
<a href="https://sproutphp.github.io/documentation/" class="nav-link" target="_blank">Docs</a>
<a href="https://github.com/sponsors/yanikkumar" class="sponsor-btn" target="_blank" rel="noopener">&#10084; Sponsor Creator</a>
<a href="https://github.com/SproutPHP/framework" class="stars-btn" target="_blank" rel="noopener">
<svg height="18" viewbox="0 0 16 16" fill="currentColor" style="vertical-align: middle">
<path d="M8 12.027l-3.717 2.21.711-4.15-3.02-2.944 4.166-.605L8 2.5l1.86 3.99 4.166.605-3.02 2.944.711 4.15z"></path>
</svg>
Stars
<span id="star-count">...</span>
</a>
<button class="toggle-btn" id="theme-toggle" title="Toggle dark/light mode">🌙</button>
</nav>
<hr/>
</div>

</header>
<script>
(function () {
const themeBtn = document.getElementById('theme-toggle-btn');
const themeIcon = document.getElementById('theme-icon');
const themeBtn = document.getElementById('theme-toggle');
const themeIcon = themeBtn;
const html = document.documentElement;
if (! themeBtn || ! themeIcon)
return;
Expand All @@ -59,4 +45,19 @@ localStorage.setItem('theme', newTheme);
themeIcon.textContent = newTheme === 'dark' ? '🌙' : '☀️';
});
})();
// Fetch GitHub star count and update #star-count
(function () {
const starCountSpan = document.getElementById('star-count');
if (! starCountSpan)
return;



fetch('https://api.github.com/repos/SproutPHP/framework').then(response => response.json()).then(data => {
if (data.stargazers_count !== undefined) {
starCountSpan.textContent = data.stargazers_count;
}
}).catch(() => { /* ignore errors, keep default */
});
})();
</script>
127 changes: 77 additions & 50 deletions app/Views/home.twig
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,29 @@
{% endblock %}

{% block content %}
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;margin-top:2rem;">
<img src="{{ assets('img/logo.png') }}" alt="SproutPHP Logo" width="64" height="64" style="margin-bottom:1rem;"/>
<h1 style="font-size:2.2rem;margin-bottom:0.5rem;">{{ config('app.name', 'SproutPHP') }}</h1>
<div style="color:#888;font-size:1.1rem;margin-bottom:1.5rem;">
<div>
<img src="{{ assets('img/logo.png') }}" alt="SproutPHP Logo" class="main-logo"/>
<h1 class="main-title">{{ config('app.name', 'SproutPHP') }}</h1>
<div class="main-version">
<span>{{ getLatestRelease }}</span>
&mdash;
<span style="text-transform:capitalize">{{ config('app.env', 'local') }}</span>
environment
</div>
<p style="font-size:1.15rem;max-width:500px;margin-bottom:2rem;">Welcome to
<p class="main-desc">Welcome to
<strong>SproutPHP</strong>, the seed-to-plant minimal PHP framework 🌱<br>HTMX-ready, JS-optional, and developer-happy.</p>
<div style="display:flex;gap:1.5rem;margin-bottom:2rem;flex-wrap:wrap;justify-content:center;">
<a href="https://sproutphp.dev/docs" class="contrast" style="padding:0.7rem 1.5rem;border-radius:6px;text-decoration:none;">📖 Documentation</a>
<a href="https://github.com/SproutPHP/framework" class="contrast" style="padding:0.7rem 1.5rem;border-radius:6px;text-decoration:none;">🌱 GitHub</a>
<a href="https://github.com/sponsors/yanikkumar" class="contrast" style="padding:0.7rem 1.5rem;border-radius:6px;text-decoration:none;">❤️ Sponsor</a>
<div class="main-links">
<a href="https://sproutphp.github.io/documentation">📖 Documentation</a>
<a href="https://github.com/SproutPHP/framework">🌱 GitHub</a>
<a href="https://github.com/sponsors/yanikkumar">❤️ Sponsor</a>
</div>
<button
id="sprout-btn"
class="contrast"
hx-get="/home"
hx-target="#result"
hx-swap="innerHTML"
style="position:relative; overflow:hidden; margin-bottom:1.5rem;"
>
<span id="sprout-seed" style="display:inline-block; transition:transform 0.5s;">🫘</span>
<span id="sprout-label" style="margin-left:0.5em;">Click to Sprout</span>
</button>
<div id="result" style="margin-top: 1rem;"></div>
<div class="sprout-btn-row">
<button id="sprout-btn" class="sprout-btn" hx-get="/home" hx-target="#result" hx-swap="innerHTML">
<span id="sprout-seed">🫘</span>
<span id="sprout-label">Click to Sprout</span>
</button>
</div>
<div id="result"></div>
<!-- main_content block is used for fragment injection by render_fragment_or_full helper -->
<div id="main-content">
{% if main_content is defined %}
Expand All @@ -40,36 +35,68 @@
</div>
</div>
<style>
#sprout-btn.sprout-animating #sprout-seed {
animation: sprout-wiggle 0.7s infinite alternate;
}
@keyframes sprout-wiggle {
0% { transform: scale(1) rotate(-10deg);}
100% { transform: scale(1.3) rotate(10deg);}
}
#sprout-btn.sprout-grown #sprout-seed {
animation: sprout-grow 0.7s forwards;
}
@keyframes sprout-grow {
0% { transform: scale(1); }
80% { transform: scale(1.7) rotate(-10deg);}
100% { transform: scale(1.3) rotate(0deg);}
}
@media(max-width: 600px) {
.main-title {
font-size: 2rem;
}
.main-logo {
width: 3.2rem;
height: 3.2rem;
}
.main-links {
flex-direction: column;
gap: 0.8em;
}
.sprout-btn {
width: 100%;
justify-content: center;
}
}
@media(max-width: 700px) {
header {
flex-direction: column;
}
}
#sprout-btn.sprout-animating #sprout-seed {
animation: sprout-wiggle 0.7s infinite alternate;
}
@keyframes sprout-wiggle {
0% {
transform: scale(1) rotate(-10deg);
}
100% {
transform: scale(1.3) rotate(10deg);
}
}
#sprout-btn.sprout-grown #sprout-seed {
animation: sprout-grow 0.7s forwards;
}
@keyframes sprout-grow {
0% {
transform: scale(1);
}
80% {
transform: scale(1.7) rotate(-10deg);
}
100% {
transform: scale(1.3) rotate(0deg);
}
}
</style>
<script>
const btn = document.getElementById('sprout-btn');
const seed = document.getElementById('sprout-seed');
btn.addEventListener('click', function() {
btn.classList.add('sprout-animating');
seed.textContent = '🫘';
});
// Listen for any HTMX request completion
document.body.addEventListener('htmx:afterRequest', function(evt) {
if (btn && seed && btn.classList.contains('sprout-animating')) {
btn.classList.remove('sprout-animating');
btn.classList.add('sprout-grown');
seed.textContent = '🌱';
}
});
const btn = document.getElementById('sprout-btn');
const seed = document.getElementById('sprout-seed');
btn.addEventListener('click', function () {
btn.classList.add('sprout-animating');
seed.textContent = '🫘';
});
// Listen for any HTMX request completion
document.body.addEventListener('htmx:afterRequest', function (evt) {
if (btn && seed && btn.classList.contains('sprout-animating')) {
btn.classList.remove('sprout-animating');
btn.classList.add('sprout-grown');
seed.textContent = '🌱';
}
});
</script>
{% endblock %}
Loading