aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorArno Richter <oelna@oelna.de>2023-08-16 14:52:58 +0200
committerArno Richter <oelna@oelna.de>2023-08-16 14:52:58 +0200
commit66c6658bac8b0e99b59e3b9f4eb285f38bcebcf5 (patch)
treefa54ab21d4c6122df124459030dd5c6af723f1af
parentff2858b6ea8f586daa95e51ae21315f86cc5ded5 (diff)
downloadmicroblog-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!
-rw-r--r--.gitignore2
-rw-r--r--.htaccess6
-rw-r--r--config-dist.php18
-rw-r--r--css/microblog/icons/icon-announce.svg4
-rw-r--r--css/microblog/icons/icon-like.svg4
-rw-r--r--css/microblog/microblog.css (renamed from css/microblog.css)34
-rw-r--r--css/plain/icons/icon-announce.svg4
-rw-r--r--css/plain/icons/icon-like.svg4
-rw-r--r--css/plain/plain.css451
-rw-r--r--favicon-large.pngbin0 -> 5463 bytes
-rw-r--r--favicon.icobin318 -> 6518 bytes
-rw-r--r--index.php26
-rw-r--r--js/microblog.js6
-rw-r--r--lib/activitypub-actor.php55
-rw-r--r--lib/activitypub-followers.php70
-rw-r--r--lib/activitypub-functions.php521
-rw-r--r--lib/activitypub-inbox.php196
-rw-r--r--lib/activitypub-outbox.php72
-rw-r--r--lib/activitypub-webfinger.php4
-rw-r--r--lib/database.php66
-rw-r--r--lib/functions.php96
-rw-r--r--lib/xmlrpc.php250
-rw-r--r--snippets/header.snippet.php15
-rw-r--r--templates/loginform.inc.php2
-rw-r--r--templates/postform.inc.php4
-rw-r--r--templates/single.inc.php39
-rw-r--r--templates/timeline.inc.php16
27 files changed, 1888 insertions, 77 deletions
diff --git a/.gitignore b/.gitignore
index abf17d6..4104f40 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,5 +3,5 @@ config.php
feed.*
feeds/
files/
-keys/
+test.php
log.txt
diff --git a/.htaccess b/.htaccess
index 11ad211..5c21e0d 100644
--- a/.htaccess
+++ b/.htaccess
@@ -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
new file mode 100644
index 0000000..af5aca6
--- /dev/null
+++ b/favicon-large.png
Binary files differ
diff --git a/favicon.ico b/favicon.ico
index 550d600..46adbbd 100644
--- a/favicon.ico
+++ b/favicon.ico
Binary files differ
diff --git a/index.php b/index.php
index 9429f19..6b1a49c 100644
--- a/index.php
+++ b/index.php
@@ -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']])): ?>