Skip to content

Commit 7b04578

Browse files
authored
Merge pull request #73 from codebar-ag/feature-updates
Feature updates
2 parents 72fe622 + 1fe2cba commit 7b04578

27 files changed

Lines changed: 777 additions & 433 deletions

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,17 @@ We have added some blade components which are located in the `resources/views/co
248248
249249
You should include both the blade components in your blade layout files
250250
251+
## Laravel Cloud deployment
252+
253+
When deploying to Laravel Cloud, ensure these environment variables are set for Lighthouse Best Practices (security headers):
254+
255+
- `CSP_ENABLED=true` — enables Content-Security-Policy enforcement via Spatie CSP middleware
256+
- `FPH_ENABLED=true` — enables Permissions-Policy headers
257+
258+
Security headers (HSTS, COOP, `X-Content-Type-Options`, `Referrer-Policy`, `X-Frame-Options`) are applied automatically by `SecurityHeaders` middleware on all web responses.
259+
260+
**Lighthouse note:** Deprecated API warnings for `/cdn-cgi/challenge-platform/scripts/jsd/main.js` come from Cloudflare bot protection injected by Laravel Cloud, not from application code. Run Lighthouse in incognito without browser extensions for accurate scores.
261+
251262
## Cloudinary
252263
253264
Please refer to the respective documentation for the Cloudinary and Cloudinary Nova packages.

app/Actions/ViewDataAction.php

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,16 +81,31 @@ public function contacts(string $locale): object
8181
->orderBy('name')
8282
->get();
8383

84+
$employeeIds = $publishedContacts
85+
->filter(fn (Contact $contact): bool => array_key_exists(
86+
ContactSectionEnum::EMPLOYEES,
87+
$contact->sections ?? []
88+
))
89+
->pluck('id');
90+
8491
return (object) collect([
8592
ContactSectionEnum::EMPLOYEES,
8693
ContactSectionEnum::COLLABORATIONS,
8794
ContactSectionEnum::BOARD_MEMBERS,
88-
])->mapWithKeys(function (string $section) use ($publishedContacts, $locale): array {
95+
])->mapWithKeys(function (string $section) use ($publishedContacts, $locale, $employeeIds): array {
8996
$contacts = $publishedContacts
90-
->filter(function (Contact $contact) use ($section): bool {
97+
->filter(function (Contact $contact) use ($section, $employeeIds): bool {
9198
$sections = $contact->sections ?? [];
9299

93-
return array_key_exists($section, $sections);
100+
if (! array_key_exists($section, $sections)) {
101+
return false;
102+
}
103+
104+
if ($section === ContactSectionEnum::BOARD_MEMBERS && $employeeIds->contains($contact->id)) {
105+
return false;
106+
}
107+
108+
return true;
94109
})
95110
->map(fn (Contact $contact) => ContactDTO::fromModel($contact, $section, $locale));
96111

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace App\Http\Middleware;
4+
5+
use Closure;
6+
use Illuminate\Http\Request;
7+
use Symfony\Component\HttpFoundation\Response;
8+
9+
class SecurityHeaders
10+
{
11+
/**
12+
* @param Closure(Request): (Response) $next
13+
*/
14+
public function handle(Request $request, Closure $next): Response
15+
{
16+
$response = $next($request);
17+
18+
if ($request->secure()) {
19+
$response->headers->set(
20+
'Strict-Transport-Security',
21+
'max-age=31536000; includeSubDomains'
22+
);
23+
}
24+
25+
$response->headers->set('Cross-Origin-Opener-Policy', 'same-origin');
26+
$response->headers->set('X-Content-Type-Options', 'nosniff');
27+
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
28+
$response->headers->set('X-Frame-Options', 'DENY');
29+
30+
return $response;
31+
}
32+
}

app/Security/Presets/MyCspPreset.php

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,44 +6,55 @@
66
use Spatie\Csp\Keyword;
77
use Spatie\Csp\Policy;
88
use Spatie\Csp\Preset;
9+
use Spatie\Csp\Value;
910

1011
class MyCspPreset implements Preset
1112
{
1213
public function configure(Policy $policy): void
1314
{
14-
$policy->add(Directive::BASE, Keyword::SELF);
15-
16-
$policy->add(Directive::DEFAULT, Keyword::SELF);
15+
$cdnHost = parse_url((string) env('AWS_CDN_ENDPOINT', ''), PHP_URL_HOST);
1716

18-
$policy->add(Directive::SCRIPT, [
17+
$scriptSources = array_filter([
1918
Keyword::SELF,
2019
'cdn.usefathom.com',
2120
'cdn-eu.usefathom.com',
21+
$cdnHost ?: null,
2222
]);
2323

24-
$policy->add(Directive::SCRIPT_ELEM, [
25-
Keyword::SELF,
26-
'cdn.usefathom.com',
27-
'cdn-eu.usefathom.com',
28-
]);
24+
$policy->add(Directive::BASE, Keyword::SELF);
2925

30-
$policy->add(Directive::STYLE, [
31-
Keyword::SELF,
32-
Keyword::UNSAFE_INLINE,
33-
]);
26+
$policy->add(Directive::DEFAULT, Keyword::SELF);
27+
28+
$policy->add(Directive::FRAME_ANCESTORS, Keyword::SELF);
29+
30+
$policy->add(Directive::UPGRADE_INSECURE_REQUESTS, Value::NO_VALUE);
31+
32+
$policy->add(Directive::SCRIPT, $scriptSources);
33+
34+
$policy->add(Directive::SCRIPT_ELEM, $scriptSources);
3435

35-
$policy->add(Directive::STYLE_ELEM, [
36+
$styleSources = array_filter([
3637
Keyword::SELF,
3738
Keyword::UNSAFE_INLINE,
39+
$cdnHost ?: null,
3840
]);
3941

42+
$policy->add(Directive::STYLE, $styleSources);
43+
44+
$policy->add(Directive::STYLE_ELEM, $styleSources);
45+
4046
$policy->add(Directive::IMG, [
4147
Keyword::SELF,
4248
'data:',
4349
'res.cloudinary.com',
4450
]);
4551

46-
$policy->add(Directive::FONT, Keyword::SELF);
52+
$fontSources = array_filter([
53+
Keyword::SELF,
54+
$cdnHost ?: null,
55+
]);
56+
57+
$policy->add(Directive::FONT, $fontSources);
4758
$policy->add(Directive::FORM_ACTION, Keyword::SELF);
4859
$policy->add(Directive::MEDIA, Keyword::SELF);
4960
$policy->add(Directive::OBJECT, Keyword::NONE);

app/Support/CloudinaryUrl.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
namespace App\Support;
4+
5+
class CloudinaryUrl
6+
{
7+
private const CLOUDINARY_HOST = 'res.cloudinary.com';
8+
9+
private const UPLOAD_MARKER = '/image/upload/';
10+
11+
public static function src(string $url, int $width): string
12+
{
13+
return self::transform($url, $width);
14+
}
15+
16+
public static function srcset(string $url, int $width): string
17+
{
18+
$oneX = self::transform($url, $width);
19+
$twoX = self::transform($url, $width * 2);
20+
21+
return "{$oneX} {$width}w, {$twoX} ".($width * 2).'w';
22+
}
23+
24+
public static function transform(string $url, int $width): string
25+
{
26+
if (! str_contains($url, self::CLOUDINARY_HOST)) {
27+
return $url;
28+
}
29+
30+
$markerPos = strpos($url, self::UPLOAD_MARKER);
31+
32+
if ($markerPos === false) {
33+
return $url;
34+
}
35+
36+
$base = substr($url, 0, $markerPos + strlen(self::UPLOAD_MARKER));
37+
$rest = substr($url, $markerPos + strlen(self::UPLOAD_MARKER));
38+
$publicId = self::stripExistingTransforms($rest);
39+
$transforms = "w_{$width},h_{$width},c_fill,f_auto,q_auto";
40+
41+
return $base.$transforms.'/'.$publicId;
42+
}
43+
44+
private static function stripExistingTransforms(string $path): string
45+
{
46+
$segments = explode('/', $path);
47+
48+
if (
49+
isset($segments[0])
50+
&& preg_match('/^[a-z0-9_,.-]+$/', $segments[0])
51+
&& preg_match('/[whcfq]_/', $segments[0])
52+
) {
53+
array_shift($segments);
54+
}
55+
56+
return implode('/', $segments);
57+
}
58+
}

app/View/Components/AppLayout.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010

1111
class AppLayout extends Component
1212
{
13-
public function __construct(protected mixed $page) {}
13+
public function __construct(
14+
protected mixed $page,
15+
public bool $preconnectCloudinary = false,
16+
) {}
1417

1518
public function render(): View
1619
{
@@ -20,6 +23,7 @@ public function render(): View
2023
'locales' => LocaleEnum::cases(),
2124
'locale' => Str::slug($locale),
2225
'page' => $this->page,
26+
'preconnectCloudinary' => $this->preconnectCloudinary,
2327
'services' => (new ViewDataAction)->services($locale),
2428
'products' => (new ViewDataAction)->products($locale),
2529
]);

bootstrap/app.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<?php
22

3+
use App\Http\Middleware\SecurityHeaders;
34
use App\Http\Middleware\SetLanguage;
45
use App\Providers\AppServiceProvider;
56
use App\Providers\EventServiceProvider;
@@ -25,6 +26,7 @@
2526
$middleware->web(append: [
2627
AddCspHeaders::class,
2728
AddFeaturePolicyHeaders::class,
29+
SecurityHeaders::class,
2830
SetLanguage::class,
2931
CacheResponse::class,
3032
]);

config/filesystems.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@
6666
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
6767
'visibility' => 'private',
6868
'throw' => false,
69+
'options' => [
70+
'CacheControl' => 'public, max-age=31536000, immutable',
71+
],
6972
],
7073

7174
],

config/seo.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
return [
4+
'default_image' => 'images/seo/og-codebar.webp',
5+
'image_width' => 1200,
6+
'image_height' => 630,
7+
];
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Support\Facades\DB;
5+
6+
return new class extends Migration
7+
{
8+
private const BROKEN_IMAGE_PATTERN = '%seo_codebar.webp%';
9+
10+
private const LEGACY_IMAGE_PATTERN = '%seo_paperflakes.webp%';
11+
12+
/**
13+
* Run the migrations.
14+
*/
15+
public function up(): void
16+
{
17+
DB::table('pages')
18+
->where(function ($query) {
19+
$query->where('image', 'like', self::BROKEN_IMAGE_PATTERN)
20+
->orWhere('image', 'like', self::LEGACY_IMAGE_PATTERN);
21+
})
22+
->update(['image' => null]);
23+
24+
foreach (['news', 'technologies', 'open_sources'] as $table) {
25+
DB::table($table)
26+
->where(function ($query) {
27+
$query->where('image', 'like', self::BROKEN_IMAGE_PATTERN)
28+
->orWhere('image', 'like', self::LEGACY_IMAGE_PATTERN);
29+
})
30+
->update(['image' => '']);
31+
}
32+
33+
$pageDescriptions = [
34+
['key' => 'start.index', 'locale' => 'de_CH', 'description' => 'Wir hören zu, denken konzeptionell und entwickeln nutzerzentrierte Software mit offenen Technologien und Standards.'],
35+
['key' => 'about-us.index', 'locale' => 'de_CH', 'description' => 'Lerne codebar solutions AG kennen – dein Schweizer Partner für konzeptionelle Softwareentwicklung mit offenen Technologien.'],
36+
['key' => 'services.index', 'locale' => 'de_CH', 'description' => 'Wir hören zu, erarbeiten Konzepte für künftige Nutzer:innen und setzen sie um – von der Idee bis zur Software.'],
37+
['key' => 'products.index', 'locale' => 'de_CH', 'description' => 'Nutzerzentrierte Softwarelösungen mit echtem Mehrwert – entwickelt mit offenen Technologien und Standards.'],
38+
['key' => 'contact.index', 'locale' => 'de_CH', 'description' => 'Hast du eine innovative Idee? Wir hören zu, verstehen deine Bedürfnisse und erwecken deine Vision zum Leben.'],
39+
['key' => 'news.index', 'locale' => 'en_CH', 'description' => 'Latest news and expert insights on software development, open technologies and digital innovation from codebar.'],
40+
['key' => 'about-us.index', 'locale' => 'en_CH', 'description' => 'Meet codebar solutions AG – your Swiss partner for conceptual software development with open technologies.'],
41+
];
42+
43+
foreach ($pageDescriptions as $page) {
44+
DB::table('pages')
45+
->where('key', $page['key'])
46+
->where('locale', $page['locale'])
47+
->update(['description' => $page['description']]);
48+
}
49+
}
50+
51+
/**
52+
* Reverse the migrations.
53+
*/
54+
public function down(): void
55+
{
56+
// SEO image and description fixes are not reversed.
57+
}
58+
};

0 commit comments

Comments
 (0)