diff --git a/chat.php b/chat.php index 10f7903..c435c49 100644 --- a/chat.php +++ b/chat.php @@ -99,6 +99,10 @@ function route(): void }elseif($_REQUEST['action']==='view'){ check_session(); send_messages(); + }elseif($_REQUEST['action']==='like'){ + check_session(); + toggle_like(); + send_messages(); }elseif($_REQUEST['action']==='redirect' && !empty($_GET['url'])){ send_redirect($_GET['url']); }elseif($_REQUEST['action']==='wait'){ @@ -473,6 +477,7 @@ function prepare_stylesheets(string $class): void $styles['messages'] .= '.msg{max-height:180px;overflow-y:auto} #bottom_link{position:fixed;top:0.5em;right:0.5em} #top_link{position:fixed;bottom:0.5em;right:0.5em} '; $styles['messages'] .= '#chatters th,#chatters td{vertical-align:top} a img{width:15%} a:hover img{width:35%}'; $styles['messages'] .= '#messages{word-wrap:break-word}'; + $styles['messages'] .= '.msgactions form{display:inline}.msgactions input{font-size:smaller}.replyref{font-size:smaller}'; } $css=get_setting('css'); $coltxt=get_setting('coltxt'); @@ -2078,19 +2083,37 @@ function send_notes(int $type): void echo '

'._('Public notes').'

'; $hiddendo=hidden('do', 'public'); } - if(isset($_POST['text'])){ - if(MSGENCRYPTED){ - try { - $_POST['text']=base64_encode(sodium_crypto_aead_aes256gcm_encrypt($_POST['text'], '', AES_IV, ENCRYPTKEY)); - } catch (SodiumException $e){ - send_error($e->getMessage()); + $notice=''; + if(isset($_POST['save_notes']) && isset($_POST['text'])){ + $save=true; + if($type===2){ + $password=$_POST['note_password'] ?? ''; + $confirm=$_POST['note_password_confirm'] ?? ''; + if($password===''){ + $notice=''._('Please enter a note password.').' '; + $save=false; + }elseif($confirm!=='' && $password!==$confirm){ + $notice=''._('Password confirmation does not match!').' '; + $save=false; + }else{ + $_POST['text']=encrypt_personal_note($_POST['text'], $password); } } - $time=time(); - $stmt=$db->prepare('INSERT INTO ' . PREFIX . 'notes (type, lastedited, editedby, text) VALUES (?, ?, ?, ?);'); - $stmt->execute([$type, $time, $U['nickname'], $_POST['text']]); - echo ''._('Notes saved!').' '; + if($save){ + if(MSGENCRYPTED){ + try { + $_POST['text']=base64_encode(sodium_crypto_aead_aes256gcm_encrypt($_POST['text'], '', AES_IV, ENCRYPTKEY)); + } catch (SodiumException $e){ + send_error($e->getMessage()); + } + } + $time=time(); + $stmt=$db->prepare('INSERT INTO ' . PREFIX . 'notes (type, lastedited, editedby, text) VALUES (?, ?, ?, ?);'); + $stmt->execute([$type, $time, $U['nickname'], $_POST['text']]); + $notice=''._('Notes saved!').' '; + } } + echo $notice; $dateformat=get_setting('dateformat'); if(($type!==2) && ($type !==3)){ $stmt=$db->prepare('SELECT COUNT(*) FROM ' . PREFIX . 'notes WHERE type=?;'); @@ -2124,9 +2147,30 @@ function send_notes(int $type): void send_error($e->getMessage()); } } + if($type===2){ + if(is_personal_note_encrypted($note['text'])){ + if(isset($_POST['note_password']) && $_POST['note_password']!==''){ + $decrypted=decrypt_personal_note($note['text'], $_POST['note_password']); + if($decrypted===false){ + echo ''._('Wrong note password.').' '; + $note['text']=''; + }else{ + $note['text']=$decrypted; + } + }else{ + echo ''._('Enter your note password to view or edit encrypted personal notes.').' '; + $note['text']=''; + } + } + } echo "

".form('notes'); echo "$hiddendo
'; - echo submit(_('Save notes')).'
'; + if($type===2){ + echo ' '; + echo '
'; + echo submit(_('View notes'), 'name="view_notes"').' '; + } + echo submit(_('Save notes'), 'name="save_notes"').'
'; if($num[0]>1){ echo '
'; if($revision<$num[0]-1){ @@ -2142,6 +2186,57 @@ function send_notes(int $type): void print_end(); } +function encrypt_personal_note(string $text, string $password): string +{ + if(!extension_loaded('sodium')){ + send_fatal_error(sprintf(_('The %s extension of PHP is required for encrypted personal notes. Please install it first.'), 'sodium')); + } + try { + $salt=random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES); + $nonce=random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $key=sodium_crypto_pwhash(SODIUM_CRYPTO_SECRETBOX_KEYBYTES, $password, $salt, SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE, SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE); + $cipher=sodium_crypto_secretbox($text, $nonce, $key); + sodium_memzero($key); + return 'LCNOTE1:'.base64_encode(json_encode([ + 'salt'=>base64_encode($salt), + 'nonce'=>base64_encode($nonce), + 'cipher'=>base64_encode($cipher), + ])); + } catch (SodiumException $e){ + send_error($e->getMessage()); + } catch (Exception $e){ + send_error($e->getMessage()); + } + return ''; +} + +function decrypt_personal_note(string $payload, string $password) +{ + if(!extension_loaded('sodium')){ + send_fatal_error(sprintf(_('The %s extension of PHP is required for encrypted personal notes. Please install it first.'), 'sodium')); + } + if(!is_personal_note_encrypted($payload)){ + return $payload; + } + $data=json_decode(base64_decode(substr($payload, 8)), true); + if(!is_array($data) || !isset($data['salt'], $data['nonce'], $data['cipher'])){ + return false; + } + try { + $key=sodium_crypto_pwhash(SODIUM_CRYPTO_SECRETBOX_KEYBYTES, $password, base64_decode($data['salt']), SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE, SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE); + $plain=sodium_crypto_secretbox_open(base64_decode($data['cipher']), base64_decode($data['nonce']), $key); + sodium_memzero($key); + return $plain; + } catch (SodiumException $e){ + return false; + } +} + +function is_personal_note_encrypted(string $text): bool +{ + return strpos($text, 'LCNOTE1:')===0; +} + function send_approve_waiting(): void { global $db; @@ -2252,8 +2347,16 @@ function send_post(string $rejected=''): void if(!isset($_REQUEST['sendto'])){ $_REQUEST['sendto']=''; } + $reply_to=get_valid_reply_id($_REQUEST['reply_to'] ?? 0); + if($reply_to>0 && $_REQUEST['sendto']===''){ + $_REQUEST['sendto']=get_reply_sendto($reply_to); + } echo '
'._('Revisions:').'
'.form('post'); echo hidden('postid', $U['postid']); + if($reply_to>0){ + echo hidden('reply_to', (string) $reply_to); + echo '
'.sprintf(_('Replying to message #%d'), $reply_to).'
'; + } if(isset($_POST['multi'])){ echo hidden('multi', 'on'); } @@ -3870,7 +3973,8 @@ function validate_input() : string { $message=sprintf(get_setting('msgattache'), "$name", $message); } } - if(add_message($message, $recipient, $U['nickname'], (int) $U['status'], $poststatus, $displaysend, $U['style'])){ + $reply_to=get_valid_reply_id($_POST['reply_to'] ?? 0); + if(add_message($message, $recipient, $U['nickname'], (int) $U['status'], $poststatus, $displaysend, $U['style'], $reply_to)){ $U['lastpost']=time(); try { $U[ 'postid' ] = bin2hex( random_bytes( 3 ) ); @@ -4043,7 +4147,7 @@ function apply_mention(string $message) : string { }, $message); } -function add_message(string $message, string $recipient, string $poster, int $delstatus, int $poststatus, string $displaysend, string$style) : bool { +function add_message(string $message, string $recipient, string $poster, int $delstatus, int $poststatus, string $displaysend, string $style, int $reply_to=0) : bool { global $db; if($message===''){ return false; @@ -4054,11 +4158,12 @@ function add_message(string $message, string $recipient, string $poster, int $de 'poster' =>$poster, 'recipient' =>$recipient, 'text' =>"$displaysend".style_this($message, $style).'', - 'delstatus' =>$delstatus + 'delstatus' =>$delstatus, + 'reply_to' =>$reply_to ]; //prevent posting the same message twice, if no other message was posted in-between. - $stmt=$db->prepare('SELECT id FROM ' . PREFIX . 'messages WHERE poststatus=? AND poster=? AND recipient=? AND text=? AND id IN (SELECT * FROM (SELECT id FROM ' . PREFIX . 'messages ORDER BY id DESC LIMIT 1) AS t);'); - $stmt->execute([$newmessage['poststatus'], $newmessage['poster'], $newmessage['recipient'], $newmessage['text']]); + $stmt=$db->prepare('SELECT id FROM ' . PREFIX . 'messages WHERE poststatus=? AND poster=? AND recipient=? AND text=? AND reply_to=? AND id IN (SELECT * FROM (SELECT id FROM ' . PREFIX . 'messages ORDER BY id DESC LIMIT 1) AS t);'); + $stmt->execute([$newmessage['poststatus'], $newmessage['poster'], $newmessage['recipient'], $newmessage['text'], $newmessage['reply_to']]); if($stmt->fetch(PDO::FETCH_NUM)){ return false; } @@ -4078,7 +4183,8 @@ function add_system_message(string $mes, string $doer): void 'poster' =>'', 'recipient' =>'', 'text' =>"$mes", - 'delstatus' =>4 + 'delstatus' =>4, + 'reply_to' =>0 ]; } else { @@ -4088,7 +4194,8 @@ function add_system_message(string $mes, string $doer): void 'poster' =>'', 'recipient' =>'', 'text' =>"$mes ($doer)", - 'delstatus' =>4 + 'delstatus' =>4, + 'reply_to' =>0 ]; } write_message($sysmessage); @@ -4105,7 +4212,8 @@ function add_system_pm_message(string $mes, string $recipient, string $doer): vo 'poster' =>'System', 'recipient' => $recipient, 'text' =>"$mes", - 'delstatus' =>4 + 'delstatus' =>4, + 'reply_to' =>0 ]; } else { @@ -4115,7 +4223,8 @@ function add_system_pm_message(string $mes, string $recipient, string $doer): vo 'poster' =>'System', 'recipient' => $recipient, 'text' =>"$mes ($doer)", - 'delstatus' =>4 + 'delstatus' =>4, + 'reply_to' =>0 ]; } write_message($sysmessage); @@ -4130,8 +4239,11 @@ function write_message(array $message): void send_error($e->getMessage()); } } - $stmt=$db->prepare('INSERT INTO ' . PREFIX . 'messages (postdate, poststatus, poster, recipient, text, delstatus) VALUES (?, ?, ?, ?, ?, ?);'); - $stmt->execute([$message['postdate'], $message['poststatus'], $message['poster'], $message['recipient'], $message['text'], $message['delstatus']]); + if(!isset($message['reply_to'])){ + $message['reply_to']=0; + } + $stmt=$db->prepare('INSERT INTO ' . PREFIX . 'messages (postdate, poststatus, poster, recipient, text, delstatus, reply_to) VALUES (?, ?, ?, ?, ?, ?, ?);'); + $stmt->execute([$message['postdate'], $message['poststatus'], $message['poster'], $message['recipient'], $message['text'], $message['delstatus'], $message['reply_to']]); if($message['poststatus']<9 && get_setting('sendmail')){ $subject='New Chat message'; $headers='From: '.get_setting('mailsender')."\r\nX-Mailer: PHP/".phpversion()."\r\nContent-Type: text/html; charset=UTF-8\r\n"; @@ -4237,7 +4349,7 @@ function print_messages(int $delstatus=0): void } echo '
'; if($delstatus>0){ - $stmt=$db->prepare('SELECT postdate, id, text FROM ' . PREFIX . 'messages WHERE '. + $stmt=$db->prepare('SELECT postdate, id, text, reply_to FROM ' . PREFIX . 'messages WHERE '. "(poststatus=?) ORDER BY id $direction;"); $stmt->execute([$U['status'], $delstatus, $U['nickname'], $U['nickname'], $entry]); while($message=$stmt->fetch(PDO::FETCH_ASSOC)){ @@ -4249,7 +4361,7 @@ function print_messages(int $delstatus=0): void echo " $message[text]
"; } }else{ - $stmt=$db->prepare('SELECT id, postdate, poststatus, text FROM ' . PREFIX . 'messages WHERE (poststatus<=? OR poststatus=4 OR '. + $stmt=$db->prepare('SELECT id, postdate, poststatus, poster, recipient, text, reply_to FROM ' . PREFIX . 'messages WHERE (poststatus<=? OR poststatus=4 OR '. '(poststatus=9 AND ( (poster=? AND recipient NOT IN (SELECT ign FROM ' . PREFIX . 'ignored WHERE ignby=?) ) OR recipient=?) AND postdate>=?)'. ') AND poster NOT IN (SELECT ign FROM ' . PREFIX . "ignored WHERE ignby=?) ORDER BY id $direction;"); $stmt->execute([$U['status'], $U['nickname'], $U['nickname'], $U['nickname'], $entry, $U['nickname']]); @@ -4262,13 +4374,47 @@ function print_messages(int $delstatus=0): void if ($message['poststatus']==4) { echo ''.get_setting('sysmessagetxt')."$message[text]"; } else { - echo "$message[text]"; + print_reply_reference((int) $message['reply_to']); + echo "$message[text]"; + print_message_actions($message); + echo ''; } } } echo ''; } +function print_reply_reference(int $reply_to): void +{ + global $db; + if($reply_to<1 || !can_view_message($reply_to)){ + return; + } + $stmt=$db->prepare('SELECT poster FROM ' . PREFIX . 'messages WHERE id=?;'); + $stmt->execute([$reply_to]); + if($message=$stmt->fetch(PDO::FETCH_ASSOC)){ + echo '
'.sprintf(_('Reply to #%1$d by %2$s'), $reply_to, htmlspecialchars($message['poster'])).'
'; + } +} + +function print_message_actions(array $message): void +{ + global $U; + $liked=is_message_liked((int) $message['id']); + $likes=count_message_likes((int) $message['id']); + echo '
'; + echo form('like').hidden('mid', (string) $message['id']); + echo submit(($liked ? _('Unlike') : _('Like'))." ($likes)").' '; + echo form_target('post', 'post'); + echo hidden('reply_to', (string) $message['id']); + echo hidden('sendto', htmlspecialchars(get_reply_sendto((int) $message['id']))); + if($U['sortupdown']){ + echo hidden('sort', '1'); + } + echo submit(_('Reply')).''; + echo '
'; +} + function prepare_message_print(array &$message, bool $removeEmbed): void { if(MSGENCRYPTED){ @@ -4287,6 +4433,76 @@ function prepare_message_print(array &$message, bool $removeEmbed): void } } +function toggle_like(): void +{ + global $U, $db; + $message_id=(int) ($_POST['mid'] ?? 0); + if($message_id<1 || !can_view_message($message_id)){ + return; + } + $stmt=$db->prepare('SELECT id FROM ' . PREFIX . 'likes WHERE message_id=? AND nickname=?;'); + $stmt->execute([$message_id, $U['nickname']]); + if($like=$stmt->fetch(PDO::FETCH_ASSOC)){ + $stmt=$db->prepare('DELETE FROM ' . PREFIX . 'likes WHERE id=?;'); + $stmt->execute([$like['id']]); + }else{ + $stmt=$db->prepare('INSERT INTO ' . PREFIX . 'likes (message_id, nickname) VALUES (?, ?);'); + $stmt->execute([$message_id, $U['nickname']]); + } +} + +function count_message_likes(int $message_id): int +{ + global $db; + $stmt=$db->prepare('SELECT COUNT(*) FROM ' . PREFIX . 'likes WHERE message_id=?;'); + $stmt->execute([$message_id]); + return (int) $stmt->fetchColumn(); +} + +function is_message_liked(int $message_id): bool +{ + global $U, $db; + $stmt=$db->prepare('SELECT null FROM ' . PREFIX . 'likes WHERE message_id=? AND nickname=?;'); + $stmt->execute([$message_id, $U['nickname']]); + return (bool) $stmt->fetch(PDO::FETCH_NUM); +} + +function get_valid_reply_id($reply_to): int +{ + $reply_to=(int) $reply_to; + if($reply_to<1 || !can_view_message($reply_to)){ + return 0; + } + return $reply_to; +} + +function get_reply_sendto(int $reply_to): string +{ + global $U, $db; + $stmt=$db->prepare('SELECT poststatus, poster, recipient FROM ' . PREFIX . 'messages WHERE id=?;'); + $stmt->execute([$reply_to]); + if($message=$stmt->fetch(PDO::FETCH_ASSOC)){ + if((int) $message['poststatus']===9 && $message['poster']!==$U['nickname']){ + return $message['poster']; + } + } + return 's *'; +} + +function can_view_message(int $message_id): bool +{ + global $U, $db; + if($message_id<1){ + return false; + } + $entry=$U['status']>1 ? 0 : $U['entry']; + $stmt=$db->prepare('SELECT null FROM ' . PREFIX . 'messages WHERE id=? AND (poststatus<=? OR poststatus=4 OR '. + '(poststatus=9 AND ( (poster=? AND recipient NOT IN (SELECT ign FROM ' . PREFIX . 'ignored WHERE ignby=?) ) OR recipient=?) AND postdate>=?)'. + ') AND poster NOT IN (SELECT ign FROM ' . PREFIX . 'ignored WHERE ignby=?);'); + $stmt->execute([$message_id, $U['status'], $U['nickname'], $U['nickname'], $U['nickname'], $entry, $U['nickname']]); + return (bool) $stmt->fetch(PDO::FETCH_NUM); +} + // this and that function send_headers(): void @@ -4571,6 +4787,12 @@ function cron(): void while($tmp=$result->fetch(PDO::FETCH_NUM)){ $stmt->execute($tmp); } + // delete likes that do not belong to any message + $result=$db->query('SELECT id FROM ' . PREFIX . 'likes WHERE message_id NOT IN (SELECT id FROM ' . PREFIX . 'messages);'); + $stmt=$db->prepare('DELETE FROM ' . PREFIX . 'likes WHERE id=?;'); + while($tmp=$result->fetch(PDO::FETCH_NUM)){ + $stmt->execute($tmp); + } // delete old notes $limit=get_setting('numnotes'); $to_keep = []; @@ -4686,11 +4908,15 @@ function init_chat(): void $db->exec('CREATE INDEX ' . PREFIX . 'inbox_poster ON ' . PREFIX . 'inbox(poster);'); $db->exec('CREATE INDEX ' . PREFIX . 'inbox_recipient ON ' . PREFIX . 'inbox(recipient);'); $db->exec('CREATE TABLE ' . PREFIX . "linkfilter (id $primary, filtermatch varchar(255) NOT NULL, filterreplace varchar(255) NOT NULL, regex smallint NOT NULL)$diskengine$charset;"); - $db->exec('CREATE TABLE ' . PREFIX . "messages (id $primary, postdate integer NOT NULL, poststatus smallint NOT NULL, poster varchar(50) NOT NULL, recipient varchar(50) NOT NULL, text text NOT NULL, delstatus smallint NOT NULL)$diskengine$charset;"); + $db->exec('CREATE TABLE ' . PREFIX . "messages (id $primary, postdate integer NOT NULL, poststatus smallint NOT NULL, poster varchar(50) NOT NULL, recipient varchar(50) NOT NULL, text text NOT NULL, delstatus smallint NOT NULL, reply_to integer NOT NULL DEFAULT 0)$diskengine$charset;"); $db->exec('CREATE INDEX ' . PREFIX . 'poster ON ' . PREFIX . 'messages (poster);'); $db->exec('CREATE INDEX ' . PREFIX . 'recipient ON ' . PREFIX . 'messages(recipient);'); $db->exec('CREATE INDEX ' . PREFIX . 'postdate ON ' . PREFIX . 'messages(postdate);'); $db->exec('CREATE INDEX ' . PREFIX . 'poststatus ON ' . PREFIX . 'messages(poststatus);'); + $db->exec('CREATE INDEX ' . PREFIX . 'reply_to ON ' . PREFIX . 'messages(reply_to);'); + $db->exec('CREATE TABLE ' . PREFIX . "likes (id $primary, message_id integer NOT NULL, nickname varchar(50) NOT NULL)$diskengine$charset;"); + $db->exec('CREATE INDEX ' . PREFIX . 'likes_message_id ON ' . PREFIX . 'likes(message_id);'); + $db->exec('CREATE UNIQUE INDEX ' . PREFIX . 'likes_message_nickname ON ' . PREFIX . 'likes(message_id, nickname);'); $db->exec('CREATE TABLE ' . PREFIX . "notes (id $primary, type smallint NOT NULL, lastedited integer NOT NULL, editedby varchar(50) NOT NULL, text text NOT NULL)$diskengine$charset;"); $db->exec('CREATE INDEX ' . PREFIX . 'notes_type ON ' . PREFIX . 'notes(type);'); $db->exec('CREATE INDEX ' . PREFIX . 'notes_editedby ON ' . PREFIX . 'notes(editedby);'); @@ -5186,6 +5412,13 @@ function update_db(): void $db->exec('ALTER TABLE ' . PREFIX . "members ADD COLUMN pgpchallengehash varchar(64) NOT NULL DEFAULT '';"); $db->exec('ALTER TABLE ' . PREFIX . 'members ADD COLUMN pgpchallengeexpires integer NOT NULL DEFAULT 0;'); } + if($dbversion<53){ + $db->exec('ALTER TABLE ' . PREFIX . 'messages ADD COLUMN reply_to integer NOT NULL DEFAULT 0;'); + $db->exec('CREATE INDEX ' . PREFIX . 'reply_to ON ' . PREFIX . 'messages(reply_to);'); + $db->exec('CREATE TABLE ' . PREFIX . "likes (id $primary, message_id integer NOT NULL, nickname varchar(50) NOT NULL)$diskengine$charset;"); + $db->exec('CREATE INDEX ' . PREFIX . 'likes_message_id ON ' . PREFIX . 'likes(message_id);'); + $db->exec('CREATE UNIQUE INDEX ' . PREFIX . 'likes_message_nickname ON ' . PREFIX . 'likes(message_id, nickname);'); + } update_setting('dbversion', DBVERSION); if($msgencrypted!==MSGENCRYPTED){ if(!extension_loaded('sodium')){ @@ -5388,7 +5621,7 @@ function load_lang(): void function load_config(): void { define('VERSION', '1.24.1'); // Script version - define('DBVERSION', 52); // Database layout version + define('DBVERSION', 53); // Database layout version define('MSGENCRYPTED', false); // Store messages encrypted in the database to prevent other database users from reading them - true/false - visit the setup page after editing! define('ENCRYPTKEY_PASS', 'MY_SECRET_KEY'); // Recommended length: 32. Encryption key for messages define('AES_IV_PASS', '012345678912'); // Recommended length: 12. AES Encryption IV