GitLab Commit is coming up on August 3-4. Learn how to innovate together using GitLab, the DevOps platform. Register for free: gitlabcommitvirtual2021.com

Post.php 98.1 KB
Newer Older
1
2
<?php
// Dobrado Content Management System
3
// Copyright (C) 2020 Malcolm Blaney
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.

class Post extends Base {

  public function Add($id) {
21
    // Add default content here so that SetContent doesn't need to check if
22
    // a table row already exists. This also updates paging for the feed.
23
    $this->Insert($id, $this->user->name, $this->user->page);
24
25
  }

26
  public function Callback() {
27
28
29
30
31
32
33
34
35
36
    // This is called after SetContent and NewComment return.
    if (isset($_POST['action'])) {
      $updated = false;
      if ($_POST['action'] === 'updated') {
        $updated = true;
      }
      else if ($_POST['action'] !== 'newComment') {
        return ['error' => 'Unknown action'];
      }

37
      $status = '';
38
      $key = $this->owner . '-' . $this->user->page;
39
      if (isset($_SESSION['post-update'][$key])) {
40
        $post = $_SESSION['post-update'][$key];
41
        if ($post['published'] === 1) {
42
43
44
          $status = $this->ContentUpdated($post['box_id'], $post['content'],
                                          $post['category'], $post['feed'],
                                          $post['permalink'], $updated);
45
        }
46
47
        $_SESSION['post-update'][$key] = NULL;
      }
48
49
50
51
      else if (isset($_SESSION['micropub-status'])) {
        $status = $_SESSION['micropub-status'];
        $_SESSION['micropub-status'] = NULL;
      }
52
      return ['status' => $status];
53
    }
54
    // 'mode' is used by the Extended module, which calls this function.
55
56
    if (isset($_POST['mode']) && $_POST['mode'] === 'box') {
      return $this->CustomSettings();
57
    }
58
    return ['error' => 'Unknown mode'];
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
  }

  public function CanAdd($page) {
    return true;
  }

  public function CanRemove($id) {
    return true;
  }

  public function CanEdit($id) {
    return true;
  }

  public function Content($id) {
74
75
    // If the reader module is on the page, it is responsible for aggregating
    // post module content, so return false here.
76
    if ($this->AlreadyOnPage('reader')) return false;
77

78
79
80
    $title = '';
    $description = '';
    $author = '';
81
    $repost = '';
82
83
84
    $category = '';
    $media = '';
    $permalink = '';
85
    $feed = '';
86
    $timestamp = 0;
87
88
    $base_url = $this->user->config->Secure() ? 'https://' : 'http://';
    $base_url .= $this->user->config->ServerName();
Mal's avatar
Mal committed
89
    $mysqli = connect_db();
90
91
92
    $query = 'SELECT title, description, author, category, enclosure, ' .
      'permalink, feed, timestamp FROM post WHERE ' .
      'user = "' . $this->owner . '" AND box_id = ' . $id;
93
94
    if ($mysqli_result = $mysqli->query($query)) {
      if ($post = $mysqli_result->fetch_assoc()) {
95
96
        // Allow the title to be stored with literal special chars as this
        // simplifies creating permalinks, but it must be escaped here.
97
98
        $title = htmlspecialchars($post['title']);
        $description = $post['description'];
99
        $enclosure = json_decode($post['enclosure'], true);
100
        $count = is_array($enclosure) ? count($enclosure) : 0;
101
        if ($count === 1) {
102
          $media .= '<p><img class="u-photo" src="' . $enclosure[0] . '"></p>';
103
104
105
106
107
108
109
110
111
112
113
114
        }
        else if ($count > 1) {
          $image_set_id = preg_replace('/[[:^alnum:]]/', '', $enclosure[0]);
          $media .= '<p class="photo-list">';
          for ($i = 0; $i < $count; $i++) {
            $hidden = $i <= 1 ? '' : 'class="hidden" ';
            $media .= '<a href="' . $enclosure[$i] . '" ' . $hidden .
              'data-lightbox="' . $image_set_id . '">' .
              '<img class="u-photo" src="' . $enclosure[$i] . '"></a>';
          }
          $media .= '<br><b>' . $count . ' photos</b></p>';
        }
115
116
        // The author is escaped in SetContent.
        $author = $post['author'];
117
        $prev_tag = '';
118
        // Category can be comma separated values.
119
        if (strpos($post['category'], ',') !== false) {
120
          foreach (explode(',', $post['category']) as $tag) {
121
122
123
124
125
            if ($prev_tag === 'Like of') {
              $category .= category_markup(htmlspecialchars($tag), 'like-of',
                                           $this->LookupName($tag));
            }
            else if (preg_match('/^reposted by (.+)$/', $prev_tag, $match)) {
126
127
              // Reposted items get their original author, which is stored in
              // the nickname cache when the repost is created.
128
              $repost = $this->LookupUser($match[1], $base_url);
129
130
              $author = $this->LookupAuthor($author, $tag) .
                ' <span class="post-reposted-by">reposted by ' .
131
                $repost . '</span>';
132
              $category .= category_markup(htmlspecialchars($tag), 'repost-of');
133
            }
134
            else if ($prev_tag === 'In reply to') {
135
              $category .= category_markup(htmlspecialchars($tag),
136
137
138
139
140
                                           'in-reply-to',
                                           $this->LookupName($tag));
            }
            else {
              $category .= category_markup(htmlspecialchars($tag), '',
141
142
143
                                           $this->LookupName($tag));
            }
            $prev_tag = $tag;
144
          }
145
        }
146
        else {
147
          $category .= category_markup(htmlspecialchars($post['category']), '',
148
                                       $this->LookupName($post['category']));
149
150
        }
        if ($category !== '') {
151
152
          $category = '<span class="post-tag-label ui-icon ui-icon-tag" ' .
            'title="tags"></span> ' . $category;
153
        }
154
        $permalink = $post['permalink'];
155
        $feed = $post['feed'];
156
        $timestamp = (int)$post['timestamp'];
Mal's avatar
Mal committed
157
      }
158
      $mysqli_result->close();
Mal's avatar
Mal committed
159
160
    }
    else {
161
      $this->Log('Post->Content 1: ' . $mysqli->error);
162
    }
163

164
    $permalink_url = $this->Url('', $permalink);
165
    $permalink_page = $this->user->page === $permalink;
166
167
    $formatted_date = date('j F Y, g:ia', $timestamp);
    $atom_date = date(DATE_ATOM, $timestamp);
168
169
    // Check if this is a tombstone post.
    if ($description === $this->Tombstone()) {
170
      if ($permalink_page) {
171
        header('HTTP/1.1 410 Gone');
172
173
174
175
        return '<article class="h-entry">' .
            '<div class="published">' .
              '<time class="dt-deleted" datetime="' . $atom_date . '">' .
                '<a href="' . $permalink_url . '" class="u-url">' .
176
                $formatted_date . '</a>' .
177
              '</time></div>' .
178
            '<div class="p-name e-content">' . $description . '</div>' .
179
          '</article>';
180
      }
181
182
183
      else {
        // Show an empty h-entry on the feed page so that readers can remove
        // cached entries.
184
185
186
        return '<article class="h-entry">' .
          '<time class="dt-deleted" datetime="' . $atom_date . '">' .
            '<a href="' . $permalink_url . '" class="u-url"></a>' .
187
188
          '</time></article>';
      }
189
190
    }

191
192
    // When creating a new post, the writer module can designate a different
    // page, so only return content when on this post's feed or permalink page.
193
    if (!$permalink_page &&
194
        !preg_match('/^' . $feed . '(-p[0-9]+)?$/', $this->user->page)) {
195
196
197
      return false;
    }

198
    // Link to previous and next posts on permalink pages.
199
    $navigation = '';
200
    if ($permalink_page) {
201
202
203
      // When a post is copied it keeps the same permalink, so don't show
      // duplicate posts in this case.
      $duplicate = false;
204
205
      $query = 'SELECT box_id FROM post WHERE user = "' . $this->owner . '" ' .
        'AND permalink = "' . $permalink . '" ORDER BY box_id LIMIT 1';
206
207
      if ($mysqli_result = $mysqli->query($query)) {
        if ($post = $mysqli_result->fetch_assoc()) {
208
          $duplicate = (int)$post['box_id'] !== $id;
209
        }
210
        $mysqli_result->close();
211
212
      }
      else {
213
        $this->Log('Post->Content 2: ' . $mysqli->error);
214
215
216
217
218
219
      }
      if ($duplicate) {
        $mysqli->close();
        return false;
      }

220
      // Show the webmention endpoint on permalink pages.
221
      header('Link: <' . $base_url . '/php/webmention.php>; rel="webmention"');
222
      // Also specify that this is the canonical version of the content.
223
224
      header('Link: <' . $base_url . $permalink_url . '>; rel="canonical"',
             false);
225
      $navigation = $this->Navigation($feed, $timestamp);
226
    }
Mal's avatar
Mal committed
227
228
    $mysqli->close();

229
230
    // If title and description are both empty, assume this post module
    // should not be shown yet and return false so that it's hidden.
231
    if ($title === '' && $description === '') return false;
232

233
    if ($author === '') $author = $this->owner;
234
    $author = $this->LookupUser($author, $base_url);
235
236
237
    // Permalink page requires h-entry class on the parent group div so that
    // comments can be nested. This is handled by the Page class.
    $post_format = $permalink_page ? 'permalink-' : '';
238
    if ($title === '') {
239
      $post_format .= $repost === '' ? 'post-no-title' : 'repost-no-title';
240
    }
241
    else {
242
243
244
      $post_format .= $repost === '' ?
        'post-with-title' : 'repost-with-title';
      $title = '<h2 class="title p-name">' . $title . '</h2>';
245
    }
246
247
    // Don't add u-url to reposts as that is handled by category_markup.
    $url_class = $repost === '' ? ' class="u-url"' : '';
248
    $date = '<time class="dt-published" datetime="' . $atom_date . '">' .
249
      '<a href="' . $permalink_url . '" ' . $url_class . '>' . $formatted_date .
250
      '</a></time>';
251
252
253
254
    $patterns = ['/!title/', '/!description/', '/!author/', '/!category/',
                 '/!media/', '/!date/', '/!navigation/'];
    $replacements = [$title, $description, $author, $category, $media, $date,
                     $navigation];
255
256
257
258
259
260
261
262
    if ($repost !== '') {
      $patterns[] = '/!repost/';
      $replacements[] = $repost;
      // Also need the permalink for the post, which is only provided above
      // after formatting so add it again here.
      $patterns[] = '/!permalink/';
      $replacements[] = $permalink_url;
    }
263
    $content = $this->Substitute($post_format, $patterns, $replacements);
264
    if ($this->Substitute('post-web-actions') === 'true') {
265
      // Add the base url to the permalink url for web actions.
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
      $permalink_url = $base_url . $permalink_url;
      $content .= '<div class="post-web-actions">' .
        '<indie-action do="like" with="' . $permalink_url . '">' .
          '<a href="#" class="action">' .
            '<span class="ui-icon ui-icon-star"></span>like</a>' .
        '</indie-action>' .
        '<indie-action do="repost" with="' . $permalink_url . '">' .
          '<a href="#" class="action">' .
            '<span class="ui-icon ui-icon-refresh"></span>share</a>' .
        '</indie-action>' .
        '<indie-action do="reply" with="' . $permalink_url . '">' .
          '<a href="#" class="action">' .
            '<span class="ui-icon ui-icon-arrowreturnthick-1-w"></span>' .
            'reply</a>' .
        '</indie-action>' .
        '<span class="indie-config-info">' .
          'Want to share this? Click to choose a site:</span>' .
        '<a href="#" class="indie-config" title="web action settings">' .
284
          '<span class="ui-icon ui-icon-gear"></span>settings</a></div>';
285
286
    }
    return $content;
287
288
  }

289
  public function Copy($id, $new_page, $old_owner, $old_id) {
290
291
292
293
    $title = '';
    $description = '';
    $category = '';
    $enclosure = '';
294
295
    $permalink = '';
    $timestamp = 0;
Mal's avatar
Mal committed
296
    $mysqli = connect_db();
297
298
299
    $query = 'SELECT title, description, author, category, enclosure, ' .
      'permalink, timestamp FROM post WHERE user = "' . $old_owner . '" AND ' .
      'box_id = ' . $old_id;
300
301
    if ($mysqli_result = $mysqli->query($query)) {
      if ($post = $mysqli_result->fetch_assoc()) {
302
303
304
305
306
307
        $title = $mysqli->escape_string($post['title']);
        $description = $mysqli->escape_string($post['description']);
        $author = $mysqli->escape_string($post['author']);
        $category = $mysqli->escape_string($post['category']);
        $enclosure = $mysqli->escape_string($post['enclosure']);
        $permalink = $mysqli->escape_string($post['permalink']);
308
        $timestamp = (int)$post['timestamp'];
Mal's avatar
Mal committed
309
      }
310
      $mysqli_result->close();
Mal's avatar
Mal committed
311
312
    }
    else {
313
      $this->Log('Post->Copy 1: ' . $mysqli->error);
314
315
    }
    // Point the new module at the existing permalink page.
316
317
318
319
    $query = 'INSERT INTO modules VALUES ("' . $this->owner . '", ' .
      '"' . $permalink . '", ' . $id . ', "post", "' . $this->Group() . '", ' .
      $this->user->config->PermalinkOrder() . ', ' .
      '"' . $this->Placement() . '", 0)';
320
    if (!$mysqli->query($query)) {
321
      $this->Log('Post->Copy 2: ' . $mysqli->error);
322
    }
Mal's avatar
Mal committed
323
324
    $mysqli->close();

325
    $this->Insert($id, $author, $new_page, $title, $description, $category,
326
                  $enclosure, $permalink, $timestamp, true);
327
    $this->CopyStyle($id, $old_owner, $old_id);
328
    $this->Notify($id, 'post', 'feed', $new_page, $this->Published($new_page));
329
330
  }

331
  public function Cron() {
332
    set_time_limit(0);
333
334
    $mysqli = connect_db();
    // Published scheduled draft posts.
335
    $publish = [];
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
    $query = 'SELECT user, box_id FROM post_draft WHERE timestamp != 0 AND ' .
      'timestamp <= ' . time();
    if ($mysqli_result = $mysqli->query($query)) {
      while ($post_draft = $mysqli_result->fetch_assoc()) {
        $publish[] = $post_draft;
      }
      $mysqli_result->close();
    }
    else {
      $this->Log('Post->Cron 1: ' . $mysqli->error);
    }
    if (count($publish) > 0) {
      $current_owner = $this->owner;
      foreach ($publish as $draft) {
        $this->owner = $draft['user'];
        $this->PublishDraft($draft['box_id']);
      }
      $this->owner = $current_owner;
    }

    // Process queued webmentions.
357
358
359
    $receive = [];
    $send = [];
    $query = 'SELECT source, target FROM post_queue WHERE source != ""';
360
361
    if ($mysqli_result = $mysqli->query($query)) {
      while ($post_queue = $mysqli_result->fetch_assoc()) {
362
363
        $receive[] = $post_queue;
      }
364
      $mysqli_result->close();
365
366
    }
    else {
367
      $this->Log('Post->Cron 2: ' . $mysqli->error);
368
    }
369
370
    $base_url = $this->user->config->Secure() ? 'https://' : 'http://';
    $base_url .= $this->user->config->ServerName();
371
372
373
374
    foreach ($receive as $webmention) {
      $us_source = $webmention['source'];
      $us_target = $webmention['target'];
      $full_target = $us_target;
375
      if (stripos($full_target, 'http') !== 0) {
376
        $full_target = $base_url . $us_target;
377
378
379
380
381
382
383
384
385
386
387
      }
      list($this->user->page, $this->owner) = page_owner($full_target);
      $result = $this->ProcessReceivedWebmention($us_source, $us_target);
      // The third result parameter indicates if a comment was updated,
      // in which case webmentions are resent for all links in the post.
      if ($result[2]) $send[] = $us_target;
    }
    $already_sent = [];
    foreach ($send as $us_target) {
      if (!in_array($us_target, $already_sent)) {
        $full_target = $us_target;
388
        if (stripos($full_target, 'http') !== 0) {
389
          $full_target = $base_url . $us_target;
390
391
392
        }
        list($this->user->page, $this->owner) = page_owner($full_target);
        if ($this->IsPermalink()) {
393
394
395
          $query = 'SELECT box_id, description, category, feed FROM post ' .
            'WHERE user = "' . $this->owner . '" AND ' .
            'permalink = "' . $this->user->page . '"';
396
397
          if ($mysqli_result = $mysqli->query($query)) {
            if ($post = $mysqli_result->fetch_assoc()) {
398
399
400
              $this->ContentUpdated($post['box_id'], $post['description'],
                                    $post['category'], $post['feed'],
                                    $this->user->page, false);
401
            }
402
            $mysqli_result->close();
403
404
          }
          else {
405
            $this->Log('Post->Cron 3: ' . $mysqli->error);
406
407
408
409
410
411
412
          }
        }
        $already_sent[] = $us_target;
      }
    }
    $query = 'DELETE FROM post_queue';
    if (!$mysqli->query($query)) {
413
      $this->Log('Post->Cron 4: ' . $mysqli->error);
414
    }
415
416
417
418

    // Tombstone posts are removed from the feed here after a week, which
    // should've given readers time to update their cache.
    $tombstone = $mysqli->escape_string($this->Tombstone());
419
420
421
    $query = 'UPDATE modules LEFT JOIN post ON modules.box_id = post.box_id ' .
      'SET deleted = 1 WHERE description = "' . $tombstone . '" ' .
      'AND timestamp < ' . strtotime('-7 days');
422
    if (!$mysqli->query($query)) {
423
      $this->Log('Post->Cron 5: ' . $mysqli->error);
424
    }
425
    $mysqli->close();
426
427
  }

428
  public function Factory($fn, $p = NULL) {
429
430
431
432
433
434
435
    if (is_array($p)) {
      $count = count($p);
      if ($fn === 'ReceiveWebmention' && $count === 2) {
        $us_source = $p[0];
        $us_target = $p[1];
        return $this->ReceiveWebmention($us_source, $us_target);
      }
436
437
438
439
440
441
442
      if ($fn === 'ContentUpdated' && $count === 5) {
        $id = $p[0];
        $data = $p[1];
        $category = $p[2];
        $feed = $p[3];
        $permalink = $p[4];
        return $this->ContentUpdated($id, $data, $category, $feed, $permalink);
443
      }
444
445
446
447
448
      if ($fn === 'Status' && $count === 2) {
        $target = $p[0];
        $me = $p[1];
        return $this->Status($target, $me);
      }
449
450
      return;
    }
451
452
453
    if ($fn === 'Syndicated') {
      return $this->Syndicated($p);
    }
454
455
456
    if ($fn === 'NewComment') {
      return $this->NewComment();
    }
457
458
  }

459
  public function Group() {
460
    return 'post-comment';
461
462
463
  }

  public function IncludeScript() {
464
    return false;
465
466
467
  }

  public function Install($path) {
Mal's avatar
Mal committed
468
    $mysqli = connect_db();
469
470
471
472
473
474
475
476
477
478
479
480
    $query = 'CREATE TABLE IF NOT EXISTS post (' .
      'user VARCHAR(50) NOT NULL,' .
      'box_id INT UNSIGNED NOT NULL,' .
      'title VARCHAR(180),' .
      'description TEXT,' .
      'author VARCHAR(50),' .
      'category VARCHAR(200),' .
      'enclosure TEXT,' .
      'permalink VARCHAR(200),' .
      'feed VARCHAR(200),' .
      'timestamp INT(10) UNSIGNED NOT NULL,' .
      'PRIMARY KEY(user, box_id)' .
481
      ') ENGINE=MyISAM';
Mal's avatar
Mal committed
482
    if (!$mysqli->query($query)) {
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
      $this->Log('Post->Install 1: ' . $mysqli->error);
    }

    $query = 'CREATE TABLE IF NOT EXISTS post_history (' .
      'user VARCHAR(50) NOT NULL,' .
      'box_id INT UNSIGNED NOT NULL,' .
      'title VARCHAR(180),' .
      'description TEXT,' .
      'author VARCHAR(50),' .
      'category VARCHAR(200),' .
      'enclosure TEXT,' .
      'permalink VARCHAR(200),' .
      'feed VARCHAR(200),' .
      'timestamp INT(10) UNSIGNED NOT NULL,' .
      'modified_by VARCHAR(50) NOT NULL,' .
      'PRIMARY KEY(user, box_id, timestamp)' .
499
      ') ENGINE=MyISAM';
Mal's avatar
Mal committed
500
    if (!$mysqli->query($query)) {
501
      $this->Log('Post->Install 2: ' . $mysqli->error);
Mal's avatar
Mal committed
502
    }
503

504
505
506
507
508
    $query = 'CREATE TABLE IF NOT EXISTS post_queue (' .
      'source VARCHAR(200),' .
      'target VARCHAR(200),' .
      'timestamp INT(10) UNSIGNED NOT NULL,' .
      'PRIMARY KEY(source(100), target(100))' .
509
510
      ') ENGINE=MyISAM';
    if (!$mysqli->query($query)) {
511
      $this->Log('Post->Install 3: ' . $mysqli->error);
512
513
    }

514
515
516
517
518
519
520
521
522
523
    $query = 'CREATE TABLE IF NOT EXISTS post_draft (' .
      'user VARCHAR(50) NOT NULL,' .
      'box_id INT UNSIGNED NOT NULL,' .
      'timestamp INT(10) UNSIGNED NOT NULL,' .
      'PRIMARY KEY(user, box_id)' .
      ') ENGINE=MyISAM';
    if (!$mysqli->query($query)) {
      $this->Log('Post->Install 4: ' . $mysqli->error);
    }

524
    $site_style = ['"",".post","padding","20px"',
525
526
                   '"",".post .published","font-size","1.2em"',
                   '"",".post .published","line-height","2em"',
527
                   '"",".post .published time","float","right"',
528
529
530
531
                   '"",".post .published .thumb","width","30px"',
                   '"",".post .published .thumb","border-radius","2px"',
                   '"",".post .published .thumb","float","left"',
                   '"",".post .published .thumb","margin-right","5px"',
532
                   '"",".post .media img","box-shadow","5px 5px 5px #aaaaaa"',
533
                   '"",".post .options > *","padding","5px"',
534
535
                   '"",".post .dobrado-editable","min-height","0"',
                   '"",".post .dobrado-editable","padding","5px"',
536
                   '"",".post .navigation .next","float","right"',
537
                   '"",".post-reposted-by","font-size","0.8em"',
538
                   '"",".post-reposted-by .thumb","display","none"',
539
                   '"",".post-web-actions","display","flex"',
540
541
                   '"",".post-web-actions","padding-top","10px"',
                   '"",".post-web-actions a","padding","10px"',
542
                   '"",".post-web-actions indie-action","flex-grow","1"',
543
544
545
546
                   '"",".post-web-actions indie-action a.highlight",' .
                     '"padding","3px"',
                   '"",".post-web-actions indie-action a.highlight",' .
                     '"border-radius","3px"'];
547
548
    $this->AddSiteStyle($site_style);

549
550
    // The two formats are required because p-name needs to be either on the
    // title or on the same div as e-content.
551
552
    $post_no_title = '<article class="h-entry">' .
      '<div class="published">' .
553
        '!date<span class="h-card p-author">!author</span></div>' .
554
      '<div class="dobrado-editable p-name e-content">!description</div>' .
555
556
      '<div class="media">!media</div><div class="options">!category</div>' .
      '!navigation</article>';
557
558
    $post_with_title = '<article class="h-entry">' .
      '!title<div class="published">' .
559
        '!date<span class="h-card p-author">!author</span></div>' .
560
      '<div class="dobrado-editable e-content">!description</div>' .
561
562
      '<div class="media">!media</div><div class="options">!category</div>' .
      '!navigation</article>';
563
    // The h-entry class is added to a parent element on the permalink page.
564
565
    // The u-comment markup is required on permalink pages to differentiate a
    // microformats comments feed from a single h-entry in a feed.
566
    $permalink_post_no_title = '<article>' .
567
      '<div class="published">' .
568
        '!date<span class="h-card p-author">!author</span></div>' .
569
      '<div class="dobrado-editable p-name e-content">!description</div>' .
570
571
      '<div class="media">!media</div><div class="options">!category</div>' .
      '!navigation</article><div class="u-comment h-entry"></div>';
572
573
    $permalink_post_with_title = '<article>' .
      '!title<div class="published">' .
574
        '!date<span class="h-card p-author">!author</span></div>' .
575
      '<div class="dobrado-editable e-content">!description</div>' .
576
577
      '<div class="media">!media</div><div class="options">!category</div>' .
      '!navigation</article><div class="u-comment h-entry"></div>';
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
    // Reposts need to be marked up using h-cite so need separate templates for
    // all four of the above cases.
    $repost_no_title = '<article class="h-entry">' .
      '<div class="h-cite u-repost-of">' .
        '<div class="published">' .
          '!date<span class="h-card p-author">!author</span></div>' .
        '<div class="dobrado-editable p-name e-content">!description</div>' .
        '<div class="media">!media</div><div class="options">!category</div>' .
      '</div>' .
      '<div class="hidden">' .
        '<span class="h-card p-author">!repost</span>' .
        '<a class="u-url" href="!permalink"></a></div>' .
      '!navigation</article>';
    $repost_with_title = '<article class="h-entry">' .
      '<div class="h-cite u-repost-of">' .
        '!title<div class="published">' .
          '!date<span class="h-card p-author">!author</span></div>' .
        '<div class="dobrado-editable e-content">!description</div>' .
        '<div class="media">!media</div><div class="options">!category</div>' .
      '</div>' .
      '<div class="hidden">' .
        '<span class="h-card p-author">!repost</span>' .
        '<a class="u-url" href="!permalink"></a></div>' .
      '!navigation</article>';
    $permalink_repost_no_title = '<article>' .
      '<div class="h-cite u-repost-of">' .
        '<div class="published">' .
          '!date<span class="h-card p-author">!author</span></div>' .
        '<div class="dobrado-editable p-name e-content">!description</div>' .
        '<div class="media">!media</div><div class="options">!category</div>' .
      '</div>' .
      '<div class="hidden">' .
        '<span class="h-card p-author">!repost</span>' .
        '<a class="u-url" href="!permalink"></a></div>' .
      '!navigation</article><div class="u-comment h-entry"></div>';
    $permalink_repost_with_title = '<article>' .
      '<div class="h-cite u-repost-of">' .
        '!title<div class="published">' .
          '!date<span class="h-card p-author">!author</span></div>' .
        '<div class="dobrado-editable e-content">!description</div>' .
        '<div class="media">!media</div><div class="options">!category</div>' .
      '</div>' .
      '<div class="hidden">' .
        '<span class="h-card p-author">!repost</span>' .
        '<a class="u-url" href="!permalink"></a></div>' .
      '!navigation</article><div class="u-comment h-entry"></div>';
    $template =
      ['"post-no-title", "", "' . $mysqli->escape_string($post_no_title) . '"',
       '"post-with-title", "", ' .
         '"' . $mysqli->escape_string($post_with_title) . '"',
       '"permalink-post-no-title", "", ' .
         '"' . $mysqli->escape_string($permalink_post_no_title) . '"',
       '"permalink-post-with-title", "", ' .
         '"' . $mysqli->escape_string($permalink_post_with_title) . '"',
       '"repost-no-title", "", ' .
         '"' . $mysqli->escape_string($repost_no_title) . '"',
       '"repost-with-title", "", ' .
         '"' . $mysqli->escape_string($repost_with_title) . '"',
       '"permalink-repost-no-title", "", ' .
         '"' . $mysqli->escape_string($permalink_repost_no_title) . '"',
       '"permalink-repost-with-title", "", ' .
         '"' . $mysqli->escape_string($permalink_repost_with_title) . '"',
       '"post-web-actions", "", "true"',
       '"post-webmention-debug", "", "true"'];
Mal's avatar
Mal committed
642
    $mysqli->close();
643

644
    $this->AddTemplate($template);
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
    $description = ['post-no-title' => 'The template for a blog post with no ' .
                      'title on the feed page, substitutes: !date, !author, ' .
                      '!description, !media, !category and !navigation.',
                    'post-with-title' => 'The template for a blog post with ' .
                      'a title on the feed page, substitutes: !title, !date, ' .
                      '!author, !description, !media, !category and ' .
                      '!navigation.',
                    'permalink-post-no-title' => 'The template for a blog ' .
                      'post with no title on the permalink page, ' .
                      'substitutes: !date, !author, !description, !media, ' .
                      '!category and !navigation.',
                    'permalink-post-with-title' => 'The template for a blog ' .
                      'post with a title on the permalink page, substitutes: ' .
                      '!title, !date, !author, !description, !media, ' .
                      '!category and !navigation.',
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
                    'repost-no-title' => 'The template for a repost with no ' .
                      'title on the feed page, substitutes: !date, !author, ' .
                      '!description, !media and !category from the original ' .
                      'post and !repost (author), !permalink and !navigation ' .
                      'from the new post.',
                    'repost-with-title' => 'The template for a repost with ' .
                      'a title on the feed page, substitutes: !title, !date, ' .
                      '!author, !description, !media and !category from the ' .
                      'original post and !repost (author), !permalink and ' .
                      '!navigation from the new post.',
                    'permalink-repost-no-title' => 'The template for a ' .
                      'repost with no title on the permalink page, ' .
                      'substitutes: !date, !author, !description, !media and ' .
                      '!category from the original post and !repost (author),' .
                      ' !permalink and !navigation from the new post.',
                    'permalink-repost-with-title' => 'The template for a ' .
                      'repost with a title on the permalink page, ' .
                      'substitutes: !title, !date, !author, !description, ' .
                      '!media and !category from the original post and ' .
                      '!repost (author), !permalink and !navigation from the ' .
                      'new post.',
681
682
683
684
                    'post-web-actions' => 'true or false to display ' .
                      'indie-action tags on posts.',
                    'post-webmention-debug' => 'true or false to log debug ' .
                      'information when sending and receiving webmentions.'];
685
    $this->AddTemplateDescription($description);
686
687
688
  }

  public function Placement() {
689
    return 'middle';
690
691
  }

692
  public function Publish($id, $update) {
693
    if (isset($id)) {
694
      $this->Notify($id, 'post', 'feed', $this->user->page, $update);
695
696
697
698
    }
    else {
      // When $id is not set the page has been made private. Remove users from
      // any post notifications groups if they don't have access permission.
699
      $this->RemoveNotification('post', $this->user->page);
700
    }
701
702
  }

703
  public function Remove($id) {
Mal's avatar
Mal committed
704
    $mysqli = connect_db();
705
    if (isset($id)) {
706
      $feed = '';
707
      // Copies of this post can point to the same permalink page, so only
708
709
710
711
      // remove the permalink page when there's one reference left. (Note that
      // posts are tombstoned rather than deleted from the database, so need to
      // skip those when looking for copies to count.)
      $tombstone = $mysqli->escape_string($this->Tombstone());
712
      $query = 'SELECT permalink, feed FROM post WHERE ' .
713
714
715
        'user = "' . $this->owner . '" AND ' .
        'description != "' . $tombstone . '" AND ' .
        'permalink = (SELECT permalink FROM post WHERE ' .
716
          'user = "' . $this->owner . '" AND box_id = ' . $id . ')';
717
      if ($mysqli_result = $mysqli->query($query)) {
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
        if ($post = $mysqli_result->fetch_assoc()) {
          if ($mysqli_result->num_rows === 1) {
            // Store the fee to call the reader module below.
            $feed = $post['feed'];
            $this->RemovePermalinkPage($id);
          }
          else {
            $permalink = $post['permalink'];
            // Otherwise just set this module as deleted on the permalink page.
            $query = 'UPDATE modules SET deleted = 1 WHERE ' .
              'box_id = ' . $id . ' AND page = "' . $permalink . '" AND ' .
              'user = "' . $this->owner . '"';
            if (!$mysqli->query($query)) {
              $this->Log('Post->Remove 1: ' . $mysqli->error);
            }
733
734
          }
        }
735
        $mysqli_result->close();
736
737
      }
      else {
738
        $this->Log('Post->Remove 2: ' . $mysqli->error);
739
      }
740
      $this->RemoveNotify($id);
741
      $this->UpdatePager($id);
742
743
744
745
      $query = 'UPDATE post SET title = "", ' .
        'description = "' . $tombstone . '", author = "", category = "", ' .
        'enclosure = "" WHERE user = "' . $this->owner . '" AND ' .
        'box_id = ' . $id;
746
      if (!$mysqli->query($query)) {
747
        $this->Log('Post->Remove 3: ' . $mysqli->error);
748
      }
749
750
751
752
753
      $query = 'DELETE FROM post_draft WHERE user = "' . $this->owner . '" ' .
        'AND box_id = ' . $id;
      if (!$mysqli->query($query)) {
        $this->Log('Post->Remove 4: ' . $mysqli->error);
      }
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
      if ($feed !== '') {
        // Update the feed, which will also notify subscribers.
        $reader = new Module($this->user, $this->owner, 'reader');
        if ($reader->IsInstalled()) {
          $url = $this->user->config->Secure() ? 'https://' : 'http://';
          $url .= $this->user->config->ServerName();
          $page = 'rss/index.php?page=' . $feed;
          $path = $this->owner === 'admin' ? $page : $this->owner . '/' . $page;
          $reader->Factory('UpdateFeed', $url . '/' . $path);
          // Also update the microformats feed and provide the post content as
          // the payload for the update. Content needs the current page to be
          // set to the feed (or permalink) page to return any content.
          $current_page = $this->user->page;
          $this->user->page = $feed;
          $data = $this->Content($id);
          $this->user->page = $current_page;
          $reader->Factory('UpdateFeed', [$url . $this->Url('', $feed), $data]);
        }
      }
773
774
    }
    else {
775
      $query = 'DELETE FROM post WHERE user = "' . $this->owner . '"';
776
      if (!$mysqli->query($query)) {
777
        $this->Log('Post->Remove 5: ' . $mysqli->error);
778
      }
779
      $query = 'DELETE FROM post_history WHERE user = "' . $this->owner . '"';
780
      if (!$mysqli->query($query)) {
781
782
783
784
785
        $this->Log('Post->Remove 6: ' . $mysqli->error);
      }
      $query = 'DELETE FROM post_draft WHERE user = "' . $this->owner . '"';
      if (!$mysqli->query($query)) {
        $this->Log('Post->Remove 7: ' . $mysqli->error);
786
      }
Mal's avatar
Mal committed
787
788
    }
    $mysqli->close();
789
790
791
  }

  public function SetContent($id, $us_content) {
792
    $micropub = isset($this->user->settings['micropub']['endpoint']);
793
    $us_data = $micropub ? ['type' => ['h-entry'], 'properties' => []] : '';
794
    if (isset($us_content['data']) && $us_content['data'] !== '') {
795
796
      if ($this->owner === 'admin') {
        if ($micropub) {
797
          $us_data['properties']['content'] = [['html' => $us_content['data']]];
798
799
800
801
        }
        else {
          $us_data .= $us_content['data'];
        }
802
803
      }
      else {
804
805
806
807
808
809
810
        include_once 'library/HTMLPurifier.auto.php';
        $config = HTMLPurifier_Config::createDefault();
        $config->set('Attr.EnableID', true);
        $config->set('Attr.IDPrefix', 'anchor-');
        // Allow iframes from youtube and vimeo.
        $config->set('HTML.SafeIframe', true);
        $config->set('URI.SafeIframeRegexp',
811
                     '%^(https?:)?//(www\.youtube(?:-nocookie)?\.com/embed/|' .
812
                     'player\.vimeo\.com/video/)%');
813
814
815
816
817
818
        $config->set('HTML.DefinitionID', 'dobrado-post');
        $config->set('HTML.DefinitionRev', 1);
        if ($def = $config->maybeGetRawHTMLDefinition()) {
          $def->addElement('data', 'Inline', 'Inline', 'Common');
          $def->addAttribute('data', 'value', 'CDATA');
        }
819
820
        $purifier = new HTMLPurifier($config);
        if ($micropub) {
821
822
          $us_data['properties']['content'] =
            [['html' => $purifier->purify($us_content['data'])]];
823
824
825
826
        }
        else {
          $us_data .= $purifier->purify($us_content['data']);
        }
827
      }
828
    }
829
    $us_title = isset($us_content['title']) ? $us_content['title'] : '';
830
831
    $us_category = isset($us_content['category']) ?
      $us_content['category'] : '';
832
833
    // If the user has chosen to send the post to twitter, add a link in the
    // post to brid.gy to enable webmentions.
834
    if (isset($us_content['twitter']) && (int)$us_content['twitter'] === 1) {
835
      if ($micropub) {
836
        $us_data['properties']['mp-syndicate-to'] = 'twitter';
837
838
839
840
841
      }
      else {
        $twitter_link = 'https://brid.gy/publish/twitter';
        if (strpos($us_data, $twitter_link) === false) {
          // A link back to the user's site is omitted when the post is less
842
          // than 280 characters and it doesn't have a title (when a title is
843
844
845
846
          // given, only that and the link back are shown). For quote-tweets
          // need to check content up to <cite> tag.
          $us_check = strstr($us_data, '<cite', true);
          if (!$us_check) $us_check = $us_data;
847
          if (strlen(strip_tags($us_check)) < 280 && $us_title === '') {
848
849
            $twitter_link .= '?bridgy_omit_link=true';
          }
850
          $us_data .= '<a href="' . $twitter_link . '"></a>';
851
        }
852
853
      }
    }
854
855
    $us_enclosure = '';
    if (isset($us_content['enclosure'])) {
856
      if ($micropub) {
857
        $us_data['properties']['photo'] = $us_content['enclosure'];
858
859
      }
      else {
860
        $us_enclosure = json_encode($us_content['enclosure']);
861
862
      }
    }
863
864
865
866
    $us_author = $this->user->name;
    if (isset($us_content['author'])) {
      $us_author = htmlspecialchars(strip_tags($us_content['author']));
    }
867
    $action = '';
868
869
870
    // If a webaction is set, make sure it's marked up correctly.
    if (isset($us_content['webactionType']) &&
        isset($us_content['webactionUrl'])) {
871
      $us_url = htmlspecialchars($us_content['webactionUrl']);
872
      $us_type = $us_url === '' ? '' : $us_content['webactionType'];
873
      if ($us_type === 'like') {
874
875
876
877
878
879
        $action = 'like-of';
        if (!$micropub) {
          $like_of = ',Like of,';
          if (strpos($us_category, $like_of) === false) {
            $us_category .= $like_of . $us_url;
          }
880
881
882
883
          // Prevent creating an empty post below.
          if ($us_data === '') {
            $us_data = '<a href="' . $us_url . '">' . $us_url . '</a>';
          }
884
        }
885
886
      }
      else if ($us_type === 'share') {
887
        $action = 'repost-of';
888
889
        if (!$micropub) {
          // The author is kept as that of the original post, so show the user
890
891
892
893
894
          // who is sharing it as a category. Make sure it's not already added.
          $reposted_by = ',reposted by ' . $this->user->name . ',';
          if (strpos($us_category, $reposted_by) === false) {
            $us_category .= $reposted_by . $us_url;
          }
895
896
897
          if ($us_data === '') {
            $us_data = '<a href="' . $us_url . '">' . $us_url . '</a>';
          }
898
899
900
        }
      }
      else if ($us_type === 'reply' || $us_type === 'reply to') {
901
        $action = 'in-reply-to';
902
903
904
        if (!$micropub) {
          // Add the url as a category for our permalink post so that it doesn't
          // need to be visible in the content of the comment.
905
906
907
908
          $in_reply_to = ',In reply to,';
          if (strpos($us_category, $in_reply_to) === false) {
            $us_category .= $in_reply_to . $us_url;
          }
909
910
911
          if ($us_data === '') {
            $us_data = '<a href="' . $us_url . '">' . $us_url . '</a>';
          }
912
        }
913
      }
914
915
916
917
918
919
920
      // Store the author in the nickname cache so their details can be used
      // when Content is called. The reader module is used because if the url
      // is not already in the nickname cache it's fetched using microsub.
      $reader = new Module($this->user, $this->owner, 'reader');
      if ($reader->IsInstalled()) {
        $reader->Factory('Nickname', [$us_url, $us_author]);
      }
921
    }
922
    if ($micropub) {
923
      if ($us_title !== '') $us_data['properties']['name'] = $us_title;
924
      if ($action !== '') $us_data['properties'][$action] = $us_url;
925
926
      if ($us_category !== '') {
        foreach (explode(',', $us_category) as $tag) {
927
          $us_data['properties']['category'][] = $tag;
928
929
        }
      }
930
931
      if (isset($us_content['paymentType']) &&
          isset($us_content['paymentAmount'])) {
932
        $us_payment = $us_content['paymentType'];
933
934
935
936
937
938
939
        if ($us_payment === 'to') {
          $us_data['properties']['debit'] = $us_content['paymentAmount'];
        }
        else if ($us_payment === 'from') {
          $us_data['properties']['credit'] = $us_content['paymentAmount'];
        }
      }
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
      $micropub_config = isset($this->user->settings['micropub']['config']) ?
        json_decode($this->user->settings['micropub']['config'], true) : [];
      if (isset($us_content['syndicate']) &&
          count($us_content['syndicate']) > 0) {
        // The syndicate-to uid's are modified for use in html, so fetch them
        // again here to compare to the values set in content.
        foreach ($us_content['syndicate'] as $syndicate) {
          foreach ($micropub_config['syndicate-to'] as $syndicate_option) {
            if (isset($syndicate_option['uid'])) {
              $id = $syndicate_option['uid'];
              $check = preg_replace('/[[:^alnum:]]/', '', $id);
              if ($check === $syndicate) {
                if (!isset($us_data['properties']['mp-syndicate-to'])) {
                  $us_data['properties']['mp-syndicate-to'] = '';
                }
                if ($us_data['properties']['mp-syndicate-to'] !== '') {
                  $us_data['properties']['mp-syndicate-to'] .= ',';
                }
                $us_data['properties']['mp-syndicate-to'] .= $id;
              }
            }
          }
        }
      }
      if (isset($us_content['destination']) &&
          count($us_content['destination']) > 0) {
        // destination uid's are used the same as above.
        foreach ($us_content['destination'] as $destination) {
          foreach ($micropub_config['destination'] as $destination_option) {
            if (isset($destination_option['uid'])) {
              $id = $destination_option['uid'];
              $check = preg_replace('/[[:^alnum:]]/', '', $id);
              if ($check === $destination) {
                if (!isset($us_data['properties']['mp-destination'])) {
                  $us_data['properties']['mp-destination'] = '';
                }
                if ($us_data['properties']['mp-destination'] !== '') {
                  $us_data['properties']['mp-destination'] .= ',';
                }
                $us_data['properties']['mp-destination'] .= $id;
              }
            }
          }
        }
      }
985
986
987
988
989
      $this->Micropub($us_data);
      return;
    }

    $mysqli = connect_db();
990
991
    // Titles can be a maximum of 180 characters when stored in the database.
    $title = $mysqli->escape_string(substr($us_title, 0, 180));
992
    $description = $mysqli->escape_string($us_data);
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
    $author = $mysqli->escape_string($us_author);
    // If in single user mode and author matches a preferred username in
    // account settings then use that.
    if ($this->Substitute('account-single-user') === 'true') {
      $query = 'SELECT value FROM settings WHERE user = "' . $author . '" ' .
        'AND label = "account" AND name = "username"';
      if ($mysqli_result = $mysqli->query($query)) {
        if ($settings = $mysqli_result->fetch_assoc()) {
          $author = $mysqli->escape_string($settings['value']);
        }
        $mysqli_result->close();
      }
      else {
        $this->Log('Post->SetContent 1: ' . $mysqli->error);
      }
1008
    }
1009
    $category = $mysqli->escape_string($us_category);
1010
    $enclosure = $mysqli->escape_string($us_enclosure);
1011
1012
    $permalink = '';
    $old_title = '';
1013
    $us_old_description = '';
1014
1015
    $old_author = '';
    $old_category = '';
1016
    $us_old_category = '';
1017
1018
1019
    $old_enclosure = '';
    $old_permalink = '';
    $feed = '';
1020
1021
1022
    $query = 'SELECT title, description, author, category, enclosure, ' .
      'permalink, feed FROM post WHERE user = "' . $this->owner . '" AND ' .
      'box_id = ' . $id;
1023
1024
    if ($mysqli_result = $mysqli->query($query)) {
      if ($post = $mysqli_result->fetch_assoc()) {
1025
        $old_title = $mysqli->escape_string($post['title']);
1026
        $us_old_description = $post['description'];
1027
        $old_author = $mysqli->escape_string($post['author']);
1028
1029
        $us_old_category = $post['category'];
        $old_category = $mysqli->escape_string($us_old_category);
1030
1031
        $old_enclosure = $mysqli->escape_string($post['enclosure']);
        $old_permalink = $mysqli->escape_string($post['permalink']);
1032
        $permalink = $old_permalink;
1033
        $feed = $mysqli->escape_string($post['feed']);
Mal's avatar
Mal committed
1034
      }
1035
      $mysqli_result->close();
Mal's avatar
Mal committed
1036
1037
    }
    else {
1038
      $this->Log('Post->SetContent 2: ' . $mysqli->error);
1039
1040
    }
    // If only data was sent, use the previous values for other fields.
1041
    if (!isset($us_content['dataOnly']) || $us_content['dataOnly'] !== false) {
1042
1043
1044
      $title = $old_title;
      $author = $old_author;
      $category = $old_category;
1045
      $enclosure = $old_enclosure;
1046
    }