Skip to content

Commit 3d41726

Browse files
committed
Merge branch 'release/0.8.79'
2 parents ab3b832 + c532127 commit 3d41726

12 files changed

Lines changed: 240 additions & 22 deletions

File tree

.version.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
"strategy": "semver",
33
"major": 0,
44
"minor": 8,
5-
"patch": 78,
5+
"patch": 79,
66
"build": 0
77
}

config/neuron.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ theme:
4646
# Guest theme (for public website and login/registration pages)
4747
guest: sandstone
4848

49+
# Pages Configuration
50+
pages:
51+
# Display "Published on" / "Last updated" dates on public pages
52+
show_dates: true
53+
4954
# Security Configuration
5055
security:
5156
# Content Security Policy (CSP)

resources/config/neuron.yaml.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ theme:
7979
# Recommended: sandstone, flatly, cosmo, litera, lumen, minty
8080
guest: sandstone
8181

82+
# Pages Configuration
83+
# Controls the public page display
84+
pages:
85+
show_dates: true # Display "Published on" / "Last updated" dates on public pages
86+
8287
# Cloudinary Configuration
8388
# Sign up for free at: https://cloudinary.com
8489
# Get credentials from: https://console.cloudinary.com/settings/general

resources/views/admin/media/index.php

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,30 @@ class="form-control"
146146

147147
<script>
148148
document.addEventListener('DOMContentLoaded', function() {
149+
// CSRF tokens are single-use, so fetch a fresh one for every AJAX action
150+
// rather than reuse the (possibly already-consumed) page meta token.
151+
function freshCsrfToken() {
152+
return fetch('<?= route_path('admin_csrf_token') ?>', {
153+
headers: { 'Accept': 'application/json' },
154+
credentials: 'same-origin'
155+
})
156+
.then(r => r.json())
157+
.then(d => (d && d.token) ? d.token : '')
158+
.catch(() => document.querySelector('meta[name="csrf-token"]')?.content || '');
159+
}
160+
161+
// Read a response as JSON, surfacing a clear message when the server returns
162+
// non-JSON (e.g. an expired session or a rejected CSRF token redirect).
163+
function parseJsonResponse(response) {
164+
return response.text().then(text => {
165+
try {
166+
return JSON.parse(text);
167+
} catch (e) {
168+
throw new Error('Your session may have expired. Please refresh the page and try again.');
169+
}
170+
});
171+
}
172+
149173
// Copy URL functionality
150174
document.querySelectorAll('.copy-url-btn').forEach(btn => {
151175
btn.addEventListener('click', function() {
@@ -180,14 +204,14 @@ class="form-control"
180204
const formData = new FormData();
181205
formData.append('public_id', publicId);
182206

183-
fetch('<?= route_path('admin_media_delete') ?>', {
207+
freshCsrfToken().then(token => fetch('<?= route_path('admin_media_delete') ?>', {
184208
method: 'POST',
185209
body: formData,
186210
headers: {
187-
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || ''
211+
'X-CSRF-TOKEN': token
188212
}
189-
})
190-
.then(response => response.json())
213+
}))
214+
.then(parseJsonResponse)
191215
.then(data => {
192216
if (data.success) {
193217
// Remove the image card from the grid
@@ -228,14 +252,14 @@ class="form-control"
228252
// Disable button during upload
229253
uploadBtn.disabled = true;
230254

231-
fetch('<?= route_path('admin_media_upload') ?>', {
255+
freshCsrfToken().then(token => fetch('<?= route_path('admin_media_upload') ?>', {
232256
method: 'POST',
233257
body: formData,
234258
headers: {
235-
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || ''
259+
'X-CSRF-TOKEN': token
236260
}
237-
})
238-
.then(response => response.json())
261+
}))
262+
.then(parseJsonResponse)
239263
.then(data => {
240264
uploadProgress.classList.add('d-none');
241265
uploadBtn.disabled = false;

resources/views/pages/show.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@
1010
*
1111
* Falls back to the page model's template when no Template var is provided
1212
* (keeps the view self-contained regardless of the calling controller).
13+
*
14+
* $ShowDates (pages.show_dates setting) controls whether the published/updated
15+
* dates are rendered; defaults to true when the controller doesn't provide it.
1316
*/
1417
$template = $Template ?? ( isset( $Page ) ? $Page->getTemplate() : 'default' );
18+
$showDates = $ShowDates ?? true;
1519

1620
$isLanding = ( $template === 'landing' );
1721
$isFullWidth = ( $template === 'full-width' );
@@ -28,7 +32,7 @@
2832
<header class="page-header mb-5">
2933
<h1 class="display-4 mb-3"><?= htmlspecialchars( $Page->getTitle() ) ?></h1>
3034

31-
<?php if( $Page->getPublishedAt() ): ?>
35+
<?php if( $showDates && $Page->getPublishedAt() ): ?>
3236
<div class="text-muted mb-3">
3337
<small>
3438
<i class="bi bi-calendar3"></i>
@@ -37,7 +41,7 @@
3741
</div>
3842
<?php endif; ?>
3943

40-
<?php if( $Page->getUpdatedAt() ): ?>
44+
<?php if( $showDates && $Page->getUpdatedAt() ): ?>
4145
<div class="text-muted mb-3">
4246
<small>
4347
<i class="bi bi-clock"></i>
@@ -83,13 +87,13 @@
8387
<div class="card-body">
8488
<h2 class="h6 text-uppercase text-muted mb-3">Page Info</h2>
8589
<ul class="list-unstyled small mb-0">
86-
<?php if( $Page->getPublishedAt() ): ?>
90+
<?php if( $showDates && $Page->getPublishedAt() ): ?>
8791
<li class="mb-2">
8892
<i class="bi bi-calendar3 me-1"></i>
8993
Published <?= $Page->getPublishedAt()->format( 'F j, Y' ) ?>
9094
</li>
9195
<?php endif; ?>
92-
<?php if( $Page->getUpdatedAt() ): ?>
96+
<?php if( $showDates && $Page->getUpdatedAt() ): ?>
9397
<li class="mb-2">
9498
<i class="bi bi-clock me-1"></i>
9599
Updated <?= $Page->getUpdatedAt()->format( 'F j, Y' ) ?>

resources/views/partials/media-picker-modal.php

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,30 @@ function applyPickedUrl(url) {
321321
const uploadError = document.getElementById('mediaPickerUploadError');
322322
const uploadSuccess = document.getElementById('mediaPickerUploadSuccess');
323323

324+
// CSRF tokens are single-use, so fetch a fresh one for every upload rather
325+
// than reuse the (possibly already-consumed) token from the page meta tag.
326+
function freshCsrfToken() {
327+
return fetch('<?= route_path('admin_csrf_token') ?>', {
328+
headers: { 'Accept': 'application/json' },
329+
credentials: 'same-origin'
330+
})
331+
.then(r => r.json())
332+
.then(d => (d && d.token) ? d.token : '')
333+
.catch(() => document.querySelector('meta[name="csrf-token"]')?.content || '');
334+
}
335+
336+
// Read a response as JSON, surfacing a clear message when the server returns
337+
// non-JSON (e.g. an expired session or a rejected CSRF token redirect).
338+
function parseJsonResponse(response) {
339+
return response.text().then(text => {
340+
try {
341+
return JSON.parse(text);
342+
} catch (e) {
343+
throw new Error('Your session may have expired. Please refresh the page and try again.');
344+
}
345+
});
346+
}
347+
324348
uploadBtn?.addEventListener('click', function() {
325349
if (!imageFile.files.length) {
326350
uploadError.textContent = 'Please select a file';
@@ -339,14 +363,14 @@ function applyPickedUrl(url) {
339363
// Disable button during upload
340364
uploadBtn.disabled = true;
341365

342-
fetch('<?= route_path('admin_media_upload') ?>', {
366+
freshCsrfToken().then(token => fetch('<?= route_path('admin_media_upload') ?>', {
343367
method: 'POST',
344368
body: formData,
345369
headers: {
346-
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
370+
'X-CSRF-Token': token
347371
}
348-
})
349-
.then(response => response.json())
372+
}))
373+
.then(parseJsonResponse)
350374
.then(data => {
351375
uploadProgress.classList.add('d-none');
352376
uploadBtn.disabled = false;

src/Cms/Controllers/Admin/Media.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Neuron\Cms\Auth\SessionManager;
66
use Neuron\Cms\Enums\FlashMessageType;
77
use Neuron\Cms\Controllers\Content;
8+
use Neuron\Cms\Services\Auth\CsrfToken;
89
use Neuron\Cms\Services\Media\CloudinaryUploader;
910
use Neuron\Cms\Services\Media\MediaValidator;
1011
use Neuron\Data\Settings\SettingManager;
@@ -142,6 +143,25 @@ public function index( Request $request ): string
142143
}
143144
}
144145

146+
/**
147+
* Issue a fresh CSRF token for AJAX media actions.
148+
*
149+
* CSRF tokens are single-use ( consumed on validation ), so AJAX flows that
150+
* stay on the page — the media picker modal and the library's upload / delete
151+
* buttons — must request a fresh token before each request rather than reuse
152+
* the one rendered into the page <meta> tag.
153+
*
154+
* @param Request $request
155+
* @return string JSON response { token }
156+
*/
157+
#[Get('/csrf-token', name: 'admin_csrf_token')]
158+
public function csrfToken( Request $request ): string
159+
{
160+
$csrf = new CsrfToken( $this->getSessionManager() );
161+
162+
return $this->renderJson( HttpResponseStatus::OK, [ 'token' => $csrf->getToken() ] );
163+
}
164+
145165
/**
146166
* Upload image for Editor.js
147167
*

src/Cms/Controllers/Pages.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@ public function show( Request $request ): string
8989
'Title' => $pageTitle,
9090
'Description' => $page->getMetaDescription() ?: $this->getDescription(),
9191
'MetaKeywords' => $page->getMetaKeywords(),
92-
'Template' => $page->getTemplate()
92+
'Template' => $page->getTemplate(),
93+
'ShowDates' => $this->_settings->get( 'pages', 'show_dates' ) ?? true
9394
],
9495
'show'
9596
);

src/Cms/Services/Payment/PaymentService.php

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -231,8 +231,8 @@ public function allowsCustomAmount( string $key ): bool
231231
*/
232232
public function resolvePayer( array $fieldDefs, array $values ): array
233233
{
234-
$email = null;
235-
$name = null;
234+
$email = null;
235+
$nameParts = [];
236236

237237
foreach( $fieldDefs as $field )
238238
{
@@ -250,12 +250,17 @@ public function resolvePayer( array $fieldDefs, array $values ): array
250250
$email = $value;
251251
}
252252

253-
if( !empty( $field['sender_name'] ) && $name === null )
253+
// Collect every field flagged as part of the payer's name ( e.g. a
254+
// split first / last name ) in declaration order so the full name is
255+
// captured, not just the first field.
256+
if( !empty( $field['sender_name'] ) && is_scalar( $value ) && trim( (string) $value ) !== '' )
254257
{
255-
$name = $value;
258+
$nameParts[] = trim( (string) $value );
256259
}
257260
}
258261

262+
$name = $nameParts !== [] ? implode( ' ', $nameParts ) : null;
263+
259264
$email ??= ( $values['email'] ?? null );
260265
$name ??= ( $values['name'] ?? null );
261266

tests/Unit/Cms/Controllers/PagesTest.php

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,100 @@ public function testShowPassesPageTemplateToView(): void
180180
$this->assertEquals( '<html>Landing</html>', $result );
181181
}
182182

183+
public function testShowDatesDefaultsToTrueWhenSettingNotSet(): void
184+
{
185+
$mockPage = $this->createMock( Page::class );
186+
$mockPage->method( 'getId' )->willReturn( 1 );
187+
$mockPage->method( 'getTitle' )->willReturn( 'Test Page' );
188+
$mockPage->method( 'isPublished' )->willReturn( true );
189+
$mockPage->method( 'getContent' )->willReturn( [ 'blocks' => [] ] );
190+
$mockPage->method( 'getMetaTitle' )->willReturn( '' );
191+
$mockPage->method( 'getMetaDescription' )->willReturn( '' );
192+
$mockPage->method( 'getMetaKeywords' )->willReturn( '' );
193+
$mockPage->method( 'getTemplate' )->willReturn( 'default' );
194+
195+
$mockPageRepository = $this->createMock( IPageRepository::class );
196+
$mockPageRepository->method( 'findBySlug' )->willReturn( $mockPage );
197+
$mockPageRepository->method( 'incrementViewCount' );
198+
199+
$mockRenderer = $this->createMock( EditorJsRenderer::class );
200+
$mockRenderer->method( 'render' )->willReturn( '<p>Content</p>' );
201+
202+
$mockSettingManager = Registry::getInstance()->get( 'Settings' );
203+
$mockSessionManager = $this->createMock( \Neuron\Cms\Auth\SessionManager::class );
204+
205+
$controller = $this->getMockBuilder( Pages::class )
206+
->setConstructorArgs( [ $this->_mockApp, $mockSettingManager, $mockSessionManager, $mockPageRepository, $mockRenderer ] )
207+
->onlyMethods( [ 'renderHtml' ] )
208+
->getMock();
209+
210+
$controller->expects( $this->once() )
211+
->method( 'renderHtml' )
212+
->with(
213+
$this->anything(),
214+
$this->callback( function( $data ) {
215+
return isset( $data['ShowDates'] ) && $data['ShowDates'] === true;
216+
} ),
217+
'show'
218+
)
219+
->willReturn( '<html>Page</html>' );
220+
221+
$request = new Request();
222+
$request->setRouteParameters( [ 'slug' => 'test-page' ] );
223+
$controller->show( $request );
224+
}
225+
226+
public function testShowDatesDisabledBySetting(): void
227+
{
228+
$settings = new Memory();
229+
$settings->set( 'site', 'name', 'Test Site' );
230+
$settings->set( 'site', 'title', 'Test Title' );
231+
$settings->set( 'site', 'description', 'Test Description' );
232+
$settings->set( 'site', 'url', 'http://test.com' );
233+
$settings->set( 'paths', 'version_file', $this->_versionFilePath );
234+
$settings->set( 'pages', 'show_dates', false );
235+
$settingManager = new SettingManager( $settings );
236+
237+
$mockPage = $this->createMock( Page::class );
238+
$mockPage->method( 'getId' )->willReturn( 1 );
239+
$mockPage->method( 'getTitle' )->willReturn( 'Test Page' );
240+
$mockPage->method( 'isPublished' )->willReturn( true );
241+
$mockPage->method( 'getContent' )->willReturn( [ 'blocks' => [] ] );
242+
$mockPage->method( 'getMetaTitle' )->willReturn( '' );
243+
$mockPage->method( 'getMetaDescription' )->willReturn( '' );
244+
$mockPage->method( 'getMetaKeywords' )->willReturn( '' );
245+
$mockPage->method( 'getTemplate' )->willReturn( 'default' );
246+
247+
$mockPageRepository = $this->createMock( IPageRepository::class );
248+
$mockPageRepository->method( 'findBySlug' )->willReturn( $mockPage );
249+
$mockPageRepository->method( 'incrementViewCount' );
250+
251+
$mockRenderer = $this->createMock( EditorJsRenderer::class );
252+
$mockRenderer->method( 'render' )->willReturn( '<p>Content</p>' );
253+
254+
$mockSessionManager = $this->createMock( \Neuron\Cms\Auth\SessionManager::class );
255+
256+
$controller = $this->getMockBuilder( Pages::class )
257+
->setConstructorArgs( [ $this->_mockApp, $settingManager, $mockSessionManager, $mockPageRepository, $mockRenderer ] )
258+
->onlyMethods( [ 'renderHtml' ] )
259+
->getMock();
260+
261+
$controller->expects( $this->once() )
262+
->method( 'renderHtml' )
263+
->with(
264+
$this->anything(),
265+
$this->callback( function( $data ) {
266+
return isset( $data['ShowDates'] ) && $data['ShowDates'] === false;
267+
} ),
268+
'show'
269+
)
270+
->willReturn( '<html>Page</html>' );
271+
272+
$request = new Request();
273+
$request->setRouteParameters( [ 'slug' => 'test-page' ] );
274+
$controller->show( $request );
275+
}
276+
183277
public function testShowThrowsNotFoundForNonexistentPage(): void
184278
{
185279
$mockPageRepository = $this->createMock( IPageRepository::class );

0 commit comments

Comments
 (0)