Skip to content
Open
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
60 changes: 58 additions & 2 deletions resources/views/admin/posts/create.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@

<div class="mb-3">
<label for="slug" class="form-label">Slug</label>
<input type="text" class="form-control" id="slug" name="slug" required>
<small class="form-text text-muted">URL-friendly version of the title</small>
<input type="text" class="form-control" id="slug" name="slug" pattern="[a-z0-9-]+">
<small class="form-text text-muted">URL-friendly version. Leave blank to auto-generate from title.</small>
</div>

<div class="mb-3">
Expand All @@ -39,9 +39,16 @@
<select class="form-select" id="status" name="status" required>
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="scheduled">Scheduled</option>
</select>
</div>

<div class="mb-3" id="published-at-wrapper" style="display: none;">
<label for="published_at" class="form-label">Published Date/Time</label>
<input type="datetime-local" class="form-control" id="published_at" name="published_at">
<small class="form-text text-muted">Required for scheduled posts. Leave blank to use current date/time when publishing.</small>
</div>

<div class="mb-3">
<label for="featured_image" class="form-label">Featured Image</label>
<div class="input-group">
Expand Down Expand Up @@ -202,6 +209,15 @@ class: Embed,
document.getElementById('post-form').addEventListener('submit', async (e) => {
e.preventDefault();

// Validate scheduled posts have a published date
const status = document.getElementById('status').value;
const publishedAt = document.getElementById('published_at').value;

if (status === 'scheduled' && !publishedAt) {
alert('Scheduled posts require a published date/time.');
return;
}

try {
const savedData = await editor.save();
console.log('Saved data:', savedData);
Expand All @@ -218,6 +234,46 @@ class: Embed,
}
}

// Auto-generate slug from title
document.getElementById('title').addEventListener('input', function() {
const slugInput = document.getElementById('slug');
if (!slugInput.value || slugInput.dataset.autoGenerated === 'true') {
const slug = this.value.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
slugInput.value = slug;
slugInput.dataset.autoGenerated = 'true';
}
});

// Mark slug as manually edited
document.getElementById('slug').addEventListener('input', function() {
if (this.value) {
this.dataset.autoGenerated = 'false';
}
});

// Show/hide published date based on status
function updatePublishedAtVisibility() {
const status = document.getElementById('status').value;
const publishedAtWrapper = document.getElementById('published-at-wrapper');
const publishedAtInput = document.getElementById('published_at');

if (status === 'published' || status === 'scheduled') {
publishedAtWrapper.style.display = 'block';
publishedAtInput.disabled = false;
} else {
publishedAtWrapper.style.display = 'none';
publishedAtInput.value = '';
publishedAtInput.disabled = true;
}
}

document.getElementById('status').addEventListener('change', updatePublishedAtVisibility);

// Initialize on page load
document.addEventListener('DOMContentLoaded', updatePublishedAtVisibility);

// Featured image preview
document.getElementById('featured_image').addEventListener('change', function() {
const preview = document.getElementById('featured_image_preview');
Expand Down
60 changes: 58 additions & 2 deletions resources/views/admin/posts/edit.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@

<div class="mb-3">
<label for="slug" class="form-label">Slug</label>
<input type="text" class="form-control" id="slug" name="slug" value="<?= htmlspecialchars( $post->getSlug() ) ?>" required>
<small class="form-text text-muted">URL-friendly version of the title</small>
<input type="text" class="form-control" id="slug" name="slug" value="<?= htmlspecialchars( $post->getSlug() ) ?>" pattern="[a-z0-9-]+">
<small class="form-text text-muted">URL-friendly version. Leave blank to auto-generate from title.</small>
</div>

<div class="mb-3">
Expand All @@ -40,9 +40,16 @@
<select class="form-select" id="status" name="status" required>
<option value="draft" <?= $post->getStatus() === 'draft' ? 'selected' : '' ?>>Draft</option>
<option value="published" <?= $post->getStatus() === 'published' ? 'selected' : '' ?>>Published</option>
<option value="scheduled" <?= $post->getStatus() === 'scheduled' ? 'selected' : '' ?>>Scheduled</option>
</select>
</div>

<div class="mb-3" id="published-at-wrapper" style="display: <?= in_array($post->getStatus(), ['published', 'scheduled']) ? 'block' : 'none' ?>;">
<label for="published_at" class="form-label">Published Date/Time</label>
<input type="datetime-local" class="form-control" id="published_at" name="published_at" value="<?= $post->getPublishedAt() ? $post->getPublishedAt()->format('Y-m-d\TH:i') : '' ?>">
<small class="form-text text-muted">Required for scheduled posts. Leave blank to use current date/time when publishing.</small>
</div>

<div class="mb-3">
<label for="featured_image" class="form-label">Featured Image</label>
<div class="input-group">
Expand Down Expand Up @@ -228,6 +235,15 @@ class: Embed,
document.getElementById('post-form').addEventListener('submit', async (e) => {
e.preventDefault();

// Validate scheduled posts have a published date
const status = document.getElementById('status').value;
const publishedAt = document.getElementById('published_at').value;

if (status === 'scheduled' && !publishedAt) {
alert('Scheduled posts require a published date/time.');
return;
}

try {
const savedData = await editor.save();
console.log('Saved data:', savedData);
Expand All @@ -244,6 +260,46 @@ class: Embed,
}
}

// Auto-generate slug from title
document.getElementById('title').addEventListener('input', function() {
const slugInput = document.getElementById('slug');
if (!slugInput.value || slugInput.dataset.autoGenerated === 'true') {
const slug = this.value.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
slugInput.value = slug;
slugInput.dataset.autoGenerated = 'true';
}
});

// Mark slug as manually edited
document.getElementById('slug').addEventListener('input', function() {
if (this.value) {
this.dataset.autoGenerated = 'false';
}
});

// Show/hide published date based on status
function updatePublishedAtVisibility() {
const status = document.getElementById('status').value;
const publishedAtWrapper = document.getElementById('published-at-wrapper');
const publishedAtInput = document.getElementById('published_at');

if (status === 'published' || status === 'scheduled') {
publishedAtWrapper.style.display = 'block';
publishedAtInput.disabled = false;
} else {
publishedAtWrapper.style.display = 'none';
// Don't clear the value - preserve existing published dates from database
publishedAtInput.disabled = true;
}
}

document.getElementById('status').addEventListener('change', updatePublishedAtVisibility);

// Initialize on page load
document.addEventListener('DOMContentLoaded', updatePublishedAtVisibility);

// Featured image preview
document.getElementById('featured_image').addEventListener('change', function() {
const preview = document.getElementById('featured_image_preview');
Expand Down
5 changes: 5 additions & 0 deletions src/Cms/Dtos/posts/create-post-request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,8 @@ dto:
type: string
required: true
enum: ['draft', 'published', 'scheduled']

published_at:
type: string
required: false
pattern: '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$|^$/' # datetime-local format: YYYY-MM-DDTHH:MM or empty
5 changes: 5 additions & 0 deletions src/Cms/Dtos/posts/update-post-request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,8 @@ dto:
type: string
required: true
enum: ['draft', 'published', 'scheduled']

published_at:
type: string
required: false
pattern: '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$|^$/' # datetime-local format: YYYY-MM-DDTHH:MM or empty
43 changes: 41 additions & 2 deletions src/Cms/Services/Post/Creator.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public function create( Dto $request, array $categoryIds = [], string $tagNames
$slug = $request->slug ?? null;
$excerpt = $request->excerpt ?? null;
$featuredImage = $request->featured_image ?? null;
$publishedAt = $request->published_at ?? null;

$post = new Post();
$post->setTitle( $title );
Expand All @@ -67,9 +68,24 @@ public function create( Dto $request, array $categoryIds = [], string $tagNames
$post->setStatus( $status );
$post->setCreatedAt( new DateTimeImmutable() );

// Business rule: auto-set published date for published posts
if( $status === ContentStatus::PUBLISHED->value )
// Business rule: set published date
if( $status === ContentStatus::SCHEDULED->value )
{
// Scheduled posts MUST have a published date
if( !$publishedAt || trim( $publishedAt ) === '' )
{
throw new \InvalidArgumentException( 'Scheduled posts require a published date' );
}
$post->setPublishedAt( $this->parseDateTime( $publishedAt ) );
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scheduled posts accept past dates, bypassing validation

Medium Severity

The new scheduling logic in Creator and Updater validates that scheduled posts have a published date, but unlike Publisher::schedule(), it doesn't validate that the date is in the future. This allows creating or updating scheduled posts with past dates, which is logically inconsistent. A post scheduled for a past date would be immediately eligible for publishing, defeating the purpose of scheduling.

Additional Locations (1)

Fix in Cursor Fix in Web

elseif( $publishedAt && trim( $publishedAt ) !== '' )
{
// Use provided published date
$post->setPublishedAt( $this->parseDateTime( $publishedAt ) );
}
elseif( $status === ContentStatus::PUBLISHED->value )
{
// Auto-set to now for published posts when not provided
$post->setPublishedAt( new DateTimeImmutable() );
}

Expand Down Expand Up @@ -97,4 +113,27 @@ private function generateSlug( string $title ): string
{
return $this->_slugGenerator->generate( $title, 'post' );
}

/**
* Safely parse a datetime string into DateTimeImmutable
*
* @param string $dateTimeString The datetime string to parse
* @return DateTimeImmutable
* @throws \InvalidArgumentException If the datetime string is invalid
*/
private function parseDateTime( string $dateTimeString ): DateTimeImmutable
{
try
{
return new DateTimeImmutable( $dateTimeString );
}
catch( \DateMalformedStringException | \Exception $e )
{
throw new \InvalidArgumentException(
"Invalid published date format: '{$dateTimeString}'. Please provide a valid datetime.",
0,
$e
);
}
}
}
43 changes: 41 additions & 2 deletions src/Cms/Services/Post/Updater.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public function update( Dto $request, array $categoryIds = [], string $tagNames
$slug = $request->slug ?? null;
$excerpt = $request->excerpt ?? null;
$featuredImage = $request->featured_image ?? null;
$publishedAt = $request->published_at ?? null;

// Look up the post
$post = $this->_postRepository->findById( $id );
Expand All @@ -71,9 +72,24 @@ public function update( Dto $request, array $categoryIds = [], string $tagNames
$post->setFeaturedImage( $featuredImage );
$post->setStatus( $status );

// Business rule: auto-set published date when changing to published status
if( $status === ContentStatus::PUBLISHED->value && !$post->getPublishedAt() )
// Business rule: set published date
if( $status === ContentStatus::SCHEDULED->value )
{
// Scheduled posts MUST have a published date
if( !$publishedAt || trim( $publishedAt ) === '' )
{
throw new \InvalidArgumentException( 'Scheduled posts require a published date' );
}
$post->setPublishedAt( $this->parseDateTime( $publishedAt ) );
}
elseif( $publishedAt && trim( $publishedAt ) !== '' )
{
// Use provided published date
$post->setPublishedAt( $this->parseDateTime( $publishedAt ) );
}
elseif( $status === ContentStatus::PUBLISHED->value && !$post->getPublishedAt() )
{
// Auto-set to now for published posts when not provided and not already set
$post->setPublishedAt( new \DateTimeImmutable() );
}

Expand Down Expand Up @@ -102,4 +118,27 @@ private function generateSlug( string $title ): string
{
return $this->_slugGenerator->generate( $title, 'post' );
}

/**
* Safely parse a datetime string into DateTimeImmutable
*
* @param string $dateTimeString The datetime string to parse
* @return \DateTimeImmutable
* @throws \InvalidArgumentException If the datetime string is invalid
*/
private function parseDateTime( string $dateTimeString ): \DateTimeImmutable
{
try
{
return new \DateTimeImmutable( $dateTimeString );
}
catch( \DateMalformedStringException | \Exception $e )
{
throw new \InvalidArgumentException(
"Invalid published date format: '{$dateTimeString}'. Please provide a valid datetime.",
0,
$e
);
}
}
}
Loading