$ git clone http://thingshare.ion.nu/thingshare.git
commit 5123de7cca2dca7a48fc28442d61ce6987d92bd0
Author: Alicia <...>
Date:   Sun Nov 1 17:32:51 2020 +0100

    Added support for comments.

diff --git a/admin_moderate.php b/admin_moderate.php
index 9d8e4d6..91f8b62 100644
--- a/admin_moderate.php
+++ b/admin_moderate.php
@@ -30,6 +30,7 @@ if(isset($_POST['report']) && isset($_POST['action']) && checknonce())
   $row=mysqli_fetch_assoc($res);
   if(!$row){die('<div class="error"><h1>Report not found</h1></div>');}
   $target=$row['target'];
+  $splittarget=explode('/', $target);
   $reason=$row['reason'];
   $action='';
   switch($_POST['action'])
@@ -41,9 +42,17 @@ if(isset($_POST['report']) && isset($_POST['action']) && checknonce())
       mysqli_query($db, 'update things set removed=true where thingid='.$id);
       $action='deleted thing';
       break;
+    case 'deletecomment':
+      if($splittarget[0]!='comment' || !is_numeric($splittarget[1]) || !is_numeric($splittarget[2])){die('<div class="error"><h1>Not a comment</h1></div>');}
+      $id=(int)$splittarget[2];
+      // Non-destructive removal
+      mysqli_query($db, 'update comments set removed=true where id='.$id);
+      $action='deleted comment';
+      break;
     case 'ban':
       // Track down user
       if(substr($target,0,6)!='thing/' || !is_numeric(substr($target,6))){die('<div class="error"><h1>Not a thing</h1></div>');} // TODO: Handle comments
+      // The problem with comments is commenters can be from other nodes and we can't ban on other nodes. maybe we need a remotebans table? In the meantime user-level blocking might be enough
       $id=(int)substr($target,6);
       $res=mysqli_query($db, 'select user from things where id='.$id);
       $user=(int)mysqli_fetch_row($res)[0];
@@ -77,7 +86,14 @@ $res=mysqli_query($db, 'select user, timestamp, action, target from moderationlo
 while($row=mysqli_fetch_assoc($res))
 {
 // TODO: Figure out timezones
-  $modlog.=$row['timestamp'].' <...>'.db_getuser($row['user']).'</a> '.$row['action'].' on <...>'.$row['target'].'</a><br />';
+  $linksplit=explode('/', $row['target']);
+  if($linksplit[0]=='comment')
+  {
+    $link=htmlentities('thing/'.$linksplit[1].'@'.DOMAIN.'#comment'.$linksplit[2]);
+  }else{
+    $link=htmlentities(implode('/', $linksplit).'@'.DOMAIN);
+  }
+  $modlog.=$row['timestamp'].' <...>'.db_getuser($row['user']).'</a> '.$row['action'].' on <a href="'.BASEURL.'/'.$link.'">'.$row['target'].'</a><br />';
 }
 if(mysqli_num_rows($res)==20){$modlog.='TODO: Paging?<br />';}
 
@@ -89,16 +105,28 @@ while($row=mysqli_fetch_assoc($res))
   $id=$row['id'];
   $user=htmlentities($row['user']);
   $subject=htmlentities($row['target']);
+  $linksplit=explode('/', $row['target']);
+  if($linksplit[0]=='comment')
+  {
+    $link=htmlentities('thing/'.$linksplit[1].'@'.DOMAIN.'#comment'.$linksplit[2]);
+  }else{
+    $link=htmlentities(implode('/', $linksplit).'@'.DOMAIN);
+  }
   $reason=htmlentities($row['reason']);
   $timestamp=htmlentities($row['timestamp']);
   $reports.='<tr><td><a href="'.BASEURL.'/user/'.$user.'">'.$user.'</a></td>';
-  $reports.='<td><...>'.$subject.'</a></td>';
+  $reports.='<td><a href="'.BASEURL.'/'.$link.'">'.$subject.'</a></td>';
   $reports.='<td>'.$reason.'</td>';
   $reports.='<td>'.$timestamp.'</td>';
   $reports.='<td><form method="post">'.nonce();
   $reports.='<input type="hidden" name="report" value="'.$id.'" />';
-  $reports.='<button name="action" value="deletething">'._('Delete thing').'</button>';
-  $reports.='<button name="action" value="ban">'._('Ban user').'</button>';
+  if($linksplit[0]=='comment')
+  {
+    $reports.='<button name="action" value="deletecomment">'._('Delete comment').'</button>';
+  }else{
+    $reports.='<button name="action" value="deletething">'._('Delete thing').'</button>';
+    $reports.='<button name="action" value="ban">'._('Ban user').'</button>';
+  }
   $reports.='<button name="action" value="deletereport">X</button>';
   $reports.='</form></td></tr>';
 }
diff --git a/db.php b/db.php
index 5fca0d3..22e7d16 100644
--- a/db.php
+++ b/db.php
@@ -122,6 +122,13 @@ function db_create_tables()
   mysqli_query($db, 'create table loginfails(ip varchar(256), timestamp datetime);');
   mysqli_query($db, 'create table tags(id integer primary key auto_increment, name text, blacklist boolean);');
   mysqli_query($db, 'create table tagmaps(tag integer, thing integer);');
+  mysqli_query($db, 'create table comments(id integer primary key auto_increment,
+    thing integer,
+    sender text,
+    replyto integer,
+    message text,
+    sent datetime,
+    removed boolean);');
 }
 
 function db_getuser($id)
diff --git a/index.php b/index.php
index b45be62..082f6a1 100644
--- a/index.php
+++ b/index.php
@@ -71,6 +71,7 @@ switch($path[1])
       case 'report': include('rpc_report.php'); exit();
       case 'messages': include('rpc_messages.php'); exit();
       case 'peers': include('rpc_peers.php'); exit();
+      case 'comments': include('rpc_comments.php'); exit();
     }
     header('HTTP/1.1 404 Not found');
     die('{"httpresponse":404,"error":"Not found"}');
diff --git a/messages.php b/messages.php
index d0592d2..74a98a0 100644
--- a/messages.php
+++ b/messages.php
@@ -23,17 +23,6 @@ if(!isset($_SESSION['id'])){header('Location: '.BASEURL.'/login?returnto='.urlen
 include_once('db.php');
 include_once('nonce.php');
 include_once('rpc.php');
-function getdisplayname($username)
-{
-  static $cache=Array();
-  if(isset($cache[$username])){return $cache[$username];}
-  $user=explode('@', $username);
-  if(count($user)!=2){return $username;} // Invalid username, fall back on username
-  $obj=rpc_get($user[1], 'user/'.$user[0]);
-  if(isset($obj['error'])){return $username;} // RPC error, fall back on username
-  $cache[$username]=$obj['displayname'];
-  return $obj['displayname'];
-}
 $error='';
 $info='';
 // Resolve chain ID (and username of second party)
diff --git a/report.php b/report.php
index 74a88ac..54ceead 100644
--- a/report.php
+++ b/report.php
@@ -23,10 +23,9 @@ if(!isset($_SESSION['id'])){header('Location: '.BASEURL.'/login?returnto='.urlen
 include_once('nonce.php');
 include_once('rpc.php');
 $target=$path[2];
-// TODO: See if this works well for comments too, only things and comments need reports
-$item=explode('@',$path[3]);
-$path[3]=$item[0];
 for($i=3; $i<count($path); ++$i){$target.='/'.$path[$i];}
+$item=explode('@',$target);
+$target=$item[0];
 if(checknonce() && isset($_POST['reason']))
 {
   $reason=$_POST['reason'];
@@ -45,8 +44,8 @@ if(checknonce() && isset($_POST['reason']))
 }
 
 // Get the name of the target
-$obj=rpc_get($item[1], $path[2].'/'.$item[0]);
-$targetname=htmlentities($target.'@'.$item[1].' ('.$obj['name'].')');
+$obj=rpc_get($item[1], 'thing/'.explode('@',$path[3])[0]);
+$targetname=htmlentities($target.'@'.$item[1].' ('.($path[2]=='thing'?'':'on ').$obj['name'].')');
 ?>
 <form method="post">
   <?=nonce()?>
diff --git a/rpc.php b/rpc.php
index 68997ec..795e7c0 100644
--- a/rpc.php
+++ b/rpc.php
@@ -54,8 +54,9 @@ function rpc_cache($domain, $rpc, $content)
   global $db;
   $timestamp=mysqli_real_escape_string($db, date('Y-m-d H:i:s'));
   $hash=hash(HASH, $domain.'/'.$rpc);
-  $content=mysqli_real_escape_string($db, $content);
   mysqli_query($db, 'delete from rpccache where hash="'.$hash.'"'); // Erase duplicate (avoids primary key duplication)
+  if($content===false){return;}
+  $content=mysqli_real_escape_string($db, $content);
   mysqli_query($db, 'insert into rpccache(hash, timestamp, cache) values("'.$hash.'", "'.$timestamp.'", "'.$content.'")');
 }
 
@@ -225,4 +226,16 @@ function rpc_verifypost(&$peer)
   }
   return $obj;
 }
+
+function getdisplayname($username)
+{
+  static $cache=Array();
+  if(isset($cache[$username])){return $cache[$username];}
+  $user=explode('@', $username);
+  if(count($user)!=2){return $username;} // Invalid username, fall back on username
+  $obj=rpc_get($user[1], 'user/'.$user[0]);
+  if(isset($obj['error'])){return $username;} // RPC error, fall back on username
+  $cache[$username]=$obj['displayname'];
+  return $obj['displayname'];
+}
 ?>
diff --git a/rpc_comments.php b/rpc_comments.php
new file mode 100644
index 0000000..37ac376
--- /dev/null
+++ b/rpc_comments.php
@@ -0,0 +1,95 @@
+<...>
+
+    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 <https://www.gnu.org/licenses/>.
+*/
+include_once('rpc.php');
+include_once('db.php');
+$thing=(int)$path[3];
+if(isset($_POST['data'])) // Posting a comment
+{
+  $obj=rpc_verifypost($peer);
+  // Find thing's user (and check that thing exists)
+  $res=mysqli_query($db, 'select user from things where thingid='.$thing.' limit 1');
+  $res=mysqli_fetch_row($res);
+  if(!$res){header('HTTP/1.1 404 Not found'); die('{"error":"Thing not found"}');}
+  // Prepare values for DB
+  $user=(int)$res[0];
+  $sender=mysqli_real_escape_string($db, $obj['from'].'@'.$peer);
+  $msg=mysqli_real_escape_string($db, $obj['message']);
+  $replyto=(int)$obj['replyto'];
+  $timestamp=mysqli_real_escape_string($db, date('Y-m-d H:i:s'));
+  // Check that $user isn't blocking $sender
+  $res=mysqli_query($db, 'select user from userblocks where user='.$user.' and blocked="'.$sender.'" limit 1');
+  if(mysqli_fetch_row($res))
+  {
+    die('{"error":"Communication blocked by user"}');
+  }
+  if($replyto) // Check that the replied-to comment exists (if it's a reply)
+  {
+    $res=mysqli_query($db, 'select id from comments where id='.$replyto);
+    if(!mysqli_fetch_row($res))
+    {
+      header('HTTP/1.1 404 Not found');
+      die('{"error":"Replied-to comment not found"}');
+    }
+  }
+  // Store message
+  $q='insert into comments(thing, sender, replyto, message, sent, removed) ';
+  $q.='values('.$thing.', "'.$sender.'", '.$replyto.', "'.$msg.'", "'.$timestamp.'", false)';
+  if(mysqli_query($db, $q))
+  {
+    print('{"status":"OK"}');
+  }else{
+    print('{"error":"Database error"}');
+  }
+}else{ // Get comments
+  $comments=Array();
+  $replies=Array();
+  function resolvereplies($id)
+  {
+    global $replies;
+    $r=Array();
+    foreach($replies as $i)
+    {
+      if($i['replyto']==$id)
+      {
+        $i['replies']=resolvereplies($i['id']);
+        $r[]=$i;
+      }
+    }
+    return $r;
+  }
+  $res=mysqli_query($db, 'select id, sender, replyto, message, sent, removed from comments where thing='.$thing.' order by sent asc');
+  while($row=mysqli_fetch_assoc($res))
+  {
+    if($row['removed'])
+    {
+      $row['sender']='removed';
+      $row['message']='removed';
+    }
+    if($row['replyto']==0)
+    {
+      $comments[]=$row;
+    }else{
+      $replies[]=$row;
+    }
+  }
+  foreach($comments as $i=>$c){$comments[$i]['replies']=resolvereplies($comments[$i]['id']);}
+  print(json_encode($comments));
+}
+?>
diff --git a/thing.php b/thing.php
index 25eb61a..f435ddd 100644
--- a/thing.php
+++ b/thing.php
@@ -19,6 +19,7 @@
 */
 include_once('head.php');
 include_once('db.php');
+include_once('nonce.php');
 include_once('rpc.php');
 $thing=explode('@',$path[2]);
 $thingobj=rpc_get($thing[1], 'thing/'.$thing[0]);
@@ -65,9 +66,101 @@ foreach($thingobj['tags'] as $tag)
   $tag=htmlentities($tag);
   $tags.=' <a href="'.BASEURL.'/tag/'.$tag.'">'.$tag.'</a>';
 }
+// Check if we're blocking the user (can't comment on things of people you're blocking)
+$by_esc=mysqli_real_escape_string($db, $thingobj['by']['name']);
+$res=mysqli_query($db, 'select user from userblocks where user='.(int)$_SESSION['id'].' and blocked="'.$by_esc.'" limit 1');
+$blocked=mysqli_fetch_row($res);
+$info='';
+$error='';
+if(isset($_POST['msg']) && checknonce())
+{
+  if($blocked){$error=_('Cannot send messages to blocked users');}else{
+    // Send it to thing's node
+    $msg=Array('from'=>$_SESSION['name'],
+               'message'=>$_POST['msg'],
+               'replyto'=>$_POST['replyto']);
+    $data=rpc_post($thing[1], 'comments/'.$thing[0], $msg);
+    if(isset($data['error']))
+    {
+      $error=$data['error'];
+    }else{
+      rpc_cache($thing[1], 'comments/'.$thing[0], false); // Invalidate cache to show new comments
+      $info=_('Comment posted');
+    }
+  }
+}
+$comments=rpc_get($thing[1], 'comments/'.$thing[0]);
+include_once('parsedown/Parsedown.php');
+$md=new Parsedown();
+function commenthasreply($comment) // Search for undeleted replies
+{
+  foreach($comment['replies'] as $reply)
+  {
+    if(substr_count($reply['sender'], '@')>0){return true;}
+    if(commenthaschild($reply)){return true;}
+  }
+  return false;
+}
+function printcomments($comments, $level=0)
+{
+  global $md;
+  global $thing;
+  foreach($comments as $comment)
+  {
+    $sender=htmlentities($comment['sender']);
+    $senderhref='href="'.BASEURL.'/user/'.$sender.'"';
+    if(substr_count($sender, '@')>0)
+    {
+      $displayname=htmlentities(getdisplayname($comment['sender']));
+    }else{
+      if(!commenthasreply($comment)){continue;} // Don't print fully deleted chains
+      $senderhref='';
+      $displayname=$sender;
+    }
+    $msg=$md->text(htmlentities($comment['message']));
+    $time=htmlentities($comment['sent']);
+    print('<div class="message" style="margin-left:'.($level*15).'px;" data-id="'.$comment['id'].'"><div class="message_sender"><a '.$senderhref.' title="'.$sender.'" name="comment'.$comment['id'].'">'.$displayname.'</a> <span class="time">'.$time.'</span></div><span>'.$msg.'</span><p>');
+    if(substr_count($sender, '@')>0 && isset($_SESSION['id']))
+    {
+      print('<a href="#" onclick="replyto(this.parentElement.parentElement); return false;">'._('Reply').'</a>');
+      print(' - <...>Report</a>');
+    }
+    print('</p></div>');
+    printcomments($comment['replies'], $level+1);
+  }
+}
+if($error!=''){$info='<span class="error">'.$error.'</span>';}
 ?>
+<script>
+<!--
+function replyto(comment)
+{
+  document.getElementById('replyto').value=comment.dataset.id;
+  document.getElementById('replyindicator').innerHTML='Replying to:<br />'+comment.children[1].innerHTML;
+  document.getElementById('commentmsg').focus();
+}
+// -->
+</script>
 <h1><?=$name?> <small class="subheader">by <?=$by?></small></h1>
 <small><?=sprintf(_('Published on %s under the license %s'), '<span class="time">'.htmlentities($thingobj['date']).'</span>', $license)?></small><br />
 <?=$description?><br />
-Tags: <?=$tags?><br />
+<?=_('Tags:')?> <?=$tags?><br />
 <?=$files?>
+<h2><?=_('Comments')?></h2>
+<?=$info?>
+<?=printcomments($comments)?>
+<?php if(isset($_SESSION['id'])){ ?>
+<script src="<?=BASEURL?>/mdjs/mdjs.js"></script>
+<form method="post" action="<?=BASEURL?>/thing/<?=$path[2]?>">
+  <p>
+    <?=nonce()?>
+    <?php if(!$blocked){ ?>
+    <input type="hidden" id="replyto" name="replyto" value="" />
+    <div id="replyindicator"></div>
+    <textarea rows="4" style="width:100%;" id="commentmsg" name="msg" onchange="document.getElementById('mdpreview').innerHTML='Markdown preview:<br />'+Mdjs.md2html(this.value.replace(/&/g,'&amp;amp;').replace(/</g,'&amp;lt;'));" onkeyup="this.onchange();"></textarea>
+    <div id="mdpreview"></div>
+    <button><?=_('Send')?></button>
+    <?php } ?>
+  </p>
+</form>
+<?php } ?>