diff options
Diffstat (limited to 'lib')
-rw-r--r-- | lib/activitypub-actor.php | 55 | ||||
-rw-r--r-- | lib/activitypub-followers.php | 70 | ||||
-rw-r--r-- | lib/activitypub-functions.php | 521 | ||||
-rw-r--r-- | lib/activitypub-inbox.php | 196 | ||||
-rw-r--r-- | lib/activitypub-outbox.php | 72 | ||||
-rw-r--r-- | lib/activitypub-webfinger.php | 4 | ||||
-rw-r--r-- | lib/database.php | 66 | ||||
-rw-r--r-- | lib/functions.php | 96 | ||||
-rw-r--r-- | lib/xmlrpc.php | 250 |
9 files changed, 1277 insertions, 53 deletions
diff --git a/lib/activitypub-actor.php b/lib/activitypub-actor.php index 705955b..b34f582 100644 --- a/lib/activitypub-actor.php +++ b/lib/activitypub-actor.php @@ -1,13 +1,26 @@ <?php + if(!$config['activitypub']) exit('ActivityPub is disabled via config file.'); + + $public_key = activitypub_get_key('public'); + + // generate a key pair, if neccessary + if(!$public_key) { + $key = activitypub_new_key('sha512', 4096, 'RSA'); + + if(!empty($key)) exit('Fatal error: Could not generate a new key!'); + $public_key = $key['key_public']; + } + + /* + // old, file-based key system if(!file_exists(ROOT.DS.'keys'.DS.'id_rsa')) { if(!is_dir(ROOT.DS.'keys')) { mkdir(ROOT.DS.'keys'); } // generate a key pair, if neccessary - - $rsa = openssl_pkey_new([ + $rsa = openssl_pkey_new([ 'digest_alg' => 'sha512', 'private_key_bits' => 4096, 'private_key_type' => OPENSSL_KEYTYPE_RSA, @@ -20,37 +33,43 @@ } else { $public_key = file_get_contents(ROOT.DS.'keys'.DS.'id_rsa.pub'); } + */ - - - /* + if(strpos($_SERVER['HTTP_ACCEPT'], 'application/activity+json') !== false): - $data = [ - 'subject' => 'acct:'.$actor.'@'.$domain, - 'links' => [ - 'rel' => 'self', - 'type' => 'application/activity+json', - 'href' => $config['url'].'/actor' - ] - ]; - */ + header('Content-Type: application/ld+json'); - header('Content-Type: application/ld+json'); - // echo(json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); ?>{ "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1" ], - "id": "<?= $config['url'] ?>/actor", "type": "Person", + "name": "<?= trim($config['site_title']) ?>", + "summary": "<?= trim($config['site_claim']) ?>", "preferredUsername": "<?= ltrim($config['microblog_account'], '@') ?>", + "manuallyApprovesFollowers": false, + "discoverable": true, + "publishedDate": "2023-01-01T00:00:00Z", + "icon": { + "url": "<?= $config['url'] ?>/favicon-large.png", + "mediaType": "image/png", + "type": "Image" + }, "inbox": "<?= $config['url'] ?>/inbox", - + "outbox": "<?= $config['url'] ?>/outbox", + "followers": "<?= $config['url'] ?>/followers", "publicKey": { "id": "<?= $config['url'] ?>/actor#main-key", "owner": "<?= $config['url'] ?>/actor", "publicKeyPem": "<?= preg_replace('/\n/', '\n', $public_key) ?>" } } +<?php + else: + // this is for people who click through to the profile URL in their mastodon client + header('Location: '.$config['url']); + exit(); + endif; +?> diff --git a/lib/activitypub-followers.php b/lib/activitypub-followers.php new file mode 100644 index 0000000..bc21ab5 --- /dev/null +++ b/lib/activitypub-followers.php @@ -0,0 +1,70 @@ +<?php + +if(!$config['activitypub']) exit('ActivityPub is disabled via config file.'); + +// get total amount +$statement = $db->prepare('SELECT COUNT(id) as total FROM followers WHERE follower_actor IS NOT NULL'); +$statement->execute(); +$followers_total = $statement->fetchAll(PDO::FETCH_ASSOC); +$followers_total = (!empty($followers_total)) ? $followers_total[0]['total'] : 0; + +if(!isset($_GET['page'])): + + $output = [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $config['url'].'/followers', + 'type' => 'OrderedCollection', + 'totalItems' => $followers_total, + 'first' => $config['url'].'/followers?page=1', + ]; + + header('Content-Type: application/ld+json'); + echo(json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); +else: + + // get items + $items_per_page = 12; // mastodon default? + + // pagination + $current_page = (isset($_GET['page']) && is_numeric($_GET['page'])) ? (int) $_GET['page'] : 1; + $total_pages = ceil($followers_total / $items_per_page); + $offset = ($current_page-1)*$items_per_page; + + if($current_page < 1 || $current_page > $total_pages) { + http_response_code(404); + header('Content-Type: application/ld+json'); + die('{}'); + } + + $statement = $db->prepare('SELECT follower_actor FROM followers WHERE follower_actor IS NOT NULL ORDER BY follower_added ASC LIMIT :limit OFFSET :page'); + $statement->bindValue(':limit', $items_per_page, PDO::PARAM_INT); + $statement->bindValue(':page', $offset, PDO::PARAM_INT); + $statement->execute(); + $followers = $statement->fetchAll(PDO::FETCH_ASSOC); + + $ordered_items = []; + if(!empty($followers)) { + $ordered_items = array_column($followers, 'follower_actor'); + } + + $output = [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $config['url'].'/followers?page='.$current_page, + 'type' => 'OrderedCollectionPage', + 'totalItems' => $followers_total, + 'partOf' => $config['url'].'/followers' + ]; + + if($current_page > 1) { + $output['prev'] = $config['url'].'/followers?page='.($current_page-1); + } + + if($current_page < $total_pages) { + $output['next'] = $config['url'].'/followers?page='.($current_page+1); + } + + $output['orderedItems'] = $ordered_items; + + header('Content-Type: application/ld+json'); + echo(json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); +endif; diff --git a/lib/activitypub-functions.php b/lib/activitypub-functions.php new file mode 100644 index 0000000..4b427eb --- /dev/null +++ b/lib/activitypub-functions.php @@ -0,0 +1,521 @@ +<?php + +if($config['subdir_install'] == true && $config['activitypub'] == true) { + exit('For ActivityPub to work, you can\'t be running in a subdirectory, sadly.'); +} + +function ap_log($name, $data) { + // file_put_contents(ROOT.DS.'inbox-log.txt', date('H:i:s ').$name.":\n".$data."\n\n", FILE_APPEND | LOCK_EX); +} + +function activitypub_new_key($algo = 'sha512', $bits = 4096, $type = 'rsa') { + global $db; + + $key_type = (mb_strtolower($type) == 'rsa') ? OPENSSL_KEYTYPE_RSA : $type; // todo: improve! + + $rsa = openssl_pkey_new([ + 'digest_alg' => $algo, + 'private_key_bits' => $bits, + 'private_key_type' => $key_type + ]); + openssl_pkey_export($rsa, $private_key); + $public_key = openssl_pkey_get_details($rsa)['key']; + $created = time(); + + try { + $statement = $db->prepare('INSERT INTO keys (key_private, key_public, key_algo, key_bits, key_type, key_created) VALUES (:private, :public, :algo, :bits, :type, :created)'); + + $statement->bindValue(':private', $private_key, PDO::PARAM_STR); + $statement->bindValue(':public', $public_key, PDO::PARAM_STR); + $statement->bindValue(':algo', $algo, PDO::PARAM_STR); + $statement->bindValue(':bits', $bits, PDO::PARAM_INT); + $statement->bindValue(':type', mb_strtolower($type), PDO::PARAM_STR); + $statement->bindValue(':created', $created, PDO::PARAM_INT); + + $statement->execute(); + + } catch(PDOException $e) { + ap_log('ERROR', $e->getMessage()); + return false; + } + + if($db->lastInsertId() > 0) { + return [ + 'id' => $db->lastInsertId(), + 'key_private' => $private_key, + 'key_public' => $public_key, + 'key_algo' => $algo, + 'key_bits' => $bits, + 'key_type' => mb_strtolower($type), + 'key_created' => $created + ]; + } + return false; +} + +function activitypub_get_key($type = 'public') { + global $db; + + $sql = ''; + + if($type == 'public') { + $sql = 'SELECT key_public FROM keys ORDER BY key_created DESC LIMIT 1'; + } elseif($type == 'private') { + $sql = 'SELECT key_private FROM keys ORDER BY key_created DESC LIMIT 1'; + } else { + $sql = 'SELECT * FROM keys ORDER BY key_created DESC LIMIT 1'; + } + + try { + $statement = $db->prepare($sql); + + $statement->execute(); + } catch(PDOException $e) { + ap_log('ERROR', $e->getMessage()); + return false; + } + + $key = $statement->fetch(PDO::FETCH_ASSOC); + + if(!empty($key)) { + if($type == 'public') { + return $key['key_public']; + } elseif($type == 'private') { + return $key['key_private']; + } else { + return $key; + } + } + + return false; +} + +function activitypub_get_actor_url($handle, $full_profile = false) { + list($user, $host) = explode('@', ltrim($handle, '@')); + + $ch = curl_init(); + + $url = sprintf('https://%s/.well-known/webfinger?resource=acct%%3A%s', $host, urlencode($user.'@'.$host)); + + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $server_response = curl_exec($ch); + // ap_log('WEBFINGER RESPONSE', $server_response); + + curl_close($ch); + + $profile = json_decode($server_response, true); + if($full_profile) { + return $profile; + } + + // make this more robust by iterating over links where href = self? + return $profile['links'][1]['href']; +} + +function activitypub_get_actor_data($actor_url='') { + if(empty($actor_url)) return false; + + $opts = [ + "http" => [ + "method" => "GET", + "header" => join("\r\n", [ + "Accept: application/activity+json", + "Content-type: application/activity+json", + ]) + ] + ]; + + $context = stream_context_create($opts); + + $file = @file_get_contents($actor_url, false, $context); // fix? + + if(!empty($file)) { + return json_decode($file, true); + } + + return false; +} + +function activitypub_plaintext($path, $host, $date, $digest, $type='application/activity+json'): string { + $plaintext = sprintf( + "(request-target): post %s\nhost: %s\ndate: %s\ndigest: %s\ncontent-type: %s", + $path, + $host, + $date, + $digest, + $type + ); + + // ap_log('PLAINTEXT', $plaintext); + + return $plaintext; +} + +function activitypub_digest(string $data): string { + return sprintf('SHA-256=%s', base64_encode(hash('sha256', $data, true))); +} + +function activitypub_sign($path, $host, $date, $digest): string { + $private_key = activitypub_get_key('private'); + + openssl_sign(activitypub_plaintext($path, $host, $date, $digest), $signature, openssl_get_privatekey($private_key), OPENSSL_ALGO_SHA256); + + return $signature; +} + +function activitypub_verify(string $signature, string $pubkey, string $plaintext): bool { + return openssl_verify($plaintext, base64_decode($signature), $pubkey, OPENSSL_ALGO_SHA256); +} + +function activitypub_send_request($host, $path, $data): void { + global $config; + + $encoded = json_encode($data); + + $date = gmdate('D, d M Y H:i:s T', time()); + $digest = activitypub_digest($encoded); + + $signature = activitypub_sign( + $path, + $host, + $date, + $digest + ); + + $signature_header = sprintf( + 'keyId="%s",algorithm="rsa-sha256",headers="(request-target) host date digest content-type",signature="%s"', + $config['url'].'/actor#main-key', + base64_encode($signature) + ); + + // DEBUG + $fp = fopen(ROOT.DS.'inbox-log.txt', 'a'); + + $curl_headers = [ + 'Content-Type: application/activity+json', + 'Date: ' . $date, + 'Signature: ' . $signature_header, + 'Digest: ' . $digest + ]; + + ap_log('SEND MESSAGE', json_encode([$data, $curl_headers], JSON_PRETTY_PRINT)); + + $ch = curl_init(); + + curl_setopt($ch, CURLOPT_URL, sprintf('https://%s%s', $host, $path)); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, $encoded); + curl_setopt($ch, CURLOPT_HTTPHEADER, $curl_headers); + curl_setopt($ch, CURLOPT_HEADER, 1); + curl_setopt($ch, CURLOPT_VERBOSE, false); + curl_setopt($ch, CURLOPT_STDERR, $fp); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $server_output = curl_exec($ch); + + curl_close($ch); + fclose($fp); + + ap_log('SERVER RESPONSE', $server_output); +} + +function activitypub_activity_from_post($post, $json=false) { + global $config; + + if(empty($post)) return false; + + $output = [ + '@context' => 'https://www.w3.org/ns/activitystreams', + + 'id' => $config['url'].'/'.$post['id'].'/json', + 'type' => 'Create', + 'actor' => $config['url'].'/actor', + 'to' => ['https://www.w3.org/ns/activitystreams#Public'], + 'cc' => [$config['url'].'/followers'], + 'object' => [ + 'id' => $config['url'].'/'.$post['id'], + 'type' => 'Note', + 'published' => gmdate('Y-m-d\TH:i:s\Z', $post['post_timestamp']), + 'attributedTo' => $config['url'].'/actor', + 'content' => filter_tags($post['post_content']), + 'to' => ['https://www.w3.org/ns/activitystreams#Public'] + ] + ]; + + $attachments = db_get_attached_files($post['id']); + + if(!empty($attachments) && !empty($attachments[$post['id']])) { + $output['object']['attachment'] = []; + + foreach ($attachments[$post['id']] as $key => $a) { + if(strpos($a['file_mime_type'], 'image') !== 0) continue; // skip non-image files + + $url = $config['url'] .'/'. get_file_path($a); + + $output['object']['attachment'][] = [ + 'type' => 'Image', + 'mediaType' => $a['file_mime_type'], + 'url' => $url, + 'name' => null + ]; + } + } + + if ($json) { + return json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + return $output; +} + +function activitypub_notify_followers($post_id): void { + global $db; + // todo: make this a queue + + // API ENDPOINTS + // https://mastodon.social/api/v2/instance + + // users without shared inbox + $statement = $db->prepare('SELECT * FROM followers WHERE follower_shared_inbox IS NULL'); + $statement->execute(); + $followers = $statement->fetchAll(PDO::FETCH_ASSOC); + + // users with shared inbox + $statement = $db->prepare('SELECT follower_shared_inbox as shared_inbox, GROUP_CONCAT(follower_name) as shared_inbox_followers FROM followers WHERE follower_shared_inbox IS NOT NULL GROUP BY follower_shared_inbox'); + $statement->execute(); + $shared_inboxes = $statement->fetchAll(PDO::FETCH_ASSOC); + + // get the activity data, eg. https://microblog.oelna.de/11/json + $post = db_select_post($post_id); + $post_activity = activitypub_activity_from_post($post); + + $update = [ + 'id' => null, + 'inbox' => null, + 'actor' => null + ]; + + // prepare db for possible updates + $statement = $db->prepare('UPDATE followers SET follower_inbox = :inbox, follower_actor = :actor WHERE id = :id'); + $statement->bindParam(':id', $update['id'], PDO::PARAM_INT); + $statement->bindParam(':inbox', $update['inbox'], PDO::PARAM_STR); + $statement->bindParam(':actor', $update['actor'], PDO::PARAM_STR); + + // iterate over shared inboxes to deliver those quickly + foreach($shared_inboxes as $inbox) { + $info = parse_url($inbox['shared_inbox']); + // ap_log('SHARED_INBOX_DELIVERY', json_encode([$inbox, $info, $post_activity], JSON_PRETTY_PRINT)); + // todo: verify we don't need to handle single usernames here + // using the followers URL as CC is enough? + activitypub_send_request($info['host'], $info['path'], $post_activity); + } + + // iterate over followers and send create activity + foreach($followers as $follower) { + + // retrieve actor info, if missing (is this necessary?) + if(empty($follower['follower_inbox'])) { + + $actor_url = activitypub_get_actor_url($follower['follower_name'].'@'.$follower['follower_host']); + if (empty($actor_url)) continue; + + $actor_data = activitypub_get_actor_data($actor_url); + if (empty($actor_data) || empty($actor_data['inbox'])) continue; + + // cache this info + $update['id'] = $follower['id']; + $update['inbox'] = $actor_data['inbox']; + $update['actor'] = $actor_url; + + try { + $statement->execute(); + } catch(PDOException $e) { + continue; + } + + $follower['follower_inbox'] = $actor_data['inbox']; + } + + $info = parse_url($follower['follower_inbox']); + + activitypub_send_request($info['host'], $info['path'], $post_activity); + + ap_log('SENDING TO', json_encode([$info['host'], $info['path']], JSON_PRETTY_PRINT)); + } +} + +function activitypub_post_from_url($url="") { + // todo: this should be more robust and conform to url scheme on this site + + $path = parse_url($url, PHP_URL_PATH); + + $items = explode('/', $path); + $post_id = end($items); + + if (is_numeric($post_id)) { + return (int) $post_id; + } + + return false; +} + +function activitypub_do($type, $user, $host, $post_id) { + if (empty($type)) return false; + + global $db; + + $activity = [ + 'actor_name' => $user, + 'actor_host' => $host, + 'type' => (mb_strtolower($type) == 'like') ? 'like' : 'announce', + 'object_id' => (int) $post_id, + 'updated' => time() + ]; + + try { + $statement = $db->prepare('INSERT OR IGNORE INTO activities (activity_actor_name, activity_actor_host, activity_type, activity_object_id, activity_updated) VALUES (:actor_name, :actor_host, :type, :object_id, :updated)'); + + $statement->bindValue(':actor_name', $activity['actor_name'], PDO::PARAM_STR); + $statement->bindValue(':actor_host', $activity['actor_host'], PDO::PARAM_STR); + $statement->bindValue(':type', $activity['type'], PDO::PARAM_STR); + $statement->bindValue(':object_id', $activity['object_id'], PDO::PARAM_INT); + $statement->bindValue(':updated', $activity['updated'], PDO::PARAM_INT); + + $statement->execute(); + + } catch(PDOException $e) { + print 'Exception : '.$e->getMessage(); + ap_log('ERROR', $e->getMessage()); + return false; + } + + ap_log('INSERTED ACTIVITY', json_encode([$activity, $db->lastInsertId()], JSON_PRETTY_PRINT)); + return $db->lastInsertId(); +} + +function activitypub_undo($type, $user, $host, $post_id) { + if (empty($type)) return false; + + global $db; + + $activity = [ + 'actor_name' => $user, + 'actor_host' => $host, + 'type' => (mb_strtolower($type) == 'like') ? 'like' : 'announce', // todo: make this safer + 'object_id' => (int) $post_id + ]; + try { + $statement = $db->prepare('DELETE FROM activities WHERE activity_actor_name = :actor_name AND activity_actor_host = :actor_host AND activity_type = :type AND activity_object_id = :object_id'); + $statement->bindValue(':actor_name', $activity['actor_name'], PDO::PARAM_STR); + $statement->bindValue(':actor_host', $activity['actor_host'], PDO::PARAM_STR); + $statement->bindValue(':type', $activity['type'], PDO::PARAM_STR); + $statement->bindValue(':object_id', $activity['object_id'], PDO::PARAM_INT); + + $statement->execute(); + } catch(PDOException $e) { + print 'Exception : '.$e->getMessage(); + ap_log('ERROR', $e->getMessage()); + return false; + } + + ap_log('SQL DELETE', json_encode([$statement->rowCount()])); + return true; + return $statement->rowCount(); +} + +function activitypub_update_post($post_id) { + // https://www.w3.org/TR/activitypub/#update-activity-inbox +} + +function activitypub_delete_user($name, $host) { + if(empty($name) || empty($host)) return false; + + global $db; + + // delete all records of user as follower + try { + $statement = $db->prepare('DELETE FROM followers WHERE follower_name = :actor_name AND follower_host = :actor_host'); + $statement->bindValue(':actor_name', $name, PDO::PARAM_STR); + $statement->bindValue(':actor_host', $host, PDO::PARAM_STR); + + $statement->execute(); + } catch(PDOException $e) { + print 'Exception : '.$e->getMessage(); + ap_log('ERROR', $e->getMessage()); + return false; + } + + // remove likes and boosts + try { + $statement = $db->prepare('DELETE FROM activities WHERE activity_actor_name = :actor_name AND activity_actor_host = :actor_host'); + $statement->bindValue(':actor_name', $name, PDO::PARAM_STR); + $statement->bindValue(':actor_host', $host, PDO::PARAM_STR); + + $statement->execute(); + } catch(PDOException $e) { + print 'Exception : '.$e->getMessage(); + ap_log('ERROR', $e->getMessage()); + return false; + } + + return true; +} + +function activitypub_get_post_stats($type="like", $post_id=null) { + global $db; + if(empty($db)) return false; + if(empty($post_id)) return false; + + // normalize type input, liberally + if(in_array($type, ['announce', 'announced', 'boost', 'boosts', 'boosted'])) $type = 'announce'; + if($type == 'both' || $type == 'all') $type = 'both'; + if($type !== 'both' && $type !== 'announce') $type = 'like'; + + $type_clause = 'activity_type = "like"'; + if($type == 'both') { + $type_clause = '(activity_type = "like" OR activity_type = "announce")'; + } elseif($type == 'announce') { + $type_clause = 'activity_type = "announce"'; + } + + $sql = 'SELECT activity_type, COUNT(id) AS amount FROM activities WHERE activity_object_id = :post_id AND '.$type_clause.' GROUP BY activity_type ORDER BY activity_type ASC'; + + try { + $statement = $db->prepare($sql); + $statement->bindValue(':post_id', (int) $post_id, PDO::PARAM_INT); + $statement->execute(); + $rows = $statement->fetchAll(PDO::FETCH_ASSOC); + } catch(PDOException $e) { + print 'Exception : '.$e->getMessage(); + return false; + } + + $return = [ + 'announce' => 0, + 'like' => 0 + ]; + + if(!empty($rows)) { + foreach ($rows as $row) { + if($row['activity_type'] == 'announce') { + $return['announce'] = (int) $row['amount']; + } else if($row['activity_type'] == 'like') { + $return['like'] = (int) $row['amount']; + } + } + } + + if($type == 'both') { + return $return; + } elseif($type == 'announce') { + unset($return['like']); + return $return; + } else { + unset($return['announce']); + return $return; + } + + return $return; +} diff --git a/lib/activitypub-inbox.php b/lib/activitypub-inbox.php new file mode 100644 index 0000000..cea4066 --- /dev/null +++ b/lib/activitypub-inbox.php @@ -0,0 +1,196 @@ +<?php + + // https://paul.kinlan.me/adding-activity-pub-to-your-static-site/ + // https://bovine.readthedocs.io/en/latest/tutorial_server.html + // https://github.com/timmot/activity-pub-tutorial + // https://magazine.joomla.org/all-issues/february-2023/turning-the-joomla-website-into-an-activitypub-server + // https://codeberg.org/mro/activitypub/src/commit/4b1319d5363f4a836f23c784ef780b81bc674013/like.sh#L101 + + // todo: handle account moves + // https://seb.jambor.dev/posts/understanding-activitypub/ + + if(!$config['activitypub']) exit('ActivityPub is disabled via config file.'); + + $postdata = file_get_contents('php://input'); + + if(!empty($postdata)) { + + $data = json_decode($postdata, true); + $inbox = parse_url($config['url'].'/inbox'); + + $request = [ + 'host' => $inbox['host'], + 'path' => $inbox['path'], + 'digest' => $_SERVER['HTTP_DIGEST'], + 'date' => $_SERVER['HTTP_DATE'], + 'length' => $_SERVER['CONTENT_LENGTH'], + 'type' => $_SERVER['CONTENT_TYPE'] + ]; + + header('Content-Type: application/ld+json'); + + ap_log('POSTDATA', $postdata); + // ap_log('REQUEST', json_encode($request)); + + // verify message digest + $digest_verify = activitypub_digest($postdata); + if($digest_verify === $request['digest']) { + // ap_log('DIGEST', 'Passed verification for ' . $digest_verify); + } else { + ap_log('ERROR', json_encode(['digest verification failed!', $request['digest'], $digest_verify], JSON_PRETTY_PRINT)); + } + + // GET ACTOR DETAILS + if(!empty($data) && !empty($data['actor'])) { + $actor = activitypub_get_actor_data($data['actor']); + + if(!empty($actor)) { + $actor_key = $actor['publicKey']; + $info = parse_url($actor['inbox']); + } else { + exit('could not parse actor data'); + } + } else { + exit('no actor provided'); + } + + $signature = []; + $signature_string = $_SERVER['HTTP_SIGNATURE']; + $parts = explode(',', stripslashes($signature_string)); + foreach ($parts as $part) { + $part = trim($part, '"'); + list($k, $v) = explode('=', $part); + $signature[$k] = trim($v, '"'); + } + + // ap_log('SIGNATURE', json_encode($signature)); + // ap_log('ACTOR', json_encode($actor)); + // ap_log('PUBLIC KEY', str_replace("\n", '\n', $actor_key['publicKeyPem'])); + + $plaintext = activitypub_plaintext($request['path'], $request['host'], $request['date'], $request['digest'], $request['type']); + + // verify request signature + $result = activitypub_verify($signature['signature'], $actor_key['publicKeyPem'], $plaintext); + + if($result != 1) { + ap_log('REQUEST', json_encode($request)); + ap_log('SIGNATURE', json_encode($signature)); + ap_log('PUBLIC KEY', str_replace("\n", '\n', $actor_key['publicKeyPem'])); + ap_log('RESULT', json_encode([$result, $plaintext], JSON_PRETTY_PRINT)); + ap_log('SSL ERROR', 'message signature did not match'); + exit('message signature did not match'); + } else { + ap_log('SSL OKAY', json_encode([$request, $signature, $result, $plaintext, $actor_key['publicKeyPem']], JSON_PRETTY_PRINT)); + } + + // message signature was ok, now handle the request + + if(!empty($data['type'])) { + if(mb_strtolower($data['type']) == 'follow') { + // follow + + $accept_data = [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => sprintf('%s/activity/%s', $config['url'], uniqid()), + 'type' => 'Accept', + 'actor' => sprintf('%s/actor', $config['url']), + 'object' => $data + ]; + + // send back Accept activity + activitypub_send_request($info['host'], $info['path'], $accept_data); + + $now = time(); + $follower = [ + 'name' => $actor['preferredUsername'], + 'host' => $info['host'], + 'actor' => $data['actor'], + 'inbox' => $actor['inbox'], + 'added' => time() + ]; + try { + $statement = $db->prepare('INSERT OR IGNORE INTO followers (follower_name, follower_host, follower_actor, follower_inbox, follower_shared_inbox, follower_added) VALUES (:follower_name, :follower_host, :follower_actor, :follower_inbox, :follower_shared_inbox, :follower_added)'); + + $statement->bindValue(':follower_name', $follower['name'], PDO::PARAM_STR); + $statement->bindValue(':follower_host', $follower['host'], PDO::PARAM_STR); + $statement->bindValue(':follower_actor', $follower['actor'], PDO::PARAM_STR); + $statement->bindValue(':follower_inbox', $follower['inbox'], PDO::PARAM_STR); + $statement->bindValue(':follower_added', $follower['added'], PDO::PARAM_INT); + + // store shared inbox if possible + if(!empty($actor['endpoints']) && !empty($actor['endpoints']['sharedInbox'])) { + $statement->bindValue(':follower_shared_inbox', $actor['endpoints']['sharedInbox'], PDO::PARAM_STR); + } else { + $statement->bindValue(':follower_shared_inbox', null, PDO::PARAM_NULL); + } + + $statement->execute(); + + } catch(PDOException $e) { + print 'Exception : '.$e->getMessage(); + ap_log('ERROR FOLLOWING', $e->getMessage()); + } + + ap_log('FOLLOW', json_encode([$actor['inbox'], $info, $accept_data], JSON_PRETTY_PRINT)); + + } elseif(mb_strtolower($data['type']) == 'like') { + // like/favorite + ap_log('LIKE', json_encode([$actor['inbox'], $info, $data], JSON_PRETTY_PRINT)); + $post_id = activitypub_post_from_url($data['object']); + activitypub_do('like', $actor['preferredUsername'], $info['host'], $post_id); + } elseif(mb_strtolower($data['type']) == 'announce') { + // boost + ap_log('ANNOUNCE/BOOST', json_encode([$actor['inbox'], $info, $data], JSON_PRETTY_PRINT)); + $post_id = activitypub_post_from_url($data['object']); + activitypub_do('announce', $actor['preferredUsername'], $info['host'], $post_id); + } elseif(mb_strtolower($data['type']) == 'undo') { + if(mb_strtolower($data['object']['type']) == 'follow') { + // undo follow + + ap_log('UNDO FOLLOW', json_encode([$plaintext])); + + // remove from db + $follower = [ + 'name' => $actor['preferredUsername'], + 'host' => $info['host'] + ]; + + try { + $statement = $db->prepare('DELETE FROM followers WHERE follower_name = :name AND follower_host = :host'); + $statement->bindValue(':name', $follower['name'], PDO::PARAM_STR); + $statement->bindValue(':host', $follower['host'], PDO::PARAM_STR); + + $statement->execute(); + } catch(PDOException $e) { + print 'Exception : '.$e->getMessage(); + ap_log('ERROR UNFOLLOWING', $e->getMessage()); + } + + } elseif(mb_strtolower($data['object']['type']) == 'like') { + // undo like + $post_id = activitypub_post_from_url($data['object']['object']); + activitypub_undo('like', $actor['preferredUsername'], $info['host'], $post_id); + ap_log('UNDO LIKE', json_encode([$actor['inbox'], $info, $data], JSON_PRETTY_PRINT)); + } elseif(mb_strtolower($data['object']['type']) == 'announce') { + // undo boost + $post_id = activitypub_post_from_url($data['object']['object']); + activitypub_undo('announce', $actor['preferredUsername'], $info['host'], $post_id); + ap_log('UNDO ANNOUNCE/BOOST', json_encode([$actor['inbox'], $info, $data], JSON_PRETTY_PRINT)); + } + } elseif(mb_strtolower($data['type']) == 'delete') { + // user is to be deleted and all references removed or replaced by Tombstone + // https://www.w3.org/TR/activitypub/#delete-activity-inbox + ap_log('DELETE 1', json_encode(['trying to delete', $data])); + activitypub_delete_user($actor['preferredUsername'], $info['host']); + ap_log('DELETE 2', json_encode([$actor['preferredUsername'], $info['host']])); + } + } + + } else { + + if(file_exists(ROOT.DS.'inbox-log.txt')) { + echo(nl2br(file_get_contents(ROOT.DS.'inbox-log.txt'))); + } else { + echo('no inbox activity'); + } + } diff --git a/lib/activitypub-outbox.php b/lib/activitypub-outbox.php new file mode 100644 index 0000000..242c695 --- /dev/null +++ b/lib/activitypub-outbox.php @@ -0,0 +1,72 @@ +<?php + +if(!$config['activitypub']) exit('ActivityPub is disabled via config file.'); + +$posts_per_page = 20; // 20 is mastodon default? +$posts_total = db_posts_count(); // get total amount of posts +$total_pages = ceil($posts_total / $posts_per_page); + +if(!isset($_GET['page'])): + + $output = [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $config['url'].'/outbox', + 'type' => 'OrderedCollection', + 'totalItems' => $posts_total, + 'first' => $config['url'].'/outbox?page=1', + 'last' => $config['url'].'/outbox?page='.$total_pages + ]; + + header('Content-Type: application/ld+json'); + echo(json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); +else: + + // pagination + $current_page = (isset($_GET['page']) && is_numeric($_GET['page'])) ? (int) $_GET['page'] : 1; + $offset = ($current_page-1)*$posts_per_page; + + if($current_page < 1 || $current_page > $total_pages) { + http_response_code(404); + header('Content-Type: application/ld+json'); + die('{}'); + } + + $posts = db_select_posts(NOW, $posts_per_page, 'desc', $offset); + + $ordered_items = []; + if(!empty($posts)) { + foreach ($posts as $post) { + + $item = []; + $item['id'] = $config['url'].'/'.$post['id'].'/json'; + $item['type'] = 'Create'; + $item['actor'] = $config['url'].'/actor'; + $item['published'] = gmdate('Y-m-d\TH:i:s\Z', $post['post_timestamp']); + $item['to'] = ['https://www.w3.org/ns/activitystreams#Public']; + $item['cc'] = [$config['url'].'/followers']; + $item['object'] = $config['url'].'/'.$post['id'].'/'; + + $ordered_items[] = $item; + } + } + + $output = [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $config['url'].'/outbox?page='.$current_page, + 'type' => 'OrderedCollectionPage', + 'partOf' => $config['url'].'/outbox' + ]; + + if($current_page > 1) { + $output['prev'] = $config['url'].'/outbox?page='.($current_page-1); + } + + if($current_page < $total_pages) { + $output['next'] = $config['url'].'/outbox?page='.($current_page+1); + } + + $output['orderedItems'] = $ordered_items; + + header('Content-Type: application/ld+json'); + echo(json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); +endif; diff --git a/lib/activitypub-webfinger.php b/lib/activitypub-webfinger.php index 2ad44fb..15177cb 100644 --- a/lib/activitypub-webfinger.php +++ b/lib/activitypub-webfinger.php @@ -1,5 +1,7 @@ <?php + if(!$config['activitypub']) exit(); + // todo: handle empty usernames $actor = ltrim($config['microblog_account'], '@'); @@ -18,5 +20,5 @@ ] ]; - header('Content-Type: application/ld+json'); + header('Content-Type: application/jrd+json'); echo(json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); diff --git a/lib/database.php b/lib/database.php index 4e5e0cd..e64a80c 100644 --- a/lib/database.php +++ b/lib/database.php @@ -93,5 +93,71 @@ if($config['db_version'] == 3) { } } +// v5, update for activitypub +if($config['db_version'] == 4) { + try { + $db->exec("PRAGMA `user_version` = 5; + CREATE TABLE IF NOT EXISTS `followers` ( + `id` INTEGER PRIMARY KEY NOT NULL, + `follower_name` TEXT NOT NULL, + `follower_host` TEXT NOT NULL, + `follower_actor` TEXT, + `follower_inbox` TEXT, + `follower_shared_inbox` TEXT, + `follower_added` INTEGER + ); + CREATE UNIQUE INDEX `followers_users` ON followers (`follower_name`, `follower_host`); + CREATE INDEX `followers_shared_inboxes` ON followers (`follower_shared_inbox`); + "); + $config['db_version'] = 5; + } catch(PDOException $e) { + print 'Exception : '.$e->getMessage(); + die('cannot upgrade database table to v5!'); + } +} + +// v6, update for activitypub likes and announces +if($config['db_version'] == 5) { + try { + $db->exec("PRAGMA `user_version` = 6; + CREATE TABLE IF NOT EXISTS `activities` ( + `id` INTEGER PRIMARY KEY NOT NULL, + `activity_actor_name` TEXT NOT NULL, + `activity_actor_host` TEXT NOT NULL, + `activity_type` TEXT NOT NULL, + `activity_object_id` INTEGER NOT NULL, + `activity_updated` INTEGER + ); + CREATE INDEX `activities_objects` ON activities (`activity_object_id`); + CREATE UNIQUE INDEX `activities_unique` ON activities (`activity_actor_name`, `activity_actor_host`, `activity_type`, `activity_object_id`); + "); + $config['db_version'] = 6; + } catch(PDOException $e) { + print 'Exception : '.$e->getMessage(); + die('cannot upgrade database table to v6!'); + } +} + +// v7, update for activitypub key storage +if($config['db_version'] == 6) { + try { + $db->exec("PRAGMA `user_version` = 6; + CREATE TABLE IF NOT EXISTS `keys` ( + `id` INTEGER PRIMARY KEY NOT NULL, + `key_private` TEXT NOT NULL, + `key_public` TEXT NOT NULL, + `key_algo` TEXT DEFAULT 'sha512', + `key_bits` INTEGER DEFAULT 4096, + `key_type` TEXT DEFAULT 'rsa', + `key_created` INTEGER + ); + "); + $config['db_version'] = 7; + } catch(PDOException $e) { + print 'Exception : '.$e->getMessage(); + die('cannot upgrade database table to v7!'); + } +} + // debug: get a list of post table columns // var_dump($db->query("PRAGMA table_info(`posts`)")->fetchAll(PDO::FETCH_COLUMN, 1)); diff --git a/lib/functions.php b/lib/functions.php index c5990f1..6fe13d8 100644 --- a/lib/functions.php +++ b/lib/functions.php @@ -155,7 +155,7 @@ function db_select_posts($from, $amount=10, $sort='desc', $offset=0) { } function db_posts_count() { - global $config; + // global $config; global $db; if(empty($db)) return false; @@ -166,6 +166,24 @@ function db_posts_count() { return (int) $row['posts_count']; } +function mime_to_extension($mime) { + if(empty($mime)) return false; + $mime = trim($mime); + + $mime_types = [ + 'image/jpg' => 'jpg', + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/avif' => 'avif', + 'image/webp' => 'webp', + + 'text/plain' => 'txt', + 'text/markdown' => 'md', + ]; + + return isset($mime_types[$mime]) ? $mime_types[$mime] : false; +} + function convert_files_array($input_array) { $file_array = []; $file_count = count($input_array['name']); @@ -181,10 +199,10 @@ function convert_files_array($input_array) { } function attach_uploaded_files($files=[], $post_id=null) { + // todo: implement php-blurhash? if(empty($files['tmp_name'][0])) return false; - + $files = convert_files_array($files); - //var_dump($files);exit(); foreach($files as $file) { @@ -194,7 +212,7 @@ function attach_uploaded_files($files=[], $post_id=null) { continue; // skip this file } - if($file['size'] > 20000000) { + if($file['size'] == 0 || $file['size'] > 20000000) { // Exceeded filesize limit. // var_dump('invalid file size');exit(); continue; @@ -225,6 +243,7 @@ function attach_uploaded_files($files=[], $post_id=null) { } function detatch_files($file_ids=[], $post_id=null) { + // == "db_unlink_files" global $db; if(empty($db)) return false; if(empty($file_ids)) return false; @@ -255,7 +274,6 @@ function detatch_files($file_ids=[], $post_id=null) { function db_select_file($query, $method='id') { global $db; if(empty($db)) return false; - if($id === 0) return false; switch ($method) { case 'hash': @@ -271,7 +289,7 @@ function db_select_file($query, $method='id') { $statement->bindValue(':q', $query, PDO::PARAM_INT); break; } - + $statement->execute(); $row = $statement->fetch(PDO::FETCH_ASSOC); @@ -283,7 +301,7 @@ function db_link_file($file_id, $post_id) { if(empty($db)) return false; try { - $statement = $db->prepare('INSERT INTO file_to_post (file_id, post_id) VALUES (:file_id, :post_id)'); + $statement = $db->prepare('INSERT OR REPLACE INTO file_to_post (file_id, post_id, deleted) VALUES (:file_id, :post_id, NULL)'); $statement->bindValue(':file_id', $file_id, PDO::PARAM_INT); $statement->bindValue(':post_id', $post_id, PDO::PARAM_INT); @@ -297,6 +315,11 @@ function db_link_file($file_id, $post_id) { return true; } +function make_file_hash($file, $algo='sha1') { + if(!file_exists($file)) return false; + return hash_file($algo, $file); +} + function save_file($filename, $extension, $tmp_file, $post_id, $mime='') { global $db; if(empty($db)) return false; @@ -309,14 +332,20 @@ function save_file($filename, $extension, $tmp_file, $post_id, $mime='') { 'file_original' => $filename, 'file_mime_type' => $mime, 'file_size' => filesize($tmp_file), - 'file_hash' => hash_file($hash_algo, $tmp_file), + 'file_hash' => make_file_hash($tmp_file, $hash_algo), 'file_hash_algo' => $hash_algo, - 'file_meta' => '{}', + 'file_meta' => [], 'file_dir' => date('Y'), 'file_subdir' => date('m'), 'file_timestamp' => time() ]; + if(substr($mime, 0, 5) === 'image') { + $file_dimensions = getimagesize($tmp_file); + + list($insert['file_meta']['width'], $insert['file_meta']['height']) = getimagesize($tmp_file); + } + if(!is_dir($files_dir)) { mkdir($files_dir, 0755); } @@ -332,14 +361,22 @@ function save_file($filename, $extension, $tmp_file, $post_id, $mime='') { $insert['file_filename'] = $post_id . '-' . substr($insert['file_hash'], 0, 7); $path = $files_dir.DS.$insert['file_dir'].DS.$insert['file_subdir']; - if(move_uploaded_file($tmp_file, $path.DS.$insert['file_filename'] .'.'. $insert['file_extension'])) { + if(rename($tmp_file, $path.DS.$insert['file_filename'] .'.'. $insert['file_extension'])) { // add to database + chmod($path.DS.$insert['file_filename'] .'.'. $insert['file_extension'], 0644); + // check if file exists already $existing = db_select_file($insert['file_hash'], 'hash'); if(!empty($existing)) { - // just link existing file + // discard the newly uploaded file! + // unlink($path.DS.$insert['file_filename'] .'.'. $insert['file_extension']); // WHY?!! + + // handle file uploads without post ID, eg via XMLRPC + if($post_id == 0) return $existing['id']; + + // just link existing one! if(db_link_file($existing['id'], $post_id)) { return $existing['id']; } else { @@ -357,13 +394,16 @@ function save_file($filename, $extension, $tmp_file, $post_id, $mime='') { $statement->bindValue(':file_size', $insert['file_size'], PDO::PARAM_INT); $statement->bindValue(':file_hash', $insert['file_hash'], PDO::PARAM_STR); $statement->bindValue(':file_hash_algo', $insert['file_hash_algo'], PDO::PARAM_STR); - $statement->bindValue(':file_meta', $insert['file_meta'], PDO::PARAM_STR); + $statement->bindValue(':file_meta', json_encode($insert['file_meta']), PDO::PARAM_STR); $statement->bindValue(':file_dir', $insert['file_dir'], PDO::PARAM_STR); $statement->bindValue(':file_subdir', $insert['file_subdir'], PDO::PARAM_STR); $statement->bindValue(':file_timestamp', $insert['file_timestamp'], PDO::PARAM_INT); $statement->execute(); + // handle file uploads without post ID, eg via XMLRPC + if($post_id == 0) return $db->lastInsertId(); + // todo: check this? db_link_file($db->lastInsertId(), $post_id); @@ -389,6 +429,36 @@ function get_file_path($file) { return $url; } +function get_file_url($file) { + global $config; + + if(empty($file)) return false; + + $url = $config['url']; + $path = get_file_path($file); + + return $config['url'].DS.$path; +} + +function images_from_html($html) { + $matches = array(); + $regex = '/<img.*?src="(.*?)"/'; + preg_match_all($regex, $html, $matches); + + if(!empty($matches) && !empty($matches[1])) return $matches[1]; + + return []; +} + +function strip_img_tags($html) { + return trim(preg_replace("/<img[^>]+\>/i", "", $html)); +} + +function filter_tags($html) { + $allowed = '<em><i><strong><b><a><br><br />'; + return strip_tags($html, $allowed); +} + /* function that pings the official micro.blog endpoint for feed refreshes */ function ping_microblog() { global $config; @@ -557,3 +627,5 @@ function twitter_post_status($status='') { $twitter = new TwitterAPIExchange($config['twitter']); return $twitter->buildOauth($url, 'POST')->setPostfields($postfields)->performRequest(); } + +require_once(__DIR__.DS.'activitypub-functions.php'); diff --git a/lib/xmlrpc.php b/lib/xmlrpc.php index 985d0f1..3808a1d 100644 --- a/lib/xmlrpc.php +++ b/lib/xmlrpc.php @@ -1,10 +1,10 @@ <?php -$request_xml = file_get_contents("php://input"); +$request_xml = isset($request_xml) ? $request_xml : file_get_contents("php://input"); // check prerequisites if(!$config['xmlrpc']) { exit('No XML-RPC support detected!'); } -if(empty($request_xml)) { exit('XML-RPC server accepts POST requests only.'); } +if(empty($request_xml) && !isset($_GET['test'])) { exit('XML-RPC server accepts POST requests only.'); } $logfile = ROOT.DS.'log.txt'; @@ -53,6 +53,15 @@ function make_post($post, $method='metaWeblog') { @xmlrpc_set_type($date_modified, 'datetime'); @xmlrpc_set_type($date_modified_gmt, 'datetime'); + // format file attachments + if(!empty($post['post_attachments'])) { + foreach($post['post_attachments'] as $attachment) { + // todo: handle attachments other than images + $attachment_url = get_file_url($attachment); + $post['post_content'] .= PHP_EOL.'<img src="'.$attachment_url.'" alt="" />'; + } + } + if(str_starts_with($method, 'microblog')) { // convert the post format to a microblog post // similar to metaWeblog.recentPosts but with offset parameter for paging, @@ -63,7 +72,7 @@ function make_post($post, $method='metaWeblog') { 'date_modified' => $date_modified, 'permalink' => $config['url'].'/'.$post['id'], 'title' => '', - 'description' => ($post['post_content']), + 'description' => $post['post_content'], 'categories' => [], 'post_status' => 'published', 'author' => [ @@ -78,7 +87,7 @@ function make_post($post, $method='metaWeblog') { $mw_post = [ 'postid' => (int) $post['id'], 'title' => '', - 'description' => ($post['post_content']), // Post content + 'description' => $post['post_content'], // Post content 'link' => $config['url'].'/'.$post['id'], // Post URL // string userid†: ID of post author. 'dateCreated' => $date_created, @@ -141,6 +150,15 @@ function mw_get_categories($method_name, $args) { ]; } else { $categories = [ + [ + 'categoryId' => '1', + 'parentId' => '0', + 'categoryName' => 'default', + 'categoryDescription' => 'The default category', + 'description' => 'default', + 'htmlUrl' => '/', + 'rssUrl' => '/' + ] /* [ 'description' => 'Default', @@ -198,9 +216,19 @@ function mw_get_recent_posts($method_name, $args) { if(!$amount) $amount = 25; $amount = min($amount, 200); // cap the max available posts at 200 (?) - $posts = db_select_posts(null, $amount, 'asc', $offset); + $posts = db_select_posts(null, $amount, 'desc', $offset); if(empty($posts)) return []; + // get attached files + $ids = array_column($posts, 'id'); + $attached_files = db_get_attached_files($ids); + + for ($i=0; $i < count($posts); $i++) { + if(!empty($attached_files[$posts[$i]['id']])) { + $posts[$i]['post_attachments'] = $attached_files[$posts[$i]['id']]; + } + } + // call make_post() on all items $mw_posts = array_map('make_post', $posts, array_fill(0, count($posts), $method_name)); @@ -208,6 +236,7 @@ function mw_get_recent_posts($method_name, $args) { } function mw_get_post($method_name, $args) { + // todo: find a way to represent media attachments to editors list($post_id, $username, $password) = $args; @@ -219,6 +248,14 @@ function mw_get_post($method_name, $args) { } $post = db_select_post($post_id); + + // get attached files + $attached_files = db_get_attached_files($post_id); + + if(!empty($attached_files[$post_id])) { + $post['post_attachments'] = $attached_files[$post_id]; + } + if($post) { if($method_name == 'microblog.getPost') { $mw_post = make_post($post, $method_name); @@ -236,9 +273,16 @@ function mw_get_post($method_name, $args) { } function mw_new_post($method_name, $args) { + global $config; + + if($method_name == 'blogger.newPost') { + // app_key (unused), blog_id, user, pass, array of post content, publish/draft - // blog_id, unknown, unknown, array of post content, unknown - list($blog_id, $username, $password, $content, $_) = $args; + list($_, $blog_id, $username, $password, $content, $publish_flag) = $args; + } else { + // blog_id, user, pass, array of post content, unknown + list($blog_id, $username, $password, $content, $_) = $args; + } if(!check_credentials($username, $password)) { return [ @@ -260,6 +304,12 @@ function mw_new_post($method_name, $args) { if(isset($content['date_created'])) { $post['post_timestamp'] = $content['date_created']->timestamp; } + } elseif($method_name == 'blogger.newPost') { + // support eg. micro.blog iOS app + $post = [ + 'post_content' => $content, + 'post_timestamp' => time() + ]; } else { $post = [ // 'post_hp' => $content['flNotOnHomePage'], @@ -275,8 +325,28 @@ function mw_new_post($method_name, $args) { } } + // clean up image tags and get references to image files + $image_urls = images_from_html($post['post_content']); + + // remove image tags + $post['post_content'] = strip_img_tags($post['post_content']); + $insert_id = db_insert($post['post_content'], $post['post_timestamp']); if($insert_id && $insert_id > 0) { + // create references to file attachments + if(!empty($image_urls)) { + foreach ($image_urls as $url) { + if(str_contains($url, $config['url'])) { + $filename = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_FILENAME); + + // todo: match by hash instead of filename?? + + $file = db_select_file($filename, 'filename'); + if($file) db_link_file($file['id'], $insert_id); + } + } + } + // success rebuild_feeds(); @@ -293,9 +363,12 @@ function mw_new_post($method_name, $args) { } function mw_edit_post($method_name, $args) { + // todo: make this work with different hash algorithms, as stored in the DB + global $config; + global $db; - // post_id, unknown, unknown, array of post content - list($post_id, $username, $password, $content, $_) = $args; + // post_id, user, password, array of post content + list($post_id, $username, $password, $content) = $args; if(!check_credentials($username, $password)) { return [ @@ -330,6 +403,77 @@ function mw_edit_post($method_name, $args) { } } + // compare old and new image attachments: + + // load existing attachments + $attached_files = db_get_attached_files($post_id); + + // extract new attachments from images + $image_urls = images_from_html($post['post_content']); + +var_dump($image_urls); + // compare via file hash + $hashes_of_existing_links = array_column(reset($attached_files), 'file_hash'); + $hashes_of_edited_post = []; + + foreach($image_urls as $url) { + $file_path = ROOT.parse_url($url, PHP_URL_PATH); // fragile? + + if(file_exists($file_path)) { + var_dump($file_path); + $hashes_of_edited_post[] = make_file_hash($file_path); + } + } + // $hashes_of_edited_post = array_unique($hashes_of_edited_post); + + $hashes_to_link = array_diff($hashes_of_edited_post, $hashes_of_existing_links); + $hashes_to_unlink = array_diff($hashes_of_existing_links, $hashes_of_edited_post); + + + var_dump([ + 'post_id' => $post_id, + 'existing' => $hashes_of_existing_links, + 'new' => $hashes_of_edited_post, + 'to_link' => $hashes_to_link, + 'to_unlink' => $hashes_to_unlink + ]); + + + try { + $statement = $db->prepare('INSERT OR REPLACE INTO file_to_post (file_id, post_id, deleted) VALUES ((SELECT id FROM files WHERE file_hash = :file_hash LIMIT 1), :post_id, :deleted)'); + + $attachment = [ + 'hash' => null, + 'post' => $post_id + ]; + + $statement->bindParam(':file_hash', $attachment['hash'], PDO::PARAM_STR); + $statement->bindParam(':post_id', $attachment['post'], PDO::PARAM_INT); + + // link + foreach ($hashes_to_link as $hash) { + $attachment['hash'] = $hash; + $statement->bindValue(':deleted', null, PDO::PARAM_NULL); + $statement->execute(); + } + + // unlink + foreach ($hashes_to_unlink as $hash) { + $attachment['hash'] = $hash; + $statement->bindValue(':deleted', time(), PDO::PARAM_INT); + $statement->execute(); + } + + } catch(PDOException $e) { + return [ + 'faultCode' => 400, + 'faultString' => 'Post update SQL error: '.$e->getMessage() + ]; + } + + // remove html img tags + $post['post_content'] = strip_img_tags($post['post_content']); + $update = db_update($post_id, $post['post_content'], $post['post_timestamp']); if($update && $update > 0) { // success @@ -373,22 +517,81 @@ function mw_delete_post($method_name, $args) { } } +function mw_new_media_object($method_name, $args) { + global $config; + + list($blog_id, $username, $password, $data) = $args; + + if(!check_credentials($username, $password)) { + return [ + 'faultCode' => 403, + 'faultString' => 'Incorrect username or password.' + ]; + } + + $new_ext = pathinfo($data['name'], PATHINFO_EXTENSION); + if(!empty($data['type'])) { + $new_ext = mime_to_extension($data['type']); + } + + // file_put_contents(ROOT.DS.'test.txt', json_encode([$data['type'], pathinfo($data['name'], PATHINFO_EXTENSION), $new_ext])); + + $media_object = $data['bits']->scalar; + + // save the file to a temporary location + $tmp_file = tempnam(sys_get_temp_dir(), 'microblog_'); + file_put_contents($tmp_file, $media_object); + + // make a DB entry for the file + $new_file_id = save_file($data['name'], $new_ext, $tmp_file, 0, $data['type']); + + // get file info + $file = db_select_file($new_file_id, 'id'); + // $file_path = get_file_path($file); + + $url = get_file_url($file); + + $success = ($new_file_id) ? 1 : 0; + + if($success > 0) { + // If newMediaObject succeeds, it returns a struct, which must contain at least one element, url, which is the url through which the object can be accessed. It must be either an FTP or HTTP url. + return [ + // 'url' => 'https://microblog.oelna.de/files/this-is-a-test.jpg' + 'url' => $url, + 'title' => $file['file_original'], + 'type' => $data['type'] ? $data['type'] : 'image/jpg' // is this reasonable? + ]; + } else { + // If newMediaObject fails, it throws an error. + return [ + 'faultCode' => 400, + 'faultString' => 'Could not store media object.' + ]; + } +} + // https://codex.wordpress.org/XML-RPC_MetaWeblog_API // https://community.devexpress.com/blogs/theprogressbar/metablog.ashx // idea: http://www.hixie.ch/specs/pingback/pingback#TOC3 $server = xmlrpc_server_create(); xmlrpc_server_register_method($server, 'demo.sayHello', 'say_hello'); +// http://nucleuscms.org/docs//item/204 +xmlrpc_server_register_method($server, 'blogger.newPost', 'mw_new_post'); +xmlrpc_server_register_method($server, 'blogger.editPost', 'mw_edit_post'); +xmlrpc_server_register_method($server, 'blogger.getPost', 'mw_get_post'); +xmlrpc_server_register_method($server, 'blogger.deletePost', 'mw_delete_post'); xmlrpc_server_register_method($server, 'blogger.getUsersBlogs', 'mw_get_users_blogs'); +xmlrpc_server_register_method($server, 'blogger.getRecentPosts', 'mw_get_recent_posts'); xmlrpc_server_register_method($server, 'blogger.getUserInfo', 'mw_get_user_info'); -xmlrpc_server_register_method($server, 'blogger.deletePost', 'mw_delete_post'); +xmlrpc_server_register_method($server, 'blogger.newMediaObject', 'mw_new_media_object'); xmlrpc_server_register_method($server, 'metaWeblog.getCategories', 'mw_get_categories'); xmlrpc_server_register_method($server, 'metaWeblog.getRecentPosts', 'mw_get_recent_posts'); xmlrpc_server_register_method($server, 'metaWeblog.newPost', 'mw_new_post'); xmlrpc_server_register_method($server, 'metaWeblog.editPost', 'mw_edit_post'); xmlrpc_server_register_method($server, 'metaWeblog.getPost', 'mw_get_post'); -// xmlrpc_server_register_method($server, 'metaWeblog.newMediaObject', 'mw_new_mediaobject'); +xmlrpc_server_register_method($server, 'metaWeblog.newMediaObject', 'mw_new_media_object'); // non-standard convenience? xmlrpc_server_register_method($server, 'metaWeblog.getPosts', 'mw_get_recent_posts'); @@ -402,7 +605,7 @@ xmlrpc_server_register_method($server, 'microblog.getPost', 'mw_get_post'); xmlrpc_server_register_method($server, 'microblog.newPost', 'mw_new_post'); xmlrpc_server_register_method($server, 'microblog.editPost', 'mw_edit_post'); xmlrpc_server_register_method($server, 'microblog.deletePost', 'mw_delete_post'); -// xmlrpc_server_register_method($server, 'microblog.newMediaObject', 'mw_new_mediaobject'); +xmlrpc_server_register_method($server, 'microblog.newMediaObject', 'mw_new_media_object'); // micro.blog pages are not supported /* @@ -413,14 +616,17 @@ xmlrpc_server_register_method($server, 'microblog.editPage', 'say_hello'); xmlrpc_server_register_method($server, 'microblog.deletePage', 'say_hello'); */ -// https://docstore.mik.ua/orelly/webprog/pcook/ch12_08.htm -$response = xmlrpc_server_call_method($server, $request_xml, null, [ - 'escaping' => 'markup', - 'encoding' => 'UTF-8' -]); - -if($response) { - header('Content-Type: text/xml; charset=utf-8'); - error_log($request_xml."\n\n".$response."\n\n", 3, $logfile); - echo($response); +if(!isset($_GET['test'])) { + // https://docstore.mik.ua/orelly/webprog/pcook/ch12_08.htm + $response = xmlrpc_server_call_method($server, $request_xml, null, [ + 'escaping' => 'markup', + 'encoding' => 'UTF-8' + ]); + + if($response) { + header('Content-Type: text/xml; charset=utf-8'); + // todo: make error logging a config option + // error_log(date('Y-m-d H:i:s')."\n".$request_xml."\n\n".$response."\n\n", 3, $logfile); + echo($response); + } } |