Skip to content

Planification de contenu — Publication temporisée avec états de cycle de vie

Planifiez la publication de contenu à une date/heure future en utilisant une colonne publish_at, une machine à états (draft → scheduled → published → archived) et un endpoint déclencheur de publication qu'un job cron appelle pour basculer les articles échus.

Implémentation de référence : FT172 pubschedulelog dans hideyukiMORI/NENE2-examples


Cycle de vie du statut

draft ──┬──► scheduled ──► published ──► archived
        │                               ▲
        └───────────────────────────────┘
        (aussi : scheduled → draft via unschedule)
DepuisTransitions autorisées
draftscheduled, published, archived
scheduledpublished, draft, archived
publishedarchived
archived(aucune)

Schéma

sql
CREATE TABLE articles (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    author_id    INTEGER NOT NULL,
    title        TEXT    NOT NULL,
    body         TEXT    NOT NULL,
    status       TEXT    NOT NULL DEFAULT 'draft',
    -- 'draft' | 'scheduled' | 'published' | 'archived'
    publish_at   TEXT,    -- ISO 8601 ; défini lors de la planification ; NULL sinon
    published_at TEXT,    -- défini lors de la publication effective
    created_at   TEXT    NOT NULL,
    updated_at   TEXT    NOT NULL
);

Endpoints

MéthodeCheminAuthDescription
POST/articlesX-User-IdCréer un brouillon
GET/articlesoptionnelLister (?status=published est public ; les autres statuts nécessitent auth + ses propres articles uniquement)
GET/articles/{id}optionnelObtenir un article (published = public, draft/scheduled = propriétaire uniquement)
PUT/articles/{id}X-User-IdMettre à jour titre/corps (draft ou scheduled uniquement)
POST/articles/{id}/scheduleX-User-IdDéfinir publish_at → passe à scheduled
POST/articles/{id}/unscheduleX-User-IdAnnuler la planification → revient à draft
POST/articles/{id}/publishX-User-IdPublier immédiatement
POST/articles/{id}/archiveX-User-IdArchiver
POST/articles/publish-dueX-Admin-KeyPublication en masse de tous les articles planifiés échus

Patterns principaux

Enum de statut avec garde de transition

php
enum ArticleStatus: string {
    case Draft     = 'draft';
    case Scheduled = 'scheduled';
    case Published = 'published';
    case Archived  = 'archived';

    public function canTransitionTo(self $next): bool {
        return match ($this) {
            self::Draft     => in_array($next, [self::Scheduled, self::Published, self::Archived], true),
            self::Scheduled => in_array($next, [self::Published, self::Draft, self::Archived], true),
            self::Published => $next === self::Archived,
            self::Archived  => false,
        };
    }
}

Planification : Validation uniquement dans le futur

php
$ts = strtotime($publishAt);
if ($ts === false || $ts === -1) {
    throw new ArticleScheduleException('publish_at is not a valid datetime.');
}
if ($ts <= strtotime($now)) {
    throw new ArticleScheduleException('publish_at must be in the future.');
}

Déclencheur de publication échus (sûr pour cron, idempotent)

php
public function publishDue(string $now): array
{
    $rows = $this->db->fetchAll(
        "SELECT id FROM articles WHERE status = ? AND publish_at <= ? ORDER BY publish_at",
        [ArticleStatus::Scheduled->value, $now],
    );

    $published = [];
    foreach ($rows as $row) {
        $id = (int) $row['id'];
        $this->db->execute(
            'UPDATE articles SET status = ?, published_at = ?, publish_at = NULL, updated_at = ? WHERE id = ?',
            [ArticleStatus::Published->value, $now, $now, $id],
        );
        $published[] = $id;
    }

    return $published;  // list<int>
}

À appeler depuis un job cron toutes les minutes. Idempotent : réexécuter immédiatement ne trouve pas de nouveaux articles échus puisque publish_at est mis à NULL lors de la publication.

Prévention IDOR

Les articles en brouillon et planifiés sont réservés au propriétaire — retourner 404 (pas 403) pour éviter de révéler l'existence :

php
if ($article->authorId !== $actorId) {
    throw new ArticleNotFoundException($id);  // 404, pas 403
}

Clé admin — Comparaison sûre dans le temps

php
if ($apiKey === '' || !hash_equals($expected, $apiKey)) {
    return $this->responseFactory->create(['error' => 'unauthorized'], 401);
}

Ne jamais utiliser !== pour les comparaisons de secrets — utiliser hash_equals() pour empêcher les attaques par timing.


Notes de sécurité

RisqueAtténuation
Injection de publish_at dans le passéstrtotime($publishAt) <= strtotime($now) → 422
Mutation d'état inter-utilisateursVérification de propriété avant chaque transition ; 404 pas 403
Injection d'ID d'auteur via le corpsauthorId pris uniquement depuis l'en-tête X-User-Id
Injection de statut via le corpsLe champ status dans le corps PUT est ignoré ; transitions via des endpoints d'action dédiés
Attaque par timing sur la clé adminhash_equals() au lieu de !==
Énumération d'articles non publiésLe listing public filtre toujours par status = published ; non-publié nécessite auth + ses propres articles uniquement
Édition après publicationPUT rejette les articles non-draft/scheduled avec 422
Double archivageLa garde de transition retourne 409 pour les transitions invalides

Intégration cron

bash
# /etc/cron.d/publish-due
* * * * * www-data curl -s -X POST https://api.example.com/articles/publish-due \
  -H "X-Admin-Key: $ADMIN_KEY"

Pour des workloads à plus grand volume, passer à une file de jobs (voir job-queue.md) et laisser le worker de file appeler publishDue().


Voir aussi

Publié sous licence MIT.