diff options
author | Arno Richter <oelna@oelna.de> | 2023-08-16 14:52:58 +0200 |
---|---|---|
committer | Arno Richter <oelna@oelna.de> | 2023-08-16 14:52:58 +0200 |
commit | 66c6658bac8b0e99b59e3b9f4eb285f38bcebcf5 (patch) | |
tree | fa54ab21d4c6122df124459030dd5c6af723f1af | |
parent | ff2858b6ea8f586daa95e51ae21315f86cc5ded5 (diff) | |
download | microblog-66c6658bac8b0e99b59e3b9f4eb285f38bcebcf5.tar.gz microblog-66c6658bac8b0e99b59e3b9f4eb285f38bcebcf5.tar.bz2 microblog-66c6658bac8b0e99b59e3b9f4eb285f38bcebcf5.zip |
huge update to implement first version of activitypub support. closes #16. AP and subdir hosting are incompatible!
27 files changed, 1888 insertions, 77 deletions
@@ -3,5 +3,5 @@ config.php feed.* feeds/ files/ -keys/ +test.php log.txt @@ -14,9 +14,6 @@ AddType application/json .json </IfModule> </Files> -# deny access to key files -RedirectMatch 403 ^/keys/.*$ - <IfModule mod_rewrite.c> RewriteEngine On RewriteBase / @@ -25,6 +22,9 @@ RewriteBase / RewriteRule ^feed/json/?$ feed/feed.json [L] RewriteRule ^feed/atom/?$ feed/feed.xml [L] +RewriteRule ^.well_known/webfinger$ /.well-known/webfinger [R=302] # stupid +RewriteRule ^.well-known/webfinger$ /webfinger [L] # correct + RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^(.*) index.php [L] diff --git a/config-dist.php b/config-dist.php index 2036e72..095eadb 100644 --- a/config-dist.php +++ b/config-dist.php @@ -11,9 +11,12 @@ DEFINE('NOW', time()); date_default_timezone_set('Europe/Berlin'); /* make the path easier to read */ -$path_fragments = (parse_url(str_replace(dirname($_SERVER['SCRIPT_NAME']), '', $_SERVER['REQUEST_URI']), PHP_URL_PATH)); -$path = explode('/', trim($path_fragments, '/')); -if(mb_strlen($path[0]) == 0) $path = array(); +$dir = dirname($_SERVER['SCRIPT_NAME']); +$uri = $_SERVER['REQUEST_URI']; +$uri = substr($uri, mb_strlen($dir)); // handle subdir installs +$path_fragments = parse_url($uri, PHP_URL_PATH); +$path = (empty($path_fragments)) ? [''] : explode('/', trim($path_fragments, '/')); +if(mb_strlen($path[0]) == 0) { $path = []; } // (mostly) user settings $config = array( @@ -22,12 +25,16 @@ $config = array( 'language' => 'en', 'max_characters' => 280, 'posts_per_page' => 10, - 'microblog_account' => '', // fill in a @username if you like + 'theme' => 'microblog', // filename of the CSS to use + 'microblog_account' => '@username', // fill in a @username if you like (is also the ActivityPub actor preferredUsername) + 'site_title' => "username's Microblog", // is also the ActivityPub actor name + 'site_claim' => "This is an automated account. Don't mention or reply please.", // is also the ActivityPub actor summary 'admin_user' => 'admin', 'admin_pass' => 'dove-life-bird-lust', 'app_token' => '3e83b13d99bf0de6c6bde5ac5ca4ae687a3d46db', // random secret used as auth with XMLRPC 'cookie_life' => 60*60*24*7*4, // cookie life in seconds 'ping' => true, // enable automatic pinging of the micro.blog service + 'activitypub' => true, 'crosspost_to_twitter' => false, // set this to true to automatically crosspost to a twitter account (requires app credentials, see below) 'twitter' => array( // get your tokens over at https://dev.twitter.com/apps 'oauth_access_token' => '', @@ -36,8 +43,11 @@ $config = array( 'consumer_secret' => '' ), 'show_edits' => true, // displays the modification time of posts + 'subdir_install' => ($dir === '/') ? false : true, ); +unset($dir, $uri, $path_fragments, $path); + $config['xmlrpc'] = function_exists('xmlrpc_server_create'); $config['local_time_offset'] = date('P'); diff --git a/css/microblog/icons/icon-announce.svg b/css/microblog/icons/icon-announce.svg new file mode 100644 index 0000000..e29d298 --- /dev/null +++ b/css/microblog/icons/icon-announce.svg @@ -0,0 +1,4 @@ +<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="24" height="20" viewBox="0 0 24 24" color="currentColor" fill="currentColor" aria-label="Repost"> + <title>Repost</title> + <path d="M19.998 9.497a1 1 0 0 0-1 1v4.228a3.274 3.274 0 0 1-3.27 3.27h-5.313l1.791-1.787a1 1 0 0 0-1.412-1.416L7.29 18.287a1.004 1.004 0 0 0-.294.707v.001c0 .023.012.042.013.065a.923.923 0 0 0 .281.643l3.502 3.504a1 1 0 0 0 1.414-1.414l-1.797-1.798h5.318a5.276 5.276 0 0 0 5.27-5.27v-4.228a1 1 0 0 0-1-1Zm-6.41-3.496-1.795 1.795a1 1 0 1 0 1.414 1.414l3.5-3.5a1.003 1.003 0 0 0 0-1.417l-3.5-3.5a1 1 0 0 0-1.414 1.414l1.794 1.794H8.27A5.277 5.277 0 0 0 3 9.271V13.5a1 1 0 0 0 2 0V9.271a3.275 3.275 0 0 1 3.271-3.27Z"></path> +</svg> diff --git a/css/microblog/icons/icon-like.svg b/css/microblog/icons/icon-like.svg new file mode 100644 index 0000000..c126633 --- /dev/null +++ b/css/microblog/icons/icon-like.svg @@ -0,0 +1,4 @@ +<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="20" height="19" viewBox="0 0 24 22" color="currentColor" fill="currentColor" aria-label="Like"> + <title>Like</title> + <path d="M1 7.66c0 4.575 3.899 9.086 9.987 12.934.338.203.74.406 1.013.406.283 0 .686-.203 1.013-.406C19.1 16.746 23 12.234 23 7.66 23 3.736 20.245 1 16.672 1 14.603 1 12.98 1.94 12 3.352 11.042 1.952 9.408 1 7.328 1 3.766 1 1 3.736 1 7.66Z" stroke="currentColor" stroke-width="2"></path> +</svg> diff --git a/css/microblog.css b/css/microblog/microblog.css index e6a657a..2742f59 100644 --- a/css/microblog.css +++ b/css/microblog/microblog.css @@ -1,4 +1,4 @@ -@import './reset.css'; +@import '../reset.css'; :root { --primary-color: #007aff; @@ -148,6 +148,38 @@ nav.main li + li a { color: currentColor; } +.wrap .post-meta li span.amount { + margin-inline-end: 0.25ch; +} + +.wrap .post-meta li span.word { display: inline-block; text-indent: -9999px; } +.wrap .post-meta li span.amount::after { + display: inline-block; + content: ''; + background-repeat: no-repeat; + background-position: center center; + background-size: contain; + vertical-align: middle; + margin-inline-start: 0.25ch; + opacity: 0.25; +} + +.wrap .post-meta li a:is(:hover, :focus) span.amount::after { + opacity: 1; +} + +.wrap .post-meta li.post-likes span.amount::after { + background-image: url(./icons/icon-like.svg); + width: 0.7rem; + height: 0.7rem; +} + +.wrap .post-meta li.post-boosts span.amount::after { + background-image: url(./icons/icon-announce.svg); + width: 1rem; + height: 1rem; +} + .wrap .post-content { font-size: 1.25rem; overflow-wrap: break-word; diff --git a/css/plain/icons/icon-announce.svg b/css/plain/icons/icon-announce.svg new file mode 100644 index 0000000..1c62441 --- /dev/null +++ b/css/plain/icons/icon-announce.svg @@ -0,0 +1,4 @@ +<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="24" height="20" viewBox="0 0 24 24" color="Canvas" fill="Canvas" aria-label="Repost"> + <title>Repost</title> + <path d="M19.998 9.497a1 1 0 0 0-1 1v4.228a3.274 3.274 0 0 1-3.27 3.27h-5.313l1.791-1.787a1 1 0 0 0-1.412-1.416L7.29 18.287a1.004 1.004 0 0 0-.294.707v.001c0 .023.012.042.013.065a.923.923 0 0 0 .281.643l3.502 3.504a1 1 0 0 0 1.414-1.414l-1.797-1.798h5.318a5.276 5.276 0 0 0 5.27-5.27v-4.228a1 1 0 0 0-1-1Zm-6.41-3.496-1.795 1.795a1 1 0 1 0 1.414 1.414l3.5-3.5a1.003 1.003 0 0 0 0-1.417l-3.5-3.5a1 1 0 0 0-1.414 1.414l1.794 1.794H8.27A5.277 5.277 0 0 0 3 9.271V13.5a1 1 0 0 0 2 0V9.271a3.275 3.275 0 0 1 3.271-3.27Z"></path> +</svg> diff --git a/css/plain/icons/icon-like.svg b/css/plain/icons/icon-like.svg new file mode 100644 index 0000000..61b2777 --- /dev/null +++ b/css/plain/icons/icon-like.svg @@ -0,0 +1,4 @@ +<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="20" height="19" viewBox="0 0 24 22" color="Canvas" fill="Canvas" aria-label="Like"> + <title>Like</title> + <path d="M1 7.66c0 4.575 3.899 9.086 9.987 12.934.338.203.74.406 1.013.406.283 0 .686-.203 1.013-.406C19.1 16.746 23 12.234 23 7.66 23 3.736 20.245 1 16.672 1 14.603 1 12.98 1.94 12 3.352 11.042 1.952 9.408 1 7.328 1 3.766 1 1 3.736 1 7.66Z" stroke="Canvas" stroke-width="2"></path> +</svg> diff --git a/css/plain/plain.css b/css/plain/plain.css new file mode 100644 index 0000000..e65bc8c --- /dev/null +++ b/css/plain/plain.css @@ -0,0 +1,451 @@ +@import '../reset.css'; + +/* + // SYSTEM FONTS + + .body { font: -apple-system-body } + .headline { font: -apple-system-headline } + .subheadline { font: -apple-system-subheadline } + .caption1 { font: -apple-system-caption1 } + .caption2 { font: -apple-system-caption2 } + .footnote { font: -apple-system-footnote } + .short-body { font: -apple-system-short-body } + .short-headline { font: -apple-system-short-headline } + .short-subheadline { font: -apple-system-short-subheadline } + .short-caption1 { font: -apple-system-short-caption1 } + .short-footnote { font: -apple-system-short-footnote } + .tall-body { font: -apple-system-tall-body } + + + // SYSTEM COLORS https://blog.jim-nielsen.com/2021/css-system-colors/ + + AccentColor // Background of accented user interface controls + AccentColorText // Text of accented user interface controls + ActiveText // Text of active links + ButtonBorder // Base border color of controls + ButtonFace // Background color of controls + ButtonText // Text color of controls + Canvas // Background of application content or documents + CanvasText // Text color in application content or documents + Field // Background of input fields + FieldText // Text in input fields + GrayText // Text color for disabled items (e.g. a disabled control) + Highlight // Background of selected items + HighlightText // Text color of selected items + LinkText // Text of non-active, non-visited links + Mark // Background of text that has been specially marked (such as by the HTML mark element) + MarkText // Text that has been specially marked (such as by the HTML mark element) + VisitedText // Text of visited links +*/ + +:root { + color-scheme: light dark; + + --primary-color: CanvasText; + --secondary-color: ButtonText; + + --background-color: Canvas; + --text-color: CanvasText; +} + +html { + font: 100%/1.4 system-ui, Helvetica, sans-serif; + background-color: var(--background-color); + color: var(--text-color); + + accent-color: var(--primary-color); + -webkit-user-select: none; + user-select: none; + -webkit-tap-highlight-color: transparent; + tap-highlight-color: transparent; + -webkit-touch-callout: none; + touch-callout: none; +} + +img { + display: block; + max-width: 100%; + height: auto; +} + +.wrap { + width: min(95%, 40rem); + margin: 2rem auto; + padding: 1rem; +} + +.button { + display: block; + background: var(--primary-color); + color: var(--background-color); + text-decoration: none; + border-radius: 0.4rem; + padding: 0.2rem 0.5rem; + font-weight: bold; + text-align: center; +} + +.button.alert { + background: coral; +} + +.hidden { + display: none !important; +} + +nav.main ul { + display: flex; + margin-bottom: 2rem; +} + +nav.main li { + list-style: none; + flex: 1; +} + +nav.main li a { + /* inherits from .button */ +} + +nav.main li + li a { + margin-left: 0.2rem; +} + +.wrap .posts { + +} + +.wrap .posts > li { + list-style: none; + margin-bottom: 1rem; + border-bottom: 1px solid CanvasText; + padding-bottom: 1rem; + + display: grid; + grid-template-columns: repeat(6, 1fr); +} + +.wrap .posts > li > * { + outline: 0px solid #f0f; +} + +.wrap .posts > li:last-child { + margin-bottom: 2rem; + border-bottom: 0; + padding-bottom: 0; +} + +.timeline .pagination { overflow: hidden; } +.timeline .pagination a { + display: block; + background: var(--primary-color); + color: Canvas; + text-decoration: none; + border-radius: 0.4rem; + padding: 0.2rem 0.6rem; + font-weight: bold; + float: left; +} +.timeline .pagination .next { float: right; } + +.wrap .post-timestamp { + display: block; + color: CanvasText; + text-decoration: none; + font-size: 0.8rem; + text-transform: uppercase; + font-weight: bold; + margin-bottom: 0.5rem; + grid-column-start: span 3; +} + +.wrap .post-timestamp time.modified { + display: block; + /* color: hsla(0, 0%, 0%, 0.2); */ + /* mix-blend-mode: multiply; */ + color: color-mix(in oklch, CanvasText 20%, Canvas); +} + +.wrap .post-meta { + grid-column-start: span 3; +} + +.wrap .post-meta ul { + display: flex; + justify-content: flex-end; + gap: 0.75ch; +} + +.wrap .post-meta li { + list-style: none; +} + +.wrap .post-meta li a { + display: block; + color: hsla(0, 0%, 0%, 0.2); + color: color-mix(in oklch, CanvasText 20%, Canvas); + /* mix-blend-mode: multiply; */ + text-decoration: none; + font-size: 0.8rem; + text-transform: uppercase; + font-weight: bold; + margin-bottom: 0.5rem; +} + +.wrap .post-meta li a:is(:hover, :focus) { + color: currentColor; +} + +.wrap .post-meta li span.amount { + margin-inline-end: 0.25ch; +} + +.wrap .post-meta li span.word { display: inline-block; text-indent: -9999px; } +.wrap .post-meta li span.amount::after { + display: inline-block; + content: ''; + background-repeat: no-repeat; + background-position: center center; + background-size: contain; + vertical-align: middle; + margin-inline-start: 0.25ch; + opacity: 0.25; +} + +.wrap .post-meta li a:is(:hover, :focus) span.amount::after { + opacity: 1; +} + +.wrap .post-meta li.post-likes span.amount::after { + background-image: url(./icons/icon-like.svg); + width: 0.7rem; + height: 0.7rem; +} + +.wrap .post-meta li.post-boosts span.amount::after { + background-image: url(./icons/icon-announce.svg); + width: 1rem; + height: 1rem; +} + +.wrap .post-content { + font-size: 1.25rem; + overflow-wrap: break-word; + grid-column-start: span 6; +} + +.wrap .post-content a { + color: var(--primary-color); + text-decoration: none; +} + +.wrap form.delete { + width: 100%; + grid-column-start: span 6; + display: flex; + margin-block-start: 2rem; +} + +.wrap form.delete input[type="submit"] { + flex: 1; + line-height: 1.4; + cursor: pointer; +} + +.wrap .posts li .message { + width: 100%; + grid-column-start: span 6; + margin-block-start: 2rem; +} + +.postform form, +form.edit, +.login form { + grid-column-start: span 6; + overflow: hidden; +} + +:is(.postform, .edit) textarea { + width: 100%; + border: 2px solid CanvasText; + background: Canvas; + padding: 0.5rem; + font-size: 1.25rem; + resize: vertical; + min-height: 10rem; + margin-bottom: 0.5rem; +} + +:is(.postform, .edit) textarea:focus { + border-color: var(--primary-color); + outline: none; +} + +:is(.postform, .edit) .post-nav { + width: 100%; + display: flex; + gap: 1rem; + align-items: center; +} + +:is(.postform, .edit) input[type="submit"], +.login input[type="submit"] { + -webkit-appearance: none; + appearance: none; + border: 0; + display: block; + background: CanvasText; + color: Canvas; + text-decoration: none; + border-radius: 0.4rem; + padding: 0.3rem 0.8rem 0.4rem; + font-weight: bold; + text-align: center; + cursor: pointer; + float: right; +} + +:is(.postform, .edit) #count { + color: CanvasText; +} + +:is(.postform, .edit) #post-droparea { + border: 0.15rem dashed CanvasText; + color: CanvasText; + padding: 0.25rem; + cursor: pointer; +} + +:is(.postform, .edit) #post-droparea.drag, +:is(.postform, .edit) #post-droparea:is(:hover, :focus) { + background-color: var(--primary-color); + color: Canvas; + border: 0.15rem solid var(--primary-color); +} + +:is(.postform, .edit) #post-attachments-label { + display: flex; + border: 0.15rem dashed rgba(0,0,0,0.4); + color: rgba(0,0,0,0.4); + padding: 0.25rem; + cursor: pointer; + position: relative; + align-self: stretch; + align-items: center; +} + +:is(.postform, .edit) #post-attachments { + /* cover the entire label, for drag and drop */ + display: block; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; +} + +:is(.postform, .edit) #post-attachments-list { + flex: 1; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + padding-inline-end: 1rem; + align-self: stretch; + justify-content: center; +} + +:is(.postform, .edit) #post-attachments-list li + li { + margin-block-start: 0.25em; + border-top: 1px solid rgba(0,0,0,0.2); + padding-block-start: 0.25em; +} + +:is(.postform, .edit) #post-attachments-list img.file-preview { + display: inline-block; + vertical-align: middle; + margin-right: 1ch; + width: 1.75rem; + height: 1.75rem; + outline: 0px solid #f0f; + object-fit: cover; + background: #fff; +} + +:is(.postform, .edit) #post-attachments-list input[type="checkbox"] { + -webkit-appearance: checkbox; + appearance: checkbox; +} + +:is(.timeline, .single) .post-attachments { + grid-column-start: span 6; + margin-block-start: 1rem; +} + +:is(.timeline, .single) .post-attachments li + li { + margin-block-start: 0.5rem; +} + +:is(.postform, .edit) .message, +.login .message { + background-color: #87b26c; + padding: 0.5rem; + color: var(--secondary-color); + border-radius: 0.4rem; + margin-bottom: 0.5rem; +} + +:is(.postform, .edit) .error, +.login .error { + background-color: #9c2128; +} + +.login input[type="text"], +.login input[type="password"] { + width: 100%; + border: 2px solid var(--background-color); + background: Canvas; + padding: 0.5rem; + font-size: 1.25rem; + resize: vertical; + margin-bottom: 0.5rem; +} + +.login input[type="text"]:focus, +.login input[type="password"]:focus { + border-color: var(--primary-color); + outline: none; +} + +footer { + width: min(95%, 40rem); + margin: 0.5rem auto 2rem; +} + +footer ul { + list-style: none; + display: flex; + justify-content: center; + gap: 1rem; +} + +footer li a { + color: color-mix(in oklch, CanvasText 25%, Canvas); + text-decoration: none; + font-size: 0.8rem; + text-transform: uppercase; + font-weight: bold; + margin-bottom: 0.5rem; +} + +/* +@supports (background: paint(id)) { + input[type="submit"] { + background: paint(squircle) !important; + --squircle-radius: 8px; + --squircle-fill: var(--primary-color); + + border-radius: 0; + } +} +*/ diff --git a/favicon-large.png b/favicon-large.png Binary files differnew file mode 100644 index 0000000..af5aca6 --- /dev/null +++ b/favicon-large.png diff --git a/favicon.ico b/favicon.ico Binary files differindex 550d600..46adbbd 100644 --- a/favicon.ico +++ b/favicon.ico @@ -16,6 +16,9 @@ } else { $page = mb_strtolower(path(0)); + // why? + // if($page == '.well_known' && path(1) == 'webfinger') { $page = 'webfinger'; } + switch($page) { case 'login': $template = 'login'; @@ -46,12 +49,35 @@ die(); } break; + /* + case 'webfinger': + die('aaaa'); + if(!empty(path(1)) && path(1) == 'webfinger') { + require_once(ROOT.DS.'lib'.DS.'activitypub-webfinger.php'); + } else { + die('xxx'); + http_response_code(404); + die(); + } + die('abc'); + break; + */ case 'actor': require_once(ROOT.DS.'lib'.DS.'activitypub-actor.php'); break; + case 'followers': + require_once(ROOT.DS.'lib'.DS.'activitypub-followers.php'); + break; + case 'outbox': + require_once(ROOT.DS.'lib'.DS.'activitypub-outbox.php'); + break; + case 'inbox': + require_once(ROOT.DS.'lib'.DS.'activitypub-inbox.php'); + break; default: // redirect everything else to the homepage if(!empty(path(0)) && path(0) != 'page') { + // die(path(0) . path(1) . 'WTF'); header('Location: '.$config['url']); die(); } diff --git a/js/microblog.js b/js/microblog.js index f7963e3..d1149e7 100644 --- a/js/microblog.js +++ b/js/microblog.js @@ -24,7 +24,7 @@ if (textarea) { const postForm = document.querySelector('#post-new-form'); let useDragDrop = (!!window.FileReader && 'draggable' in document.createElement('div')); -useDragDrop = true; +// useDragDrop = false; // remove, only for testing! if (postForm) { const droparea = postForm.querySelector('#post-droparea'); const attachmentsInput = postForm.querySelector('#post-attachments'); @@ -158,8 +158,8 @@ if (postForm) { if (response.ok && response.status == 200) { const txt = await response.text(); - // console.log(response, txt); - window.location.href = postForm.dataset.redirect; + // console.log('form result', response, txt); + window.location.href = postForm.dataset.redirect + '?t=' + Date.now(); } else { console.warn('error during post submission!', response); } 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); + } } diff --git a/snippets/header.snippet.php b/snippets/header.snippet.php index cc0becb..ca746b9 100644 --- a/snippets/header.snippet.php +++ b/snippets/header.snippet.php @@ -2,7 +2,7 @@ $title_suffix = isset($title_suffix) ? ' - ' . $title_suffix : ''; $css = 'microblog'; // the default - if(!empty($config['theme']) && file_exists(ROOT.DS.'css'.DS.$config['theme'].'.css')) { + if(!empty($config['theme']) && file_exists(ROOT.DS.'css'.DS.$config['theme'].DS.$config['theme'].'.css')) { $css = $config['theme']; } @@ -16,7 +16,12 @@ <title><?= empty($config['microblog_account']) ? "" : $config['microblog_account'] . "'s "; ?>micro.blog<?= $title_suffix ?></title> <meta name="viewport" content="width=device-width, initial-scale=1" /> - + <meta name="apple-mobile-web-app-capable" content="yes" /> + <link rel="apple-touch-icon" href="<?= $config['url'] ?>/favicon-large.png" /> + <!-- <link rel="apple-touch-startup-image" href="launch.png"> --> + <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> + <meta name="apple-mobile-web-app-title" content="<?= $config['site_title'] ?>"> + <link rel="alternate" type="application/json" title="JSON Feed" href="<?= $config['url'] ?>/feed/json" /> <link rel="alternate" type="application/atom+xml" title="Atom Feed" href="<?= $config['url'] ?>/feed/atom" /> <?php if($config['xmlrpc']): ?><link rel="EditURI" type="application/rsd+xml" title="RSD" href="<?= $config['url'] ?>/rsd" /><?php endif; ?> @@ -27,8 +32,10 @@ <?php if(!empty($config['microblog_account'])): ?> <link href="https://micro.blog/<?= ltrim($config['microblog_account'], '@') ?>" rel="me" /> <?php endif; ?> - - <link rel="stylesheet" href="<?= $config['url'] ?>/css/<?= $css ?>.css" /> + + <link rel="icon" href="<?= $config['url'] ?>/favicon.ico" /> + + <link rel="stylesheet" href="<?= $config['url'] ?>/css/<?= $css ?>/<?= $css ?>.css" /> <script src="<?= $config['url'] ?>/js/microblog.js" type="module" defer></script> </head> diff --git a/templates/loginform.inc.php b/templates/loginform.inc.php index b852de7..de155e6 100644 --- a/templates/loginform.inc.php +++ b/templates/loginform.inc.php @@ -21,7 +21,7 @@ $title_suffix = 'login'; require(ROOT.DS.'snippets'.DS.'header.snippet.php'); -?><body> +?><body ontouchstart=""> <div class="wrap"> <?php require(ROOT.DS.'snippets'.DS.'nav.snippet.php'); ?> <p>Please enter your login information.</p> diff --git a/templates/postform.inc.php b/templates/postform.inc.php index df7566c..59579c1 100644 --- a/templates/postform.inc.php +++ b/templates/postform.inc.php @@ -25,6 +25,8 @@ } rebuild_feeds(); + + if($config['activitypub'] == true) activitypub_notify_followers($id); if($config['ping'] == true) ping_microblog(); if($config['crosspost_to_twitter'] == true) { $twitter_response = json_decode(twitter_post_status($_POST['content']), true); @@ -42,7 +44,7 @@ $title_suffix = 'new post'; require(ROOT.DS.'snippets'.DS.'header.snippet.php'); -?><body> +?><body ontouchstart=""> <div class="wrap"> <?php require(ROOT.DS.'snippets'.DS.'nav.snippet.php'); ?> <?php if(isset($message['status']) && isset($message['message'])): ?> diff --git a/templates/single.inc.php b/templates/single.inc.php index 5514742..35c9244 100644 --- a/templates/single.inc.php +++ b/templates/single.inc.php @@ -7,6 +7,7 @@ if(mb_strtolower(path(1)) == 'delete') $action = 'delete'; if(mb_strtolower(path(1)) == 'undelete') $action = 'undelete'; if(mb_strtolower(path(1)) == 'edit') $action = 'edit'; + if(mb_strtolower(path(1)) == 'json') $action = 'json'; $error = false; if($config['logged_in']) { @@ -18,8 +19,15 @@ if(!$result) { $error = 'Post could not be deleted!'; } else { + rebuild_feeds(); + if($config['activitypub']) { + // todo: send DELETE activity to followers + // https://www.w3.org/TR/activitypub/#delete-activity-inbox + activitypub_delete_post($_POST['id']); + } + header('Location: '.$config['url']); die(); } @@ -72,6 +80,12 @@ } else { rebuild_feeds(); + if($config['activitypub']) { + // todo: send UPDATE activity to followers + // https://www.w3.org/TR/activitypub/#update-activity-inbox + activitypub_update_post($_POST['id']); + } + header('Location: '.$config['url'].'/'.$_POST['id']); die(); } @@ -86,10 +100,19 @@ } } + if($action == 'json') { + + $json = activitypub_activity_from_post($post, true); + + header('Content-Type: application/ld+json'); + echo($json); + die(); + } + $title_suffix = 'entry #' . $id; require(ROOT.DS.'snippets'.DS.'header.snippet.php'); -?><body> +?><body ontouchstart=""> <div class="wrap"> <?php require(ROOT.DS.'snippets'.DS.'nav.snippet.php'); ?> <ul class="posts"> @@ -150,14 +173,24 @@ <?php endif; ?> </span> <nav class="post-meta"> - <?php if($config['logged_in']): ?><ul> + <ul> + <?php if($config['activitypub']): + // todo: is it possible to retrieve this at the same time as post data? + $post_stats = activitypub_get_post_stats('both', $post['id']); + ?> + <li class="post-likes"><a href="<?= $config['url'] ?>/<?= $post['id'] ?>/likes" title="This post has been liked <?= $post_stats['like'] ?> times in the Fediverse"><span class="amount"><?= $post_stats['like'] ?></span><span class="word">Likes</span></a></li> + <li class="post-boosts"><a href="<?= $config['url'] ?>/<?= $post['id'] ?>/boosts" title="This post has been announced <?= $post_stats['announce'] ?> times in the Fediverse"><span class="amount"><?= $post_stats['announce'] ?></span><span class="word">Boosts</span></a></li> + <?php endif; ?> + + <?php if($config['logged_in']): ?> <?php if(is_numeric($post['post_deleted'])): ?> <li><a href="<?= $config['url'] ?>/<?= $post['id'] ?>/undelete" title="Restore">Deleted on <?= date('M d Y', $post['post_deleted']) ?></a></li> <?php else: ?> <li><a href="<?= $config['url'] ?>/<?= $post['id'] ?>/edit">Edit</a></li> <li><a href="<?= $config['url'] ?>/<?= $post['id'] ?>/delete">Delete</a></li> <?php endif; ?> - </ul><?php endif; ?> + <?php endif; ?> + </ul> </nav> <div class="post-content"><?= nl2br(autolink($post['post_content'])) ?></div> <?php if(!empty($attachments) && !empty($attachments[$post['id']])): ?> diff --git a/templates/timeline.inc.php b/templates/timeline.inc.php index d07776d..61a889d 100644 --- a/templates/timeline.inc.php +++ b/templates/timeline.inc.php @@ -23,7 +23,7 @@ $title_suffix = ''; require(ROOT.DS.'snippets'.DS.'header.snippet.php'); -?><body> +?><body ontouchstart=""> <div class="wrap"> <?php require(ROOT.DS.'snippets'.DS.'nav.snippet.php'); ?> <ul class="posts"> @@ -46,10 +46,20 @@ <?php endif; ?> </a> <nav class="post-meta"> - <?php if($config['logged_in']): ?><ul> + <ul> + <?php if($config['activitypub']): + // todo: is it possible to retrieve this at the same time as post data? + $post_stats = activitypub_get_post_stats('both', $post['id']); + ?> + <li class="post-likes"><a href="<?= $config['url'] ?>/<?= $post['id'] ?>/likes" title="This post has been liked <?= $post_stats['like'] ?> times in the Fediverse"><span class="amount"><?= $post_stats['like'] ?></span><span class="word">Likes</span></a></li> + <li class="post-boosts"><a href="<?= $config['url'] ?>/<?= $post['id'] ?>/boosts" title="This post has been announced <?= $post_stats['announce'] ?> times in the Fediverse"><span class="amount"><?= $post_stats['announce'] ?></span><span class="word">Boosts</span></a></li> + <?php endif; ?> + + <?php if($config['logged_in']): ?> <li><a href="<?= $config['url'] ?>/<?= $post['id'] ?>/edit">Edit</a></li> <li><a href="<?= $config['url'] ?>/<?= $post['id'] ?>/delete">Delete</a></li> - </ul><?php endif; ?> + <?php endif; ?> + </ul> </nav> <div class="post-content"><?= nl2br(autolink($post['post_content'])) ?></div> <?php if(!empty($attachments) && !empty($attachments[$post['id']])): ?> |